diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 60530151..1d2b79a6 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -224,6 +224,10 @@ jobs: with: r-version: '4.4.2' use-public-rspm: true + - name: Install R sysdeps (libuv for fs package) + if: runner.os == 'Linux' + shell: bash + run: sudo apt-get update && sudo apt-get install -y libuv1-dev - name: Install R language server shell: bash run: | @@ -231,10 +235,10 @@ jobs: - name: Set up Julia uses: julia-actions/setup-julia@v2 with: - version: '1.10' + version: '1.12.6' - name: Install Julia LanguageServer shell: bash - run: julia -e 'using Pkg; Pkg.add("LanguageServer")' + run: julia -e 'using Pkg; Pkg.add(Pkg.PackageSpec(name="LanguageServer", version="5.0.0"))' - name: Setup Haskell toolchain if: runner.os != 'Windows' uses: haskell/ghcup-setup@v1 diff --git a/.gitignore b/.gitignore index dbc94920..37c1649c 100644 --- a/.gitignore +++ b/.gitignore @@ -79,6 +79,8 @@ downloads/ eggs/ .eggs/ lib/ +!test/resources/repos/dart/test_repo/lib/ +!test/resources/repos/dart/test_repo/lib/diagnostics_sample.dart lib64/ parts/ sdist/ @@ -271,4 +273,4 @@ cabal.project.local* zz-misc/ vue-implementation/ -news/news.json \ No newline at end of file +news/news.json diff --git a/.serena/project.yml b/.serena/project.yml index 84b6fcf8..aa5d12e3 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -3,15 +3,18 @@ project_name: "serena" # list of languages for which language servers are started; choose from: -# al bash clojure cpp csharp -# csharp_omnisharp dart elixir elm erlang -# fortran fsharp go groovy haskell -# java julia kotlin lua markdown -# matlab nix pascal perl php -# php_phpactor powershell python python_jedi r -# rego ruby ruby_solargraph rust scala -# swift terraform toml typescript typescript_vts -# vue yaml zig +# al ansible bash clojure cpp +# cpp_ccls crystal csharp csharp_omnisharp dart +# elixir elm erlang fortran fsharp +# go groovy haskell haxe hlsl +# java json julia kotlin lean4 +# lua luau markdown matlab msl +# nix ocaml pascal perl php +# php_phpactor powershell python python_jedi python_ty +# r rego ruby ruby_solargraph rust +# scala solidity swift systemverilog terraform +# toml typescript typescript_vts vue yaml +# zig # (This list may be outdated. For the current list, see values of Language enum here: # https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py # For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) @@ -28,6 +31,7 @@ project_name: "serena" languages: - python - typescript +- clojure # whether to use project's .gitignore files to ignore files ignore_all_files_in_gitignore: true @@ -44,49 +48,12 @@ read_only: false # list of tool names to exclude. # This extends the existing exclusions (e.g. from the global configuration) -# -# Below is the complete list of tools for convenience. -# To make sure you have the latest list of tools, and to view their descriptions, -# execute `uv run scripts/print_tool_overview.py`. -# * `activate_project`: Activates a project based on the project name or path. -# * `check_onboarding_performed`: Checks whether project onboarding was already performed. -# * `create_text_file`: Creates/overwrites a file in the project directory. -# * `delete_memory`: Delete a memory file. Should only happen if a user asks for it explicitly, -# for example by saying that the information retrieved from a memory file is no longer correct -# or no longer relevant for the project. -# * `edit_memory`: Replaces content matching a regular expression in a memory. -# * `execute_shell_command`: Executes a shell command. -# * `find_file`: Finds files in the given relative paths -# * `find_referencing_symbols`: Finds symbols that reference the given symbol using the language server backend -# * `find_symbol`: Performs a global (or local) search using the language server backend. -# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. -# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. -# * `initial_instructions`: Provides instructions Serena usage (i.e. the 'Serena Instructions Manual') -# for clients that do not read the initial instructions when the MCP server is connected. -# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. -# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. -# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). -# * `list_memories`: List available memories. Any memory can be read using the `read_memory` tool. -# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). -# * `read_file`: Reads a file within the project directory. -# * `read_memory`: Read the content of a memory file. This tool should only be used if the information -# is relevant to the current task. You can infer whether the information -# is relevant from the memory file name. -# You should not read the same memory file multiple times in the same conversation. -# * `rename_memory`: Renames or moves a memory. Moving between project and global scope is supported -# (e.g., renaming "global/foo" to "bar" moves it from global to project scope). -# * `rename_symbol`: Renames a symbol throughout the codebase using language server refactoring capabilities. -# For JB, we use a separate tool. -# * `replace_content`: Replaces content in a file (optionally using regular expressions). -# * `replace_symbol_body`: Replaces the full definition of a symbol using the language server backend. -# * `safe_delete_symbol`: -# * `search_for_pattern`: Performs a search for a pattern in the project. -# * `write_memory`: Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format. -# The memory name should be meaningful. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html excluded_tools: [] # list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). # This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html included_optional_tools: [] # initial prompt for the project. It will always be given to the LLM upon activating the project @@ -121,14 +88,17 @@ encoding: utf-8 base_modes: # list of mode names that are to be activated by default. -# The full set of modes to be activated is base_modes + default_modes. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. # If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. # Otherwise, this overrides the setting from the global configuration (serena_config.yml). # This setting can, in turn, be overridden by CLI parameters (--mode). +# Set this to [] to not use the default modes defined in the global config for this project. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes default_modes: # fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. # This cannot be combined with non-empty excluded_tools or included_optional_tools. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html fixed_tools: [] # time budget (seconds) per tool call for the retrieval of additional symbol information @@ -166,3 +136,10 @@ ignored_memory_patterns: [] # 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. ls_specific_settings: {} + +# list of mode names to be activated additionally for this project. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# Otherwise, this setting overrides the global configuration. +# Set this to a list of mode names to always include the respective modes for this project. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: diff --git a/CHANGELOG.md b/CHANGELOG.md index f9a0389a..bb10e5b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,35 @@ Status of the `main` branch. Changes prior to the next official version change will appear here. +* Hooks: + - `serena-hooks auto-approve` now also emits an `allow` decision when Claude Code reports + `permission_mode == "auto"`, in addition to the existing `acceptEdits` behavior. #1386 + +* General: + - Breaking change in mode definitions: Projects (project.yml) can no longer override `base_modes`. + Instead, they can define `added_modes` to add modes on top of base and default modes. + See updated [documentation on modes](https://oraios.github.io/serena/02-usage/050_configuration.html#modes). + - Serena's default configuration now uses `interactive` and `editing` as `base_modes` instead of as `default_modes`. + - Add cross-package reference support via `additional_workspace_folders` setting (currently implemented for TypeScript). + +* JetBrains: + - Add new tools: + - `jet_brains_list_inspections`: Lists available IDE inspections (akin to diagnostics), optionally filtered by language or group + - `jet_brains_run_inspections`: Runs IDE inspections on a file and returns the results + +* LSP Tools: + - Add new tools: + - `find_declaration`: Finds the declaration/definition of a symbol + - `find_implementations`: Finds the implementations of an interface or abstract method + - `get_diagnostics_for_file`: Retrieves diagnostics for a specific file (errors, warnings, etc.) + - `get_diagnostics_for_symbol`: Retrieves diagnostics pertaining to a specific symbol + +* Language Servers: + - Java (`eclipse.jdt.ls`): Add upstream JDTLS mode for offline / restricted-network use. Setting both `jdtls_path` and `lombok_path` in `ls_specific_settings.java` makes Serena use an existing upstream JDTLS installation (e.g. `brew install jdtls`) and the system JDK 21+, skipping the ~500 MB vscode-java VSIX, Gradle, and IntelliCode downloads. New related setting `java_home` lets the user override the JDK used to launch JDTLS. Default behavior unchanged — the JDTLS workspace hash is preserved bit-for-bit for users on the default route, so existing project caches are reused without a one-time reindex; the launcher path is mixed into the hash only when `jdtls_path` is set, isolating upstream installations from the default workspace. #1415 + +# v1.2.0 (2026-04-27) + * General: - - Add cross-package reference support via `additional_workspace_folders` project setting (currently implemented for TypeScript). - Configure additional workspace folder paths in `project.yml` to enable `find_referencing_symbols` and `request_references` - to discover symbols across package boundaries in monorepos. The base `SolidLanguageServer` class provides the workspace - folder resolution and activation framework; other language servers can enable support by overriding - `_find_representative_source_file()`. #750 - - Support `serena --version` CLI command for displaying the current version #1347 - Fix: Check for ignored path ignored `.git` folder only at the top level, not in every subdirectory (`Project._is_ignored_relative_path`) #1350 - `GetSymbolsOverviewTool`: ignored paths were not respected in LSP variant (fix in `SolidLanguageServer`) - Fix: Duplicate comments in re-saved YAML configuration files #1285 @@ -36,6 +58,13 @@ Status of the `main` branch. Changes prior to the next official version change w The `initial_instructions` tool provides the full prompt on demand, keeping the initial context lean. - Add `serena_info` tool for on-demand retrieval of usage information +* CLI: + - Support `serena --version` CLI command for displaying the current version #1347 + - Extend `prompts` subcommand with `print-prompt-template` and `print-cc-system-prompt-override`, improve `list` subcommand + +* Clients: + - Document workaround to make Claude Code use Serena's tools after recent degradations caused by changes in CC harness and Opus 4.7 release. + * JetBrains: - Add `debug` tool: The agent can set breakpoints, inspect variables, evaluate expressions and control execution flow by directly interacting with the IDE's debugger, using a REPL-style interface for maximum flexibility. @@ -55,11 +84,14 @@ Status of the `main` branch. Changes prior to the next official version change w - Add JSON language server support via `vscode-json-languageserver` (experimental) #1391 - Fix: Elixir/Expert deadlock on startup — Expert's build pipeline requires a `textDocument/didOpen` notification to start; Serena now opens `mix.exs` immediately after `initialized` so Expert begins compiling instead of waiting indefinitely #1397 -Dashboard: +* Dashboard: - Add configurable dashboard interface mode (new global configuration setting `web_dashboard_interface`): Three modes (browser, native app with tray, tray manager for aggregating multiple instances) are supported, depending on the OS - Fix: Memory leaks in frontend when using Chromium-based browsers/Windows webview #1389 +* Hooks: + - Adjusted wording of startup hook, improving project activation instructions #1401. + # v1.1.2 (2026-04-14) * General: diff --git a/README.md b/README.md index 0c819fb4..de04fa91 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

- Serena is the IDE for your coding agent. + The IDE for Your Coding Agent

@@ -145,9 +145,10 @@ without reading entire files. | find referencing symbols | yes | yes | | search in project dependencies | -- | yes | | type hierarchy | -- | yes | -| find declaration | -- | yes | -| find implementations | -- | yes | +| find declaration | yes | yes | +| find implementations | yes | yes | | query external projects | yes | yes | +| diagnostics/inspections | yes | yes | ### Refactoring diff --git a/docs/02-usage/030_clients.md b/docs/02-usage/030_clients.md index c65bfe0b..cbfe0d4e 100644 --- a/docs/02-usage/030_clients.md +++ b/docs/02-usage/030_clients.md @@ -101,8 +101,32 @@ You can do this in Tools / GitHub Copilot / Chat, where at the bottom you can cl ## Claude Code -Serena is a great way to make Claude Code both cheaper and more powerful! -To add Serena to Claude Code, you can simply run `serena setup claude-code`. Alternatively, follow the instructions below. +Serena is a great way to make Claude Code both more efficient and more powerful! +To set up the Serena MCP server for Claude Code, you can simply run this command: + + serena setup claude-code + +Find manual setup instructions as well as workarounds for Claude Code's recent regressions pertaining to (external) tool use below. + +:::{attention} +Recent updates to Claude Code (CC) and to the Opus line of models resulted in drastically reduced +adherence to instructions pertaining to Serena's tools. + +After extensive analysis, we identified part of the reason to be very long and detailed +tool descriptions for built-in tools and parts of the default system prompt. +The descriptions of CC's system tools take almost 16k tokens, cannot be adjusted by the user, +and introduce a very strong bias towards internal tools, making it almost impossible to convince Opus 4.7 to use Serena. + +As a workaround, we crafted a system prompt that counteracts this bias. +When using Serena, we highly recommend that you start CC as + +```shell +claude --system-prompt="$(serena prompts print-cc-system-prompt-override)" +``` + +You can also consider adding the content of `serena cc-system-prompt-override` to your `CLAUDE.md` files, +but the effect be insufficient for counteracting Claude Code's bias towards internal tools. +::: **Global Configuration**. To add the Serena MCP server for all your projects, use the user-level configuration of claude code and the `--project-from-cwd` flag: @@ -194,9 +218,9 @@ The hooks will: `grep` or `read_file` calls without using any Serena tools in between. - **`activate`**: Prompt the agent to activate the project and read Serena's instructions at session start. - **`cleanup`**: Clean up hook session data when the session ends. -- **`auto-approve`**: Auto-approve Serena tool calls whenever Claude Code is in `acceptEdits` mode, - so blanket edit approvals cover Serena's destructive tools (e.g. `replace_symbol_body`, - `rename_symbol`) instead of prompting on every call. +- **`auto-approve`**: Auto-approve Serena tool calls whenever Claude Code is in a permissive + permission mode (`acceptEdits` or `auto`), so blanket approvals cover Serena's destructive + tools (e.g. `replace_symbol_body`, `rename_symbol`) instead of prompting on every call. For more details on Claude Code's hook system, see the [Claude Code hooks documentation](https://code.claude.com/docs/en/hooks). diff --git a/docs/02-usage/040_workflow.md b/docs/02-usage/040_workflow.md index c5d9cc52..d4482f49 100644 --- a/docs/02-usage/040_workflow.md +++ b/docs/02-usage/040_workflow.md @@ -18,20 +18,22 @@ setting up a project with Serena typically involves the following steps: Project creation is the process of defining fundamental project settings that are relevant to Serena's operation. You can create a project either + * explicitly, using the project creation command (see below), or * implicitly, by just activating a directory as a project while already in a conversation; this will use default settings for your project (skip to the next section). - * explicitly, using the project creation command, or ### Explicit Project Creation To explicitly create a project, use the following command while in the project directory: - serena project create [options] + serena project create [options] [project directory] - * For an empty project, you will need to specify the programming language - (e.g., `--language python`). - * For an existing project, the main programming language will be detected automatically, - but you can choose to explicitly specify multiple languages by passing the `--language` parameter - multiple times (e.g. `--language python --language typescript`). + * The project directory defaults to the current directory if not specified. + * For an existing project, the programming languages will be detected based on + the source files present, and the main language will be activated automatically. + If multiple languages are detected, you will be prompted whether you want to enable them. + * For an empty project, you can optionally specify one or more languages + to be activated explicitly via the `--language` parameter + (e.g. `--language python --language typescript`). * You can optionally specify a custom project name with `--name my-name`. * You can immediately index the project after creation with `--index`. @@ -42,6 +44,7 @@ After creation, you can adjust the project settings in the generated `.serena/pr within the project directory. The file allows you to configure ... + * the name by which you want to refer to the project (relevant when telling the LLM to dynamically activate the project) * the set of programming languages for which language servers are spawned (not relevant when using the JetBrains plugin) Note that you can dynamically add/remove language servers while Serena is running via the [Dashboard](060_dashboard). * the [language backend](per-project-language-backend) to use for this project (overriding the global setting) @@ -50,8 +53,8 @@ The file allows you to configure ... * write access * [additional workspace folders](additional-workspace-folders) for cross-package reference support in monorepos * an initial prompt that shall be passed to the LLM whenever the project is activated - * the name by which you want to refer to the project (relevant when telling the LLM to dynamically activate the project) - * the set of tools and modes to use by default + * the set of tools and modes to use for the project + * and some other settings. For detailed information on the parameters and possible settings, see the [template file](https://github.com/oraios/serena/blob/main/src/serena/resources/project.template.yml). diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 0c3c5198..7d83a86d 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -118,32 +118,26 @@ Examples of built-in modes include: Find the concrete definitions of these modes [here](https://github.com/oraios/serena/tree/main/src/serena/resources/config/modes). -Active modes are configured in (from lowest to highest precedence): +The modes to be activated are configured in: * the global configuration file (`serena_config.yml`) + - defines `base_modes`, which are always included + - defines `default_modes`, which can be overridden by projects or command line parameters * the project configuration file (`project.yml`) + - defines `default_modes` (overriding the default modes in the global configuration) + - defines `added_modes`, which are added on top * at startup via command-line parameters + - can override default modes with `--mode` + - can define modes to be added on top with `--add-mode` -The two former sources define both **base modes** and **default modes**. -Ultimately, the active modes are the union of base modes and default modes (after applying all overrides). -Command-line parameters override default modes but not base modes. -Base modes should thus be used to define modes that you always want to be active, regardless of command-line parameters. +Ultimately, the active modes are given by the union of + * `base_modes` defined in the global configuration (always active) + * `default_modes` (defined in the global configuration, optionally overridden by the project/CLI) + * `added_modes` (defined in the project configuration/via CLI parameters) -Command-line parameters for overriding default modes: -When launching the MCP sever, specify modes using `--mode `; multiple modes can be specified, e.g. `--mode planning --mode no-onboarding`. - -:::{important} -By default, Serena activates the two modes `interactive` and `editing` (as defined in the global configuration). - -As soon as you start to specify modes via the command line, 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 -``` - -If you want to keep certain modes as always active, regardless of command-line parameters, -define them as *base modes* in the global or project configuration. -::: +So you should + * define modes you definitely always want to use in `base_modes`, + * define modes that you typically want to use but sometimes want to override in `default_modes`, + * use `added_modes` to add modes that you need only for specific projects/sessions. :::{note} **Mode Compatibility**: While you can combine modes, some may be semantically incompatible (e.g., `interactive` and `one-shot`). @@ -504,28 +498,72 @@ ls_specific_settings: #### Java (`eclipse.jdt.ls`) +Java support has two installation modes: + +1. **Default vscode-java VSIX mode** (no extra config required): Serena downloads the platform-specific + vscode-java VSIX (~500 MB: JDTLS + bundled JRE 21 + Lombok + IntelliCode), Gradle distribution and + IntelliCode VSIX from public hosts on first use. +2. **Upstream JDTLS mode** (offline-friendly): Activated by setting both `jdtls_path` and `lombok_path`. + Uses an existing JDTLS installation (~100 MB) and the system JDK 21+. Nothing is downloaded. + Recommended for restricted-network/corporate environments. + +**When to use which mode:** + +- **Default vscode-java VSIX mode** — recommended for most users. No setup required; + Serena downloads everything on first use. +- **Upstream JDTLS mode** — recommended when: + - you cannot reach `github.com`, `services.gradle.org` or `marketplace.visualstudio.com` + from the host (corporate proxy, air-gapped network); + - you want a smaller on-disk footprint (~100 MB vs ~500 MB); + - you already maintain a JDTLS installation (e.g. for `nvim-jdtls` or another editor); + - your security policy prohibits per-project runtime downloads. + +**JDK 21+ is required** in upstream mode. Serena resolves the JDK in this order: +`ls_specific_settings.java.java_home` → `JAVA_HOME` env var → first `java` on `PATH`. +The resolved JVM is interrogated and rejected if its `java.specification.version` is below 21. + The following settings are supported for the Java language server: | Setting | Default | Description | |---|---|---| +| `jdtls_path` | `null` | Activates upstream JDTLS mode. Path to upstream JDTLS root (containing `plugins/` and `config_/`). Get via `brew install jdtls` or extract `jdt-language-server-*.tar.gz` from . Must be set together with `lombok_path`. | +| `lombok_path` | `null` | Path to the Lombok jar. Activates upstream JDTLS mode together with `jdtls_path`. Get from `~/.m2/repository/org/projectlombok/lombok//lombok-.jar` or download from . | +| `java_home` | `null` | (upstream-jdtls mode only) Path to JDK 21+ home directory used to launch JDTLS. Falls back to `JAVA_HOME` env var, then `which java`. | | `maven_user_settings` | `~/.m2/settings.xml` | Path to Maven `settings.xml` | | `gradle_user_home` | `~/.gradle` | Path to Gradle user home directory | | `gradle_wrapper_enabled` | `false` | Use the project's Gradle wrapper (`gradlew`) instead of the bundled Gradle distribution. Enable this for projects with custom plugins or repositories. | | `gradle_java_home` | `null` | Path to the JDK used by Gradle. When unset, Gradle uses the bundled JRE. | | `use_system_java_home` | `false` | Use the system's `JAVA_HOME` environment variable for JDTLS itself. Enable this if your project requires a specific JDK vendor or version for Gradle's JDK checks. | -| `gradle_version` | `8.14.2` | Override the Gradle distribution version Serena downloads by default. | -| `vscode_java_version` | `1.42.0-561` | Override the bundled `vscode-java` runtime bundle version Serena downloads by default. | -| `intellicode_version` | `1.2.30` | Override the IntelliCode VSIX version Serena downloads by default. | +| `gradle_version` | `8.14.2` | (vscode-java mode only) Override the Gradle distribution version Serena downloads by default. | +| `vscode_java_version` | `1.42.0-561` | (vscode-java mode only) Override the bundled `vscode-java` runtime bundle version Serena downloads by default. | +| `intellicode_version` | `1.2.30` | (vscode-java mode only) Override the IntelliCode VSIX version Serena downloads by default. | | `jdtls_xmx` | `3G` | Maximum heap size for the JDTLS server JVM. | | `jdtls_xms` | `100m` | Initial heap size for the JDTLS server JVM. | -| `intellicode_xmx` | `1G` | Maximum heap size for the IntelliCode embedded JVM. | -| `intellicode_xms` | `100m` | Initial heap size for the IntelliCode embedded JVM. | +| `intellicode_xmx` | `1G` | (vscode-java mode only) Maximum heap size for the IntelliCode embedded JVM. | +| `intellicode_xms` | `100m` | (vscode-java mode only) Initial heap size for the IntelliCode embedded JVM. | -Note: +Notes: - When overriding `vscode_java_version`, Serena still assumes that the downloaded runtime bundle keeps the same internal directory layout and file names as the bundled default version. +- In upstream-jdtls mode, IntelliCode is not loaded (it's an ML completions ranker that is irrelevant to Serena's + symbol-tools workflow), and Serena does not ship a Gradle distribution. Maven projects work via JDTLS's bundled m2e. + Gradle projects must have `./gradlew` in the project, or rely on a system-installed Gradle through Buildship's + default discovery rules. +- In upstream-jdtls mode the `gradle_version`, `vscode_java_version`, `intellicode_version`, + `intellicode_xmx`, `intellicode_xms` settings are silently ignored — they only apply to the + vscode-java VSIX mode. -Example for a project with custom Gradle plugins and JDK requirements: +Example: upstream-jdtls mode (offline / corporate network): + +```yaml +ls_specific_settings: + java: + jdtls_path: "/opt/homebrew/Cellar/jdtls/1.50.0/libexec" + lombok_path: "/Users/me/.m2/repository/org/projectlombok/lombok/1.18.36/lombok-1.18.36.jar" + # java_home: "/opt/homebrew/opt/openjdk@21" # optional +``` + +Example: default vscode-java VSIX mode for a project with custom Gradle plugins: ```yaml ls_specific_settings: diff --git a/news/20260427.html b/news/20260427.html new file mode 100644 index 00000000..0cdba681 --- /dev/null +++ b/news/20260427.html @@ -0,0 +1,27 @@ +
+

Serena v1.2.0

+

April 27, 2026

+

+ Interactive Debugging. + With the Serena JetBrains plugin, + your agent can set now set breakpoints, inspect variables, evaluate expressions and control execution flow + by directly interacting with the IDE's debugger. + Our REPL-based interface is highly flexible, allowing all of this to be provided via a single tool. +

+

+ Counteracting Claude Code Regressions. + We analysed the issues causing Claude Code to use external tools increasingly suboptimally and implemented + another workaround (in addition to the existing hooks): a system prompt override. + Read our updated Claude code usage instructions for more information. +

+

+ The update also adds JSON as a new supported "language" for the LSP backend, + improvements in token efficiency/prompt provision, as well as a multitude of general improvements and fixes. + See our change log for details.
+

+

+ Update Now. + If you are using the uv tool installation of Serena, upgrade as follows:
+ uv tool upgrade serena-agent --prerelease=allow +

+
\ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 837800e8..35c00d2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = ["hatchling"] [project] name = "serena-agent" -version = "1.1.2" +version = "1.2.0" description = "A powerful MCP toolkit for coding, providing semantic retrieval and editing capabilities - the IDE for your agent" authors = [{ name = "Oraios AI", email = "info@oraios-ai.de" }] readme = "README.md" diff --git a/scripts/demo_cli_call.py b/scripts/demo_cli_call.py new file mode 100644 index 00000000..9639a9a8 --- /dev/null +++ b/scripts/demo_cli_call.py @@ -0,0 +1,4 @@ +from serena.cli import top_level + +if __name__ == "__main__": + top_level(["--help"]) diff --git a/scripts/demo_diagnostics.py b/scripts/demo_diagnostics.py new file mode 100644 index 00000000..2e5cd30e --- /dev/null +++ b/scripts/demo_diagnostics.py @@ -0,0 +1,176 @@ +""" +Demonstrates diagnostics tools and edit-tool diagnostic reporting on the Serena repo itself. + +The script creates a temporary Python file inside this repository, introduces one warning, +shows file and symbol diagnostics, then introduces another warning and verifies that the +second edit reports only the newly introduced warning. +""" + +import json +import shutil +import tempfile +from pathlib import Path +from pprint import pprint + +from serena.agent import SerenaAgent +from serena.config.serena_config import LanguageBackend, ProjectConfig, RegisteredProject, SerenaConfig +from serena.constants import REPO_ROOT +from serena.project import Project +from serena.tools import ( + CreateTextFileTool, + EditingToolWithDiagnostics, + GetDiagnosticsForFileTool, + GetDiagnosticsForSymbolTool, + ReplaceContentTool, +) +from solidlsp.ls_config import Language + +SEPARATOR = "=" * 80 +REPO_PATH = Path(REPO_ROOT) +EDIT_RESULT_PREFIX = "Edit introduced new warning-or-higher diagnostics: " + + +def make_agent() -> SerenaAgent: + """Create an LSP-backed Serena agent for the Serena repository.""" + serena_config = SerenaConfig.from_config_file() + serena_config.web_dashboard = False + serena_config.language_backend = LanguageBackend.LSP + + project = Project( + project_root=str(REPO_PATH), + project_config=ProjectConfig( + project_name="demo_serena_repo", + languages=[Language.PYTHON], + ignored_paths=[], + excluded_tools=[], + read_only=False, + ignore_all_files_in_gitignore=True, + initial_prompt="", + encoding="utf-8", + ), + serena_config=serena_config, + ) + serena_config.projects = [RegisteredProject.from_project_instance(project)] + return SerenaAgent(project="demo_serena_repo", serena_config=serena_config) + + +def print_section(title: str) -> None: + """Print a visibly separated section header.""" + print(f"\n{SEPARATOR}") + print(title) + print(SEPARATOR) + + +def parse_json_result(result: str) -> object: + """Parse and pretty-print JSON tool output.""" + parsed = json.loads(result) + pprint(parsed, width=200) + return parsed + + +def parse_edit_diagnostics_result(result: str) -> dict: + """Extract the grouped diagnostics payload from an edit-tool result.""" + assert result.startswith(EDIT_RESULT_PREFIX), result + return json.loads(result[len(EDIT_RESULT_PREFIX) :]) + + +if __name__ == "__main__": + EditingToolWithDiagnostics.ENABLE_DIAGNOSTICS = True + + temp_dir = Path(tempfile.mkdtemp(prefix="serena_demo_", dir=REPO_PATH)) + temp_file = temp_dir / "demo_temp_diagnostics.py" + relative_path = temp_file.relative_to(REPO_PATH).as_posix() + + initial_content = """def demo_existing_issue() -> int: + value = 1 + return value +""" + + agent = make_agent() + + try: + # letting the language server finish startup + agent.execute_task(lambda: None) + + create_text_file_tool = agent.get_tool(CreateTextFileTool) + replace_content_tool = agent.get_tool(ReplaceContentTool) + get_diagnostics_for_file_tool = agent.get_tool(GetDiagnosticsForFileTool) + get_diagnostics_for_symbol_tool = agent.get_tool(GetDiagnosticsForSymbolTool) + + # creating a clean temporary file + print_section("Create Temporary File") + create_result = agent.execute_task(lambda: create_text_file_tool.apply(relative_path=relative_path, content=initial_content)) + print(create_result) + + # showing file diagnostics before introducing any warning + print_section("Initial File Diagnostics") + initial_diagnostics_result = agent.execute_task( + lambda: get_diagnostics_for_file_tool.apply(relative_path=relative_path, min_severity=2) + ) + initial_diagnostics = parse_json_result(initial_diagnostics_result) + assert initial_diagnostics == {}, initial_diagnostics + + # introducing the first warning + print_section("First Edit Result") + first_edit_result = agent.execute_task( + lambda: replace_content_tool.apply( + relative_path=relative_path, + needle="value = 1", + repl="value = missing_one", + mode="literal", + ) + ) + print(first_edit_result) + first_edit_diagnostics = parse_edit_diagnostics_result(first_edit_result) + pprint(first_edit_diagnostics, width=200) + assert "missing_one" in json.dumps(first_edit_diagnostics), first_edit_diagnostics + + # showing the file- and symbol-level diagnostics after the first warning + print_section("File Diagnostics After First Edit") + diagnostics_after_first_edit_result = agent.execute_task( + lambda: get_diagnostics_for_file_tool.apply(relative_path=relative_path, min_severity=2) + ) + diagnostics_after_first_edit = parse_json_result(diagnostics_after_first_edit_result) + assert "missing_one" in json.dumps(diagnostics_after_first_edit), diagnostics_after_first_edit + + print_section("Symbol Diagnostics After First Edit") + symbol_diagnostics_result = agent.execute_task( + lambda: get_diagnostics_for_symbol_tool.apply( + name_path="demo_existing_issue", + reference_file=relative_path, + min_severity=2, + ) + ) + symbol_diagnostics = parse_json_result(symbol_diagnostics_result) + assert "missing_one" in json.dumps(symbol_diagnostics), symbol_diagnostics + + # introducing a second warning while keeping the first one unchanged + print_section("Second Edit Result") + second_edit_result = agent.execute_task( + lambda: replace_content_tool.apply( + relative_path=relative_path, + needle=" return value\n", + repl=" other = missing_two\n return value + other\n", + mode="literal", + ) + ) + print(second_edit_result) + second_edit_diagnostics = parse_edit_diagnostics_result(second_edit_result) + pprint(second_edit_diagnostics, width=200) + second_edit_json = json.dumps(second_edit_diagnostics) + assert "missing_two" in second_edit_json, second_edit_diagnostics + assert "missing_one" not in second_edit_json, second_edit_diagnostics + print("\nVerified: the second edit result reports only the newly introduced warning.") + + # showing the complete file diagnostics after both warnings exist + print_section("File Diagnostics After Second Edit") + diagnostics_after_second_edit_result = agent.execute_task( + lambda: get_diagnostics_for_file_tool.apply(relative_path=relative_path, min_severity=2) + ) + diagnostics_after_second_edit = parse_json_result(diagnostics_after_second_edit_result) + diagnostics_after_second_edit_json = json.dumps(diagnostics_after_second_edit) + assert "missing_one" in diagnostics_after_second_edit_json, diagnostics_after_second_edit + assert "missing_two" in diagnostics_after_second_edit_json, diagnostics_after_second_edit + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + agent.shutdown() diff --git a/scripts/demo_find_defining_symbol.py b/scripts/demo_find_defining_symbol.py new file mode 100644 index 00000000..63bde56c --- /dev/null +++ b/scripts/demo_find_defining_symbol.py @@ -0,0 +1,112 @@ +""" +Demonstrates both defining-symbol tools on the Python test repository. +""" + +import json +import re +from pathlib import Path +from pprint import pprint + +from serena.agent import SerenaAgent +from serena.config.serena_config import LanguageBackend, ProjectConfig, RegisteredProject, SerenaConfig +from serena.constants import REPO_ROOT +from serena.project import Project +from serena.tools import FindDeclarationTool +from solidlsp.ls_config import Language + +SEPARATOR = "=" * 80 +PYTHON_TEST_REPO = Path(REPO_ROOT) / "test" / "resources" / "repos" / "python" / "test_repo" +SERVICES_FILE = Path("test_repo") / "services.py" + + +def make_agent(project_root: Path, language: Language, project_name: str) -> SerenaAgent: + """Create an LSP-backed Serena agent for a single explicit project.""" + serena_config = SerenaConfig.from_config_file() + serena_config.web_dashboard = False + serena_config.language_backend = LanguageBackend.LSP + + project = Project( + project_root=str(project_root), + project_config=ProjectConfig( + project_name=project_name, + languages=[language], + ignored_paths=[], + excluded_tools=[], + read_only=False, + ignore_all_files_in_gitignore=True, + initial_prompt="", + encoding="utf-8", + ), + serena_config=serena_config, + ) + serena_config.projects = [RegisteredProject.from_project_instance(project)] + return SerenaAgent(project=project_name, serena_config=serena_config) + + +def print_section(title: str) -> None: + """Print a visibly separated section header.""" + print(f"\n{SEPARATOR}") + print(title) + print(SEPARATOR) + + +def find_identifier_occurrence_position(file_path: Path, identifier: str, occurrence_index: int = 0) -> tuple[int, int]: + """Find the 0-based position of an identifier occurrence in a file.""" + pattern = re.compile(r"\b" + re.escape(identifier) + r"\b") + current_occurrence_index = 0 + with file_path.open(encoding="utf-8") as f: + for line_index, line in enumerate(f): + for match in pattern.finditer(line): + if current_occurrence_index == occurrence_index: + return line_index, match.start() + current_occurrence_index += 1 + raise ValueError(f"Could not find occurrence {occurrence_index} of {identifier!r} in {file_path}") + + +if __name__ == "__main__": + agent = make_agent(PYTHON_TEST_REPO, Language.PYTHON, "demo_python_test_repo") + + try: + # letting the language server finish startup + agent.execute_task(lambda: None) + + relative_path = SERVICES_FILE.as_posix() + services_abs_path = PYTHON_TEST_REPO / SERVICES_FILE + + # resolving via regex over the full file + find_by_regex_tool = agent.get_tool(FindDeclarationTool) + regex_result = agent.execute_task( + lambda: find_by_regex_tool.apply( + regex=r"from \.models import Item, (User)", + relative_path=relative_path, + include_info=True, + ) + ) + + print_section("FindDefiningSymbolTool (File Regex)") + regex_symbol = json.loads(regex_result) + pprint(regex_symbol, width=200) + + # resolving via regex restricted to one containing symbol body + contained_regex_result = agent.execute_task( + lambda: find_by_regex_tool.apply( + regex=r"=\s+(User)\(", + relative_path=relative_path, + containing_symbol_name_path="UserService/create_user", + include_info=True, + ) + ) + + print_section("FindDefiningSymbolTool (Contained Regex)") + contained_regex_symbol = json.loads(contained_regex_result) + pprint(contained_regex_symbol, width=200) + + # validating the demonstrated result + for symbol in [regex_symbol, contained_regex_symbol]: + assert symbol is not None, "Expected a defining symbol result" + assert symbol.get("relative_path") is not None + assert "models.py" in symbol["relative_path"], symbol + assert "User" in json.dumps(symbol), symbol + print("\nVerified definition target: User in models.py") + finally: + agent.shutdown() diff --git a/scripts/demo_find_implementing_symbol.py b/scripts/demo_find_implementing_symbol.py new file mode 100644 index 00000000..a19d4b94 --- /dev/null +++ b/scripts/demo_find_implementing_symbol.py @@ -0,0 +1,76 @@ +""" +Demonstrates FindImplementationsTool on the Go test repository. +""" + +import json +from pathlib import Path +from pprint import pprint + +from serena.agent import SerenaAgent +from serena.config.serena_config import LanguageBackend, ProjectConfig, RegisteredProject, SerenaConfig +from serena.constants import REPO_ROOT +from serena.project import Project +from serena.tools import FindImplementationsTool +from solidlsp.ls_config import Language + +SEPARATOR = "=" * 80 +GO_TEST_REPO = Path(REPO_ROOT) / "test" / "resources" / "repos" / "go" / "test_repo" + + +def make_agent(project_root: Path, language: Language, project_name: str) -> SerenaAgent: + """Create an LSP-backed Serena agent for a single explicit project.""" + serena_config = SerenaConfig.from_config_file() + serena_config.web_dashboard = False + serena_config.language_backend = LanguageBackend.LSP + + project = Project( + project_root=str(project_root), + project_config=ProjectConfig( + project_name=project_name, + languages=[language], + ignored_paths=[], + excluded_tools=[], + read_only=False, + ignore_all_files_in_gitignore=True, + initial_prompt="", + encoding="utf-8", + ), + serena_config=serena_config, + ) + serena_config.projects = [RegisteredProject.from_project_instance(project)] + return SerenaAgent(project=project_name, serena_config=serena_config) + + +def print_section(title: str) -> None: + """Print a visibly separated section header.""" + print(f"\n{SEPARATOR}") + print(title) + print(SEPARATOR) + + +if __name__ == "__main__": + agent = make_agent(GO_TEST_REPO, Language.GO, "demo_go_test_repo") + + try: + # letting the language server finish startup + agent.execute_task(lambda: None) + + # running the implementation lookup + find_implementations_tool = agent.get_tool(FindImplementationsTool) + result = agent.execute_task( + lambda: find_implementations_tool.apply( + name_path="Greeter/FormatGreeting", + relative_path="main.go", + include_info=True, + ) + ) + + print_section("Find Implementations Result") + implementations = json.loads(result) + pprint(implementations, width=200) + + # validating the demonstrated result + assert any(implementation["name_path"] == "(ConsoleGreeter).FormatGreeting" for implementation in implementations), result + print("\nVerified implementation target: (ConsoleGreeter).FormatGreeting") + finally: + agent.shutdown() diff --git a/scripts/demo_run_tools.py b/scripts/demo_run_tools.py index b0ed063b..1c95365c 100644 --- a/scripts/demo_run_tools.py +++ b/scripts/demo_run_tools.py @@ -8,14 +8,16 @@ from pathlib import Path from pprint import pprint from serena.agent import SerenaAgent -from serena.config.serena_config import SerenaConfig +from serena.config.serena_config import LanguageBackend, SerenaConfig from serena.constants import REPO_ROOT from serena.tools import ( FindFileTool, FindReferencingSymbolsTool, + GetDiagnosticsForFileTool, JetBrainsFindSymbolTool, JetBrainsGetSymbolsOverviewTool, JetBrainsInlineSymbol, + JetBrainsRunInspectionsTool, JetBrainsSafeDeleteTool, SearchForPatternTool, ) @@ -23,6 +25,7 @@ from serena.tools import ( if __name__ == "__main__": serena_config = SerenaConfig.from_config_file() serena_config.web_dashboard = False + serena_config.language_backend = LanguageBackend.LSP # project = Path(REPO_ROOT).parent / "serena-jetbrains-plugin-copy" project = Path(REPO_ROOT) agent = SerenaAgent(project=str(project), serena_config=serena_config) @@ -35,12 +38,13 @@ if __name__ == "__main__": overview_tool = agent.get_tool(JetBrainsGetSymbolsOverviewTool) safe_delete_tool = agent.get_tool(JetBrainsSafeDeleteTool) inline_symbol = agent.get_tool(JetBrainsInlineSymbol) + diagnostics_in_file_tool = agent.get_tool(GetDiagnosticsForFileTool) + jb_inspections_tool = agent.get_tool(JetBrainsRunInspectionsTool) result = agent.execute_task( - lambda: overview_tool.apply( + lambda: diagnostics_in_file_tool.apply( # name_path_pattern="SerenaAgent", - relative_path="src/serena/agent.py", - depth=2, + relative_path="test/resources/repos/clojure/test_repo/src/test_app/diagnostics_sample.clj", # keep_definition=True, ) ) diff --git a/src/interprompt/jinja_template.py b/src/interprompt/jinja_template.py index 63f4fd87..32220155 100644 --- a/src/interprompt/jinja_template.py +++ b/src/interprompt/jinja_template.py @@ -30,6 +30,9 @@ class JinjaTemplate(ParameterizedTemplateInterface): parsed_content = self._template.environment.parse(self._template_string) self._parameters = sorted(jinja2.meta.find_undeclared_variables(parsed_content)) + def get_template_string(self) -> str: + return self._template_string + def render(self, **params: Any) -> str: """Renders the template with the given kwargs. You can find out which parameters are required by calling get_parameter_names().""" return self._template.render(**params) diff --git a/src/interprompt/multilang_prompt.py b/src/interprompt/multilang_prompt.py index e7322efb..ba953b4c 100644 --- a/src/interprompt/multilang_prompt.py +++ b/src/interprompt/multilang_prompt.py @@ -12,13 +12,17 @@ log = logging.getLogger(__name__) class PromptTemplate(ToStringMixin, ParameterizedTemplateInterface): - def __init__(self, name: str, jinja_template_string: str) -> None: + def __init__(self, name: str, jinja_template_string: str, path: str) -> None: self.name = name + self.path = path self._jinja_template = JinjaTemplate(jinja_template_string.strip()) def _tostring_exclude_private(self) -> bool: return True + def get_template_string(self) -> str: + return self._jinja_template.get_template_string() + def render(self, **params: Any) -> str: return self._jinja_template.render(**params) @@ -249,6 +253,7 @@ class MultiLangPromptCollection: self, name: str, template_str: str, + path: str, lang_code: str = DEFAULT_LANG_CODE, on_name_collision: Literal["skip", "overwrite", "raise"] = "raise", ) -> None: @@ -259,7 +264,7 @@ class MultiLangPromptCollection: :param on_name_collision: how to deal with name/lang_code collisions """ allow_overwrite = False - prompt_template = PromptTemplate(name, template_str) + prompt_template = PromptTemplate(name, template_str, path=path) mlpt = self._multi_lang_prompt_templates.get(name) if mlpt is None: mlpt = MultiLangPromptTemplate(name) @@ -327,7 +332,7 @@ class MultiLangPromptCollection: self._add_prompt_list(prompt_name, prompt_template_or_list, lang_code=lang_code, on_name_collision=on_name_collision) elif isinstance(prompt_template_or_list, str): self._add_prompt_template( - prompt_name, prompt_template_or_list, lang_code=lang_code, on_name_collision=on_name_collision + prompt_name, prompt_template_or_list, lang_code=lang_code, path=path, on_name_collision=on_name_collision ) else: raise ValueError( diff --git a/src/interprompt/prompt_factory.py b/src/interprompt/prompt_factory.py index 1769e7f2..647b0ed2 100644 --- a/src/interprompt/prompt_factory.py +++ b/src/interprompt/prompt_factory.py @@ -2,7 +2,7 @@ import logging import os from typing import Any -from .multilang_prompt import DEFAULT_LANG_CODE, LanguageFallbackMode, MultiLangPromptCollection, PromptList +from .multilang_prompt import DEFAULT_LANG_CODE, LanguageFallbackMode, MultiLangPromptCollection, PromptList, PromptTemplate log = logging.getLogger(__name__) @@ -23,9 +23,18 @@ class PromptFactoryBase: self.lang_code = lang_code self._prompt_collection = MultiLangPromptCollection(prompts_dir, fallback_mode=fallback_mode) + def get_prompt_names(self) -> list[str]: + return self._prompt_collection.get_prompt_template_names() + + def get_prompt_template(self, prompt_name: str) -> PromptTemplate: + return self._prompt_collection.get_prompt_template(prompt_name, lang_code=self.lang_code) + + def get_prompt_template_string(self, prompt_name: str) -> str: + return self.get_prompt_template(prompt_name).get_template_string() + def _render_prompt(self, prompt_name: str, params: dict[str, Any]) -> str: del params["self"] - return self._prompt_collection.render_prompt_template(prompt_name, params, lang_code=self.lang_code) + return self.get_prompt_template(prompt_name).render(**params) def _get_prompt_list(self, prompt_name: str) -> PromptList: return self._prompt_collection.get_prompt_list(prompt_name, self.lang_code) @@ -41,14 +50,12 @@ def autogenerate_prompt_factory_module(prompts_dir: str, target_module_path: str :param prompts_dir: the directory containing the prompt templates and prompt lists :param target_module_path: the path to the target module file (.py). Important: The module will be overwritten! """ - generated_code = """ -# ruff: noqa + generated_code = """# ruff: noqa # black: skip # mypy: ignore-errors # NOTE: This module is auto-generated from interprompt.autogenerate_prompt_factory_module, do not edit manually! -from interprompt.multilang_prompt import PromptList from interprompt.prompt_factory import PromptFactoryBase from typing import Any @@ -68,6 +75,9 @@ class PromptFactory(PromptFactoryBase): else: method_params_str = ", *, " + ", ".join([f"{param}: Any" for param in template_parameters]) generated_code += f""" + def get_{template_name}_template_string(self) -> str: + return self.get_prompt_template_string('{template_name}') + def create_{template_name}(self{method_params_str}) -> str: return self._render_prompt('{template_name}', locals()) """ diff --git a/src/serena/__init__.py b/src/serena/__init__.py index ce2b1abf..1a89dded 100644 --- a/src/serena/__init__.py +++ b/src/serena/__init__.py @@ -1,4 +1,4 @@ -__version__ = "1.1.2" +__version__ = "1.2.0" import logging diff --git a/src/serena/agent.py b/src/serena/agent.py index c4062a5a..db67577d 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -31,6 +31,8 @@ from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode from serena.config.serena_config import ( LanguageBackend, ModeSelectionDefinition, + ModeSelectionDefinitionWithAddedModes, + ModeSelectionDefinitionWithBaseModes, NamedToolInclusionDefinition, RegisteredProject, SerenaConfig, @@ -206,18 +208,36 @@ class ActiveModes: def __init__(self) -> None: self._configured_base_modes: Sequence[str] | None = None self._configured_default_modes: Sequence[str] | None = None + self._added_modes: set[str] = set() + self._dynamically_activated_mode_names: set[str] = set() + """ + the subset of active mode names that are dynamically activated (not necessarily enabled after project change) + """ self._active_mode_names: Sequence[str] = [] + """ + the full list of active mode names + """ def apply(self, mode_selection: ModeSelectionDefinition) -> None: + log.debug("Applying mode selection definition %s", mode_selection) + # apply overrides - log.debug("Applying mode selection: default_modes=%s, base_modes=%s", mode_selection.default_modes, mode_selection.base_modes) - if mode_selection.base_modes is not None: - self._configured_base_modes = mode_selection.base_modes + if isinstance(mode_selection, ModeSelectionDefinitionWithBaseModes): + if mode_selection.base_modes is not None: + self._configured_base_modes = mode_selection.base_modes if mode_selection.default_modes is not None: self._configured_default_modes = mode_selection.default_modes log.debug("Current mode selection: base_modes=%s, default_modes=%s", self._configured_base_modes, self._configured_default_modes) - self._active_mode_names = sorted(set(self._configured_base_modes or []) | set(self._configured_default_modes or [])) + # apply added modes (if any) + if isinstance(mode_selection, ModeSelectionDefinitionWithAddedModes): + if mode_selection.added_modes: + log.debug("Adding modes: %s", mode_selection.added_modes) + self._added_modes.update(mode_selection.added_modes) + log.debug("Current added modes: %s", self._added_modes) + + self._dynamically_activated_mode_names = set(self._configured_default_modes or []) | self._added_modes + self._active_mode_names = sorted(set(self._configured_base_modes or []) | self._dynamically_activated_mode_names) def get_mode_names(self) -> Sequence[str]: return self._active_mode_names @@ -231,8 +251,8 @@ class ActiveModes: def get_modes(self) -> Sequence[SerenaAgentMode]: return [self.get_mode_instance(mode_name) for mode_name in self._active_mode_names] - def get_default_modes(self) -> Sequence[SerenaAgentMode]: - return [self.get_mode_instance(mode_name) for mode_name in self._configured_default_modes or []] + def get_dynamically_activated_modes(self) -> Sequence[SerenaAgentMode]: + return [self.get_mode_instance(mode_name) for mode_name in self._dynamically_activated_mode_names] def get_base_modes(self) -> Sequence[SerenaAgentMode]: return [self.get_mode_instance(mode_name) for mode_name in self._configured_base_modes or []] @@ -511,8 +531,7 @@ class SerenaAgent: :param serena_config: the Serena configuration or None to read the configuration from the default location. :param context: the context in which the agent is operating, None for default context. The context may adjust prompts, tool availability, and tool descriptions. - :param modes: list of modes in which the agent is operating (they will be combined), None for default modes. - The modes may adjust prompts, tool availability, and tool descriptions. + :param modes: mode selection definition to apply for this session :param memory_log_handler: a MemoryLogHandler instance from which to read log messages; if None, a new one will be created if necessary. """ @@ -521,7 +540,7 @@ class SerenaAgent: self._gui_log_viewer: Optional["GuiLogViewer"] = None self._dashboard_manager: DashboardManager | None = None self._project_prompt_status = ProjectPromptProvisionStatus() - self._mode_overrides = modes + self._session_mode_selection_definition = modes self.version = serena_version() # obtain serena configuration using the decoupled factory function @@ -712,9 +731,11 @@ class SerenaAgent: # * base modes: These cannot be changed, so they are fully applied for base_mode in modes.get_base_modes(): tool_inclusion_definitions.append(base_mode) - # * default modes: When not in a single-project context, these modes are dynamic (can later be turned off), - # so we consider only their inclusions (but not their exclusions, because these must not be hard) - for mode in modes.get_default_modes(): + # * dynamically activated modes: + # - When not in a single-project context, these modes can later be turned off, + # so we consider only their inclusions (but not their exclusions, because these must not be hard). + # - In a single-project context, we can consider them fully. + for mode in modes.get_dynamically_activated_modes(): if is_single_project: tool_inclusion_definitions.append(mode) else: @@ -869,18 +890,6 @@ class SerenaAgent: raise ValueError("No active project. Please activate a project first.") return project - def set_modes(self, mode_names: list[str]) -> None: - """ - Set the current mode configurations. - - :param mode_names: List of mode names or paths to use - """ - self._mode_overrides = ModeSelectionDefinition(default_modes=mode_names) - self._update_active_modes() - self._update_active_tools() - - log.info(f"Set modes to {[mode.name for mode in self.get_active_modes()]}") - def get_active_modes(self) -> list[SerenaAgentMode]: """ :return: the list of active modes @@ -998,8 +1007,8 @@ class SerenaAgent: self._active_modes.apply(self.serena_config) if self._active_project: self._active_modes.apply(self._active_project.project_config) - if self._mode_overrides: - self._active_modes.apply(self._mode_overrides) + if self._session_mode_selection_definition: + self._active_modes.apply(self._session_mode_selection_definition) if log_message: active_mode_names = self._active_modes.get_mode_names() log.info(f"Active modes ({len(active_mode_names)}): {', '.join(active_mode_names)}") diff --git a/src/serena/cli.py b/src/serena/cli.py index 5e87fa2e..8591d712 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -18,12 +18,12 @@ from sensai.util.string import dict_string from tqdm import tqdm from serena import serena_version -from serena.agent import SerenaAgent from serena.config.client_setup import client_setup_handlers from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode from serena.config.serena_config import ( LanguageBackend, ModeSelectionDefinition, + ModeSelectionDefinitionWithAddedModes, ProjectConfig, RegisteredProject, SerenaConfig, @@ -36,11 +36,8 @@ from serena.constants import ( SERENAS_OWN_CONTEXT_YAMLS_DIR, SERENAS_OWN_MODE_YAMLS_DIR, ) -from serena.mcp import SerenaMCPFactory -from serena.project import Project -from serena.tools import FindReferencingSymbolsTool, FindSymbolTool, GetSymbolsOverviewTool, SearchForPatternTool, ToolRegistry +from serena.prompt_factory import SerenaPromptFactory from serena.util.cli_util import AutoRegisteringGroup -from serena.util.dataclass import get_dataclass_default from serena.util.logging import MemoryLogHandler from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind @@ -49,15 +46,17 @@ from solidlsp.util.subprocess_util import subprocess_kwargs log = logging.getLogger(__name__) _MAX_CONTENT_WIDTH = 200 -_MODES_EXPLANATION = f"""\b\nBuilt-in mode names or paths to custom mode YAMLs with which to -override the default modes defined in the global Serena configuration or +_MODES_EXPLANATION = """\b\nBuilt-in mode names or paths to custom mode YAMLs with which to +override the default_modes defined in the global Serena configuration or the active project. For details on mode configuration, see https://oraios.github.io/serena/02-usage/050_configuration.html#modes. -If no configuration changes were made, the base defaults are: - {get_dataclass_default(SerenaConfig, "default_modes")}. -Overriding them means that they no longer apply, so you will need to -re-specify them in addition to further modes if you want to keep them.""" +""" +_ADD_MODES_EXPLANATION = """\b\nMode names or paths to custom mode YAMLs which shall +be added on top of the other modes specified by the global/project configuration. +For details on mode configuration, see + https://oraios.github.io/serena/02-usage/050_configuration.html#modes. +""" def find_project_root(root: str | Path | None = None) -> str | None: @@ -231,13 +230,22 @@ class TopLevelCommands(AutoRegisteringGroup): ) @click.option( "--mode", - "modes", + "default_modes", type=str, multiple=True, default=(), show_default=False, help=_MODES_EXPLANATION, ) + @click.option( + "--add-mode", + "added_modes", + type=str, + multiple=True, + default=(), + show_default=False, + help=_ADD_MODES_EXPLANATION, + ) @click.option( "--language-backend", type=click.Choice([lb.value for lb in LanguageBackend]), @@ -303,7 +311,8 @@ class TopLevelCommands(AutoRegisteringGroup): project_file_arg: str | None, project_from_cwd: bool | None, context: str, - modes: Sequence[str], + default_modes: Sequence[str], + added_modes: Sequence[str], language_backend: str | None, transport: Literal["stdio", "sse", "streamable-http"], host: str, @@ -315,6 +324,8 @@ class TopLevelCommands(AutoRegisteringGroup): trace_lsp_communication: bool | None, tool_timeout: float | None, ) -> None: + from serena.mcp import SerenaMCPFactory + # initialize logging, using INFO level initially (will later be adjusted by SerenaAgent according to the config) # * memory log handler (for use by GUI/Dashboard) # * stream handler for stderr (for direct console output, which will also be captured by clients like Claude Desktop) @@ -346,11 +357,16 @@ class TopLevelCommands(AutoRegisteringGroup): log.warning("No project root found from %s; not activating any project", os.getcwd()) project_file = project_file_arg or project + + mode_selection_def: ModeSelectionDefinition | None = None + if default_modes or added_modes: + mode_selection_def = ModeSelectionDefinitionWithAddedModes(default_modes=default_modes or None, added_modes=added_modes or None) + factory = SerenaMCPFactory(context=context, project=project_file, memory_log_handler=memory_log_handler) server = factory.create_mcp_server( host=host, port=port, - modes=modes, + mode_selection_def=mode_selection_def, language_backend=LanguageBackend.from_str(language_backend) if language_backend else None, enable_web_dashboard=enable_web_dashboard, open_web_dashboard=open_web_dashboard, @@ -394,6 +410,8 @@ class TopLevelCommands(AutoRegisteringGroup): def print_system_prompt( project: str, log_level: str, only_instructions: bool, context: str, modes: Sequence[str] | None = None ) -> None: + from serena.agent import SerenaAgent + prefix = "You will receive access to Serena's symbolic tools. Below are instructions for using them, take them into account." postfix = "You begin by acknowledging that you understood the above instructions and are ready to receive tasks." @@ -403,9 +421,14 @@ class TopLevelCommands(AutoRegisteringGroup): modes_selection_def: ModeSelectionDefinition | None = None if modes: modes_selection_def = ModeSelectionDefinition(default_modes=modes) + serena_config = SerenaConfig.from_config_file() + serena_config.web_dashboard = False + print(serena_config.default_modes) + print(serena_config.base_modes) + agent = SerenaAgent( project=os.path.abspath(project), - serena_config=SerenaConfig(web_dashboard=False, log_level=lvl), + serena_config=serena_config, context=context_instance, modes=modes_selection_def, ) @@ -828,6 +851,8 @@ class ProjectCommands(AutoRegisteringGroup): :param path: The path to check. :param project: The path to the project directory, defaults to the current working directory. """ + from serena.project import Project + serena_config = SerenaConfig.from_config_file() proj = Project.load(os.path.abspath(project), serena_config=serena_config) if os.path.isabs(path): @@ -851,6 +876,8 @@ class ProjectCommands(AutoRegisteringGroup): :param project: path to the project directory, defaults to the current working directory. :param verbose: if set, prints detailed information about the indexed symbols. """ + from serena.project import Project + serena_config = SerenaConfig.from_config_file() proj = Project.load(os.path.abspath(project), serena_config=serena_config) if os.path.isabs(file): @@ -887,6 +914,10 @@ class ProjectCommands(AutoRegisteringGroup): :param project: path to the project directory, defaults to the current working directory. """ # NOTE: completely written by Claude Code, only functionality was reviewed, not implementation + from serena.agent import SerenaAgent + from serena.project import Project + from serena.tools import FindReferencingSymbolsTool, FindSymbolTool, GetSymbolsOverviewTool, SearchForPatternTool + logging.configure(level=logging.INFO) project_path = os.path.abspath(project) serena_config = SerenaConfig.from_config_file() @@ -1040,6 +1071,8 @@ class ToolCommands(AutoRegisteringGroup): @click.option("--all", "-a", "include_optional", is_flag=True, help="List all tools, including those not enabled by default.") @click.option("--only-optional", is_flag=True, help="List only optional tools (those not enabled by default).") def list(quiet: bool = False, include_optional: bool = False, only_optional: bool = False) -> None: + from serena.tools import ToolRegistry + tool_registry = ToolRegistry() if quiet: if only_optional: @@ -1062,6 +1095,9 @@ class ToolCommands(AutoRegisteringGroup): @click.argument("tool_name", type=str) @click.option("--context", type=str, default=None, help="Context name or path to context file.") def description(tool_name: str, context: str | None = None) -> None: + from serena.agent import SerenaAgent + from serena.mcp import SerenaMCPFactory + # Load the context serena_context = None if context: @@ -1089,16 +1125,26 @@ class PromptCommands(AutoRegisteringGroup): @staticmethod @click.command( - "list", help="Lists yamls that are used for defining prompts.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH} + "list", help="Lists prompt names and YAML files that can be overridden.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH} ) def list() -> None: + # list prompt names + click.echo("Prompts:") + factory = SerenaPromptFactory() + for key in factory.get_prompt_names(): + template = factory.get_prompt_template(key) + is_overridden = not template.path.startswith(PROMPT_TEMPLATES_DIR_INTERNAL) + click.echo(f" * '{key}' ({template.path if is_overridden else 'default'})") + + # list prompts files + click.echo("\nPrompt files (which you can override with the create-override command):") serena_prompt_yaml_names = [os.path.basename(f) for f in glob.glob(PROMPT_TEMPLATES_DIR_INTERNAL + "/*.yml")] for prompt_yaml_name in serena_prompt_yaml_names: user_prompt_yaml_path = PromptCommands._get_user_prompt_yaml_path(prompt_yaml_name) if os.path.exists(user_prompt_yaml_path): - click.echo(f"{user_prompt_yaml_path} merged with default prompts in {prompt_yaml_name}") + click.echo(f" * {user_prompt_yaml_path} merged with default prompts in {prompt_yaml_name}") else: - click.echo(prompt_yaml_name) + click.echo(f" * {prompt_yaml_name}") @staticmethod @click.command( @@ -1171,6 +1217,26 @@ class PromptCommands(AutoRegisteringGroup): os.remove(user_prompt_yaml_path) click.echo(f"Deleted override file '{prompt_yaml_name}'.") + @staticmethod + @click.command( + "print-prompt-template", + help="prints the (unrendered) template for the corresponding prompt name. " + "This respects custom prompt yaml overrides and thus will print the value that will be used in Serena", + context_settings={"max_content_width": _MAX_CONTENT_WIDTH}, + ) + @click.argument("prompt_name", type=str) + def print_prompt_template(prompt_name: str) -> None: + click.echo(SerenaPromptFactory().get_prompt_template_string(prompt_name)) + + @staticmethod + @click.command( + "print-cc-system-prompt-override", + help="To be used specifically in Claude Code as value for `--system-prompt`", + context_settings={"max_content_width": _MAX_CONTENT_WIDTH}, + ) + def print_cc_system_prompt_override() -> None: + click.echo(SerenaPromptFactory().create_cc_system_prompt_override()) + _mode = ModeCommands() _context = ContextCommands() diff --git a/src/serena/code_editor.py b/src/serena/code_editor.py index 2392d2ba..fed0297f 100644 --- a/src/serena/code_editor.py +++ b/src/serena/code_editor.py @@ -57,15 +57,21 @@ class CodeEditor(Generic[TSymbol], ABC): """ raise NotImplementedError("This method must be overridden for each subclass") - def read_file(self, relative_path: str) -> str: + def read_file(self, relative_path: str, lines: tuple[int, int] | None = None) -> str: """ Reads the content of a file. :param relative_path: the relative path of the file to read + :param lines: tuple of (first_line, last_line) to read only a specific line range (0-based, inclusive) :return: the content of the file """ with self._open_file_context(relative_path) as file: - return file.get_contents() + contents = file.get_contents() + if lines is None: + return contents + else: + first_line, last_line = lines + return TextUtils.get_text_in_lines_range(contents, first_line, last_line) @contextmanager def edited_file_context(self, relative_path: str) -> Iterator["CodeEditor.EditedFile"]: diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 5e49a2c0..fa754d8a 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -168,10 +168,22 @@ class NamedToolInclusionDefinition(ToolInclusionDefinition): @dataclass class ModeSelectionDefinition: - base_modes: Sequence[str] | None = None default_modes: Sequence[str] | None = None +@dataclass +class ModeSelectionDefinitionWithBaseModes(ModeSelectionDefinition): + base_modes: Sequence[str] | None = ("interactive", "editing") + """ + the base modes to use, which are always guaranteed to be included + """ + + +@dataclass +class ModeSelectionDefinitionWithAddedModes(ModeSelectionDefinition): + added_modes: Sequence[str] | None = None + + class LanguageBackend(Enum): LSP = "LSP" """ @@ -228,7 +240,7 @@ class LineEnding(Enum): @dataclass -class SharedConfig(ModeSelectionDefinition, ToolInclusionDefinition, ToStringMixin): +class SharedConfig(ToolInclusionDefinition, ToStringMixin): """Shared between SerenaConfig and ProjectConfig, the latter used to override values in the form (same as in ModeSelectionDefinition). The defaults here shall be none and should be set to the global default values in SerenaConfig. @@ -255,7 +267,7 @@ Uses $projectDir and $projectFolderName as placeholders. @dataclass(kw_only=True) -class ProjectConfig(SharedConfig): +class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): project_name: str languages: list[Language] ignored_paths: list[str] = field(default_factory=list) @@ -473,6 +485,9 @@ class ProjectConfig(SharedConfig): included_optional_tools = data["included_optional_tools"] or [] additional_workspace_folders = data.get("additional_workspace_folders") or [] + if "base_modes" in data and data["base_modes"] is not None: + log.warning("The base_modes setting in project.yml is deprecated and will be ignored.") + return cls( project_name=data["project_name"], languages=languages, @@ -489,7 +504,7 @@ class ProjectConfig(SharedConfig): encoding=data["encoding"], line_ending=line_ending, language_backend=language_backend, - base_modes=data["base_modes"], + added_modes=data["added_modes"], default_modes=data["default_modes"], symbol_info_budget=symbol_info_budget, ls_specific_settings=data.get("ls_specific_settings", {}), @@ -680,7 +695,7 @@ class RegisteredProject(ToStringMixin): @dataclass(kw_only=True) -class SerenaConfig(SharedConfig): +class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): """ Holds the Serena agent configuration, which is typically loaded from a YAML configuration file (when instantiated via :method:`from_config_file`), which is updated when projects are added or removed. @@ -734,7 +749,6 @@ class SerenaConfig(SharedConfig): """ the language backend to use for code understanding features """ - default_modes: Sequence[str] | None = ("interactive", "editing") line_ending: LineEnding = LineEnding.NATIVE symbol_info_budget: float = 10.0 """ @@ -872,7 +886,18 @@ class SerenaConfig(SharedConfig): if path is None: continue num_migrations += 1 - project_config = ProjectConfig.load(path, serena_config=instance) # instance is sufficiently populated + try: + project_config = ProjectConfig.load(path, serena_config=instance) # instance is sufficiently populated + except Exception as e: + log.error( + "Failed to load project configuration for %s: %s. " + "This project will be skipped. Fix or delete its " + ".serena/project.yml (or remove it from " + "serena_config.yml) to re-enable it.", + path, + e, + ) + continue project = RegisteredProject( project_root=str(path), project_config=project_config, diff --git a/src/serena/generated/generated_prompt_factory.py b/src/serena/generated/generated_prompt_factory.py index 43f866d7..a1ee2920 100644 --- a/src/serena/generated/generated_prompt_factory.py +++ b/src/serena/generated/generated_prompt_factory.py @@ -4,7 +4,6 @@ # NOTE: This module is auto-generated from interprompt.autogenerate_prompt_factory_module, do not edit manually! -from interprompt.multilang_prompt import PromptList from interprompt.prompt_factory import PromptFactoryBase from typing import Any @@ -14,15 +13,27 @@ class PromptFactory(PromptFactoryBase): A class for retrieving and rendering prompt templates and prompt lists. """ + def get_info_jet_brains_debug_repl_template_string(self) -> str: + return self.get_prompt_template_string("info_jet_brains_debug_repl") + def create_info_jet_brains_debug_repl(self) -> str: return self._render_prompt("info_jet_brains_debug_repl", locals()) + def get_onboarding_prompt_template_string(self) -> str: + return self.get_prompt_template_string("onboarding_prompt") + def create_onboarding_prompt(self, *, system: Any) -> str: return self._render_prompt("onboarding_prompt", locals()) + def get_connection_prompt_template_string(self) -> str: + return self.get_prompt_template_string("connection_prompt") + def create_connection_prompt(self) -> str: return self._render_prompt("connection_prompt", locals()) + def get_system_prompt_template_string(self) -> str: + return self.get_prompt_template_string("system_prompt") + def create_system_prompt( self, *, @@ -33,3 +44,9 @@ class PromptFactory(PromptFactoryBase): mode_system_prompts: Any, ) -> str: return self._render_prompt("system_prompt", locals()) + + def get_cc_system_prompt_override_template_string(self) -> str: + return self.get_prompt_template_string("cc_system_prompt_override") + + def create_cc_system_prompt_override(self) -> str: + return self._render_prompt("cc_system_prompt_override", locals()) diff --git a/src/serena/hooks.py b/src/serena/hooks.py index 4a0ff12f..b1becc04 100644 --- a/src/serena/hooks.py +++ b/src/serena/hooks.py @@ -339,8 +339,9 @@ class PreToolUseRemindAboutSerenaHook(PreToolUseHook): class SessionStartActivateProjectHook(Hook): def execute(self) -> None: message = ( - "**IMPORTANT**: Activate the current working directory as project using Serena's tools if not already done. " - "If you haven't read Serena Instructions Manual yet, do so. " + "**IMPORTANT**: If the current directory is a coding project you are working on:" + " 1. activate it using Serena's activate_project tool unless already done. " + " 2. if you haven't read Serena Instructions Manual yet, do so. " "Follow this instruction before doing anything else." ) result = { @@ -358,29 +359,41 @@ class SessionEndCleanupHook(Hook): class PreToolUseAutoApproveSerenaHook(PreToolUseHook): - """Pre-tool-use hook that auto-approves Serena tool calls while the client is in accept-edits mode. + """Pre-tool-use hook that auto-approves Serena tool calls while the client is in a permissive permission mode. - Claude Code's ``acceptEdits`` permission mode only applies to its built-in editing tools; - Serena's destructive tools (e.g. ``replace_symbol_body`` or ``rename_symbol``) would still - prompt the user on every call. This hook emits an ``allow`` decision for any Serena MCP - tool call whenever the client reports ``acceptEdits`` as the active permission mode, so - blanket edit approvals also cover Serena's tools. In all other situations it stays silent, - preserving the default approval flow. + Claude Code's permissive permission modes (``acceptEdits`` for blanket edit approval and + ``auto`` for hands-off autonomous execution) only apply to its built-in editing tools or + its auto-mode classifier; Serena's destructive tools (e.g. ``replace_symbol_body`` or + ``rename_symbol``) would still prompt the user on every call. This hook emits an ``allow`` + decision for any Serena MCP tool call whenever the client reports one of these modes as + the active permission mode, so blanket approvals also cover Serena's tools. In all other + situations it stays silent, preserving the default approval flow. + + ``bypassPermissions`` and ``dontAsk`` are deliberately excluded. ``bypassPermissions`` + already approves everything before the hook would matter, so silence here is harmless. + ``dontAsk`` is the user's deliberate deny-by-default posture (auto-deny unless an explicit + allow rule matches); the hook honors that choice and stays silent rather than blanket + overriding it. """ - _ACCEPT_EDITS_MODE = "acceptEdits" + #: permission modes for which this hook emits an ``allow`` decision. Frozen so the + #: set is immutable at the class level; matching is exact (case-sensitive) against + #: the canonical mode strings emitted by Claude Code's hook payload. + _AUTO_APPROVE_MODES: frozenset[str] = frozenset({"acceptEdits", "auto"}) - def is_accept_edits_mode(self) -> bool: - return self._permission_mode == self._ACCEPT_EDITS_MODE + def is_auto_approve_mode(self) -> bool: + return self._permission_mode in self._AUTO_APPROVE_MODES def execute(self) -> None: # only emit a decision when both the tool and the mode match; stay silent otherwise - if not self.is_serena_tool() or not self.is_accept_edits_mode(): + if not self.is_serena_tool() or not self.is_auto_approve_mode(): return + # name the actual mode in the reason so logs/debug output are unambiguous + # (the same hook handles multiple modes now) output_data = self.OutputData( permission_decision="allow", - permission_decision_reason="Auto-approved: Serena tool call while client is in acceptEdits mode.", + permission_decision_reason=f"Auto-approved: Serena tool call while client is in {self._permission_mode} mode.", ) click.echo(output_data.to_json_string()) @@ -425,7 +438,8 @@ class HookCommands(AutoRegisteringGroup): @staticmethod @click.command( "auto-approve", - help="Set this as hook at PreToolUse to auto-approve Serena tool calls while the client is in acceptEdits mode (Claude Code).", + help="Set this as hook at PreToolUse to auto-approve Serena tool calls while the client is in a " + "permissive permission mode (acceptEdits or auto, Claude Code).", ) @_client_option def auto_approve(client: str) -> None: diff --git a/src/serena/jetbrains/jetbrains_plugin_client.py b/src/serena/jetbrains/jetbrains_plugin_client.py index 263bc8a9..c7b440ce 100644 --- a/src/serena/jetbrains/jetbrains_plugin_client.py +++ b/src/serena/jetbrains/jetbrains_plugin_client.py @@ -661,6 +661,54 @@ class JetBrainsPluginClient(ToStringMixin): } return self._make_request("POST", "/debugReplClose", request_data) + def run_inspections( + self, + relative_path: str, + min_severity: str | None = None, + inspection_names: list[str] | None = None, + start_line: int | None = None, + end_line: int | None = None, + ) -> jb.RunInspectionsResponse: + """ + Runs IDE inspections on the given file and returns the results. + + :param relative_path: the relative path to the file to inspect + :param min_severity: minimum severity level to include (e.g. "WARNING", "ERROR") + :param inspection_names: optional list of specific inspection names to run + :param start_line: optional start line to restrict the inspection range + :param end_line: optional end line to restrict the inspection range + """ + request_data: dict[str, Any] = { + "relativePath": relative_path, + } + if min_severity is not None: + request_data["minSeverity"] = min_severity + if inspection_names is not None: + request_data["inspectionNames"] = inspection_names + if start_line is not None: + request_data["startLine"] = start_line + if end_line is not None: + request_data["endLine"] = end_line + return cast(jb.RunInspectionsResponse, self._make_request("POST", "/runInspectionsOnFile", request_data)) + + def list_inspections( + self, + language: str | None = None, + group_path_contains: str | None = None, + ) -> jb.ListInspectionsResponse: + """ + Lists available IDE inspections, optionally filtered by language and/or group path. + + :param language: optional language to filter inspections by (e.g. "Java", "Python") + :param group_path_contains: optional substring to filter inspection group paths + """ + request_data: dict[str, Any] = {} + if language is not None: + request_data["language"] = language + if group_path_contains is not None: + request_data["groupPathContains"] = group_path_contains + return cast(jb.ListInspectionsResponse, self._make_request("POST", "/listInspections", request_data)) + def close(self) -> None: self._session.close() diff --git a/src/serena/jetbrains/jetbrains_types.py b/src/serena/jetbrains/jetbrains_types.py index ead4eb55..ce488ea2 100644 --- a/src/serena/jetbrains/jetbrains_types.py +++ b/src/serena/jetbrains/jetbrains_types.py @@ -89,3 +89,30 @@ class TypeHierarchyNodeDTO(TypedDict): class TypeHierarchyResponse(TypedDict): hierarchy: NotRequired[list[TypeHierarchyNodeDTO]] num_levels_not_included: NotRequired[int] + + +class InspectionResultDTO(TypedDict): + inspection_name: str + severity: str + message: str + relative_path: str + start_line: int + end_line: int + start_col: NotRequired[int] + end_col: NotRequired[int] + quick_fix_available: NotRequired[bool] + + +class RunInspectionsResponse(TypedDict): + inspections: list[InspectionResultDTO] + + +class InspectionInfoDTO(TypedDict): + name: str + group_path: str + language: NotRequired[str] + description: NotRequired[str] + + +class ListInspectionsResponse(TypedDict): + inspections: list[InspectionInfoDTO] diff --git a/src/serena/mcp.py b/src/serena/mcp.py index 13b9a00c..130f432a 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -3,7 +3,7 @@ The Serena Model Context Protocol (MCP) Server """ import sys -from collections.abc import AsyncIterator, Iterator, Sequence +from collections.abc import AsyncIterator, Iterator from contextlib import asynccontextmanager from copy import deepcopy from dataclasses import dataclass @@ -272,7 +272,7 @@ class SerenaMCPFactory: self, host: str = "127.0.0.1", port: int = 8000, - modes: Sequence[str] = (), + mode_selection_def: ModeSelectionDefinition | None = None, language_backend: LanguageBackend | None = None, enable_web_dashboard: bool | None = None, enable_gui_log_window: bool | None = None, @@ -286,7 +286,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 mode_selection_def: the mode selection definition to apply :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. @@ -318,9 +318,6 @@ class SerenaMCPFactory: if language_backend is not None: config.language_backend = language_backend - mode_selection_def: ModeSelectionDefinition | None = None - if modes: - mode_selection_def = ModeSelectionDefinition(default_modes=modes) self.agent = self._create_serena_agent(config, mode_selection_def) except Exception as e: diff --git a/src/serena/resources/config/contexts/claude-code.yml b/src/serena/resources/config/contexts/claude-code.yml index fe63878b..e6b27b7d 100644 --- a/src/serena/resources/config/contexts/claude-code.yml +++ b/src/serena/resources/config/contexts/claude-code.yml @@ -3,14 +3,35 @@ 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. + You have access to Serena's code intelligence tools that exploit the symbolic + structure of the code and are much more efficient than your own tools for most coding scenarios. + If you are working on any coding task and if Serena's tools are deferred, you + should load them all immediately, before performing any read, grep or bash commands. If Serena's tools can be used to achieve your task, you should prioritize them. - **IMPORTANT**: If you haven't seen Serena's tools yet, you should use the tool search tool to load all of them - right now. + **CRITICAL**: If you haven't seen Serena's tools yet, you should use the tool search tool to load all of them + **right now**. For exploring and reading code in a token-efficient manner, use Serena's code-centric overview and symbolic search tools. For edits, prefer Serena's editing tools since they are more token efficient. - Especially when editing code you have read through Serena, ALWAYS use Serena's tools for editing, because your built-in tools will deny such edits (they will assume you haven't read the content). - **IMPORTANT**: Never use a tool before having read its schema via the tool-search tool! + Especially when editing code you have read through Serena, **ALWAYS** use Serena's tools for editing, because your built-in tools will deny such edits (they will assume you haven't read the content). + **CRITICAL**: Never use a tool before having read its schema via the tool-search tool! + **CRITICAL**: You have an preference for relying on your own tools like grep, read and edit. It is of paramount + importance that you overcome this preference and instead develop a preference for using Serena's tools since they + are superior for most coding tasks. Follow the following rules: + + For any code files: + - Read -> FORBIDDEN for discovery. Use get_symbols_overview, then find_symbol with include_body. + You may use read only if you already have an overview of the file and if reading whole symbols + is inappropriate (e.g. a few lines of code). + - Glob (by name) -> Allowed for discovery only. + - Grep (content) -> Allowed for discovery only; follow up reads or reference searches must be Serena. + - Edit -> FORBIDDEN. Use replace_symbol_body / insert_*_symbol / replace_content. + + Disallowed reasoning. Do NOT use any of the following to justify Read/Edit on a code file: + - "I already know the path" + - "one Read call is faster than three Serena calls" + - "the built-in tool description says to use Read for known paths" + If you catch yourself reaching for one of these, that is the signal to switch to Serena. excluded_tools: - create_text_file diff --git a/src/serena/resources/config/internal_modes/jetbrains.yml b/src/serena/resources/config/internal_modes/jetbrains.yml index 34baf6ce..cbaab89d 100644 --- a/src/serena/resources/config/internal_modes/jetbrains.yml +++ b/src/serena/resources/config/internal_modes/jetbrains.yml @@ -1,10 +1,11 @@ description: JetBrains tools replace language server-based tools prompt: | You have access to the very powerful JetBrains tools for symbolic operations. - These party replace regular tools you were informed about: + These partly replace regular tools you were informed about: * `jet_brains_find_symbol` replaces `find_symbol` * `jet_brains_find_referencing_symbols` replaces `find_referencing_symbols` * `jet_brains_get_symbols_overview` replaces `get_symbols_overview` + * `jet_brains_rename` replaces `rename_symbol` excluded_tools: - find_symbol - find_referencing_symbols @@ -25,3 +26,5 @@ included_optional_tools: - jet_brains_safe_delete - jet_brains_debug - serena_info + - jet_brains_run_inspections + - jet_brains_list_inspections diff --git a/src/serena/resources/config/prompt_templates/system_prompt.yml b/src/serena/resources/config/prompt_templates/system_prompt.yml index 426ba0be..722d3a9d 100644 --- a/src/serena/resources/config/prompt_templates/system_prompt.yml +++ b/src/serena/resources/config/prompt_templates/system_prompt.yml @@ -58,3 +58,159 @@ prompts: {% endfor %} You have hereby read the 'Serena Instructions Manual' and do not need to read it again. + + cc_system_prompt_override: | + You are Claude Code, Anthropic's official CLI for Claude. You are an interactive + software-engineering agent. The user works with you through a terminal; your text + output is what they see, and your tool calls are what change the world. + + # Tool selection (read this before every tool call on a code file) + + This project uses Serena, an MCP server that exposes semantic, symbol-aware tools + for reading and editing code. Serena's tools are the PRIMARY tools for code work + in this project. The built-in Read, Glob, Grep, and Edit tools are SECONDARY and + must not be used on code files when a Serena equivalent exists. + + The built-in tool descriptions in your context will tell you things like "use Read + for a known path" and "prefer dedicated tools (Read, Edit, Write, Glob, Grep)". + Those descriptions are written for projects without Serena and are SUPERSEDED here. + When they conflict with this section, this section wins. Do not rationalize the + built-in tools with "the file is small," "I already know what I need," "this is + one call versus three," or "the path is known" — those rationalizations have + produced incorrect behavior before and are explicitly disallowed. + + ## Mapping (use the right column, not the left) + + Task Tool to use + -------------------------------------- ---------------------------------------- + See a code file's structure get_symbols_overview + Read a specific symbol's body find_symbol (include_body=true) + Find a symbol by name across the repo find_symbol + Find references / callers find_referencing_symbols + Find declarations / implementations find_declaration / _find_implementations + Edit a symbol's body replace_symbol_body + Insert near a symbol insert_before_symbol / _insert_after_symbol + Pattern replace inside a file replace_content + Rename / move / delete a symbol rename / _move / _safe_delete + Inline a symbol inline_symbol + Type hierarchy type_hierarchy + + Built-in Read/Edit/Glob/Grep are permitted on code files ONLY when: + - Serena has been tried on the target and failed, OR + - The file is not parseable as code (e.g., generated, malformed), OR + - You need a regex search across many files that Serena's symbolic tools cannot + express — in which case Grep is acceptable as a discovery step, but follow-up + reads/edits on matched code files must still go through Serena. + - You need to read a few lines and symbolic reads would be an overkill. + - You absolutely have to read the full file for some reason. + + Read/Edit/Glob are fine for non-code files: markdown, JSON, YAML, TOML, .env, + config files, lockfiles, plain text, images. + + ## Required workflow before editing code + + 1. get_symbols_overview on the target file (skip if already done this session). + 2. find_symbol with include_body=true for the specific symbols you'll touch. + Read only the symbols you need — not the whole file. + 3. Edit with replace_symbol_body, insert_before_symbol, insert_after_symbol, or + replace_content. Never use the built-in Edit on a code file when one of these + fits. + + ## Self-check + + Before every Read, Glob, Grep, or Edit call: "Does this target a code file, and + does the mapping above name a Serena tool for this task?" If yes, switch. Do this + check every time — not just once per session. + + # Doing tasks + + The user will ask you to fix bugs, add features, refactor, explain code, and + similar. Approach each task with these defaults: + + - Understand before changing. Use the symbolic tools to build a precise picture of + what's there, then make the smallest change that satisfies the request. + - Don't add scope. No surrounding cleanup on a bug fix, no abstractions for + hypothetical future needs, no error handling for cases that can't happen, no + feature flags or backwards-compat shims unless asked. Three similar lines beats + a premature abstraction. + - Don't write comments unless the WHY is non-obvious — a hidden constraint, a + workaround, a subtle invariant. Don't narrate WHAT the code does; well-named + identifiers handle that. Don't reference the current task or PR in comments. + - Prefer editing existing files to creating new ones. Never create *.md or README + files unless the user explicitly asks. + - For exploratory questions ("what could we do about X?"), reply in 2–3 sentences + with a recommendation and the main tradeoff. Don't implement until the user + agrees. + - For UI/frontend changes you can't test in a browser, say so explicitly rather + than claiming success. + - Watch for security issues (injection, XSS, SQL injection, path traversal, secret + leaks). Fix them when you spot them. + + # Executing actions with care + + Local, reversible actions (editing files, running tests, reading state) are free + to take. Pause and confirm before: + + - Destructive ops: deleting files/branches, dropping tables, killing processes, + rm -rf, overwriting uncommitted changes, git reset --hard, force-push. + - Hard-to-reverse ops: amending published commits, removing dependencies, + modifying CI/CD. + - Externally visible actions: pushing, opening/closing/commenting on PRs or + issues, sending messages, posting to third-party services. + - Uploading content to third-party tools (renderers, pastebins) — assume it's + public and may be cached. + + If you hit an obstacle, find the root cause. Don't bypass it with --no-verify, + --force, or by deleting the thing in your way. If you find unfamiliar files, + branches, or config, investigate before deleting — it may be the user's + in-progress work. + + A user approving an action once does not approve it forever. Match the scope of + your action to what was actually requested. + + # Git and commits + + - Only commit when the user asks. Never proactively. + - Never update git config. Never skip hooks (--no-verify, --no-gpg-sign) unless + the user explicitly asks. + - Prefer new commits over --amend. If a pre-commit hook fails, the commit didn't + happen — fix the issue, re-stage, and create a NEW commit (not --amend, which + would modify the previous commit). + - Stage files by name, not `git add -A` or `git add .` — those can sweep in + secrets or large binaries. + - Don't commit files that look like secrets (.env, credentials.json, *.pem). If + the user explicitly asks, warn first. + - For commit messages, use a HEREDOC to preserve formatting. End the trailer with: + Co-Authored-By: Claude Opus 4.7 (1M context) + - Don't push unless asked. Never force-push to main/master; warn if asked. + - For PRs, use `gh` via Bash. Look at the full diff against the base branch (not + just the latest commit) before drafting title/body. + + # Tone and output + + - Your tool calls aren't visible to the user — only your text is. Before your + first tool call, say in one sentence what you're about to do. While working, + give short updates at key moments: a finding, a direction change, a blocker. + Brief is good; silent is not. + - Don't narrate internal deliberation. State results and decisions; skip the + thinking-aloud. + - End-of-turn summary: one or two sentences max. What changed, what's next. + Nothing else. + - Match response shape to the task: a simple question gets a direct answer, not + headers and sections. + - No emojis unless the user asks. + - Use Github-flavored markdown. Reference code locations as `path:line` so the + user can jump. + + # Parallel tool calls + + When tool calls don't depend on each other, issue them in a single response. + When they do depend on each other, issue them sequentially with the dependent + values resolved. Don't use placeholders or guess. + + # Asking for help vs. acting + + When a request is ambiguous in a way that materially changes the work, ask one + focused question. When it's only ambiguous in ways that don't change the work, + pick the reasonable interpretation and proceed — and say which interpretation + you picked. diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index 6570c1c0..18e4b75d 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -3,16 +3,18 @@ project_name: "project_name" # list of languages for which language servers are started; choose from: -# al bash clojure cpp csharp -# csharp_omnisharp dart elixir elm erlang -# fortran fsharp go groovy haskell -# haxe java julia kotlin lua -# markdown -# matlab nix pascal perl php -# php_phpactor powershell python python_jedi r -# rego ruby ruby_solargraph rust scala -# swift terraform toml typescript typescript_vts -# vue yaml zig +# al ansible bash clojure cpp +# cpp_ccls crystal csharp csharp_omnisharp dart +# elixir elm erlang fortran fsharp +# go groovy haskell haxe hlsl +# java json julia kotlin lean4 +# lua luau markdown matlab msl +# nix ocaml pascal perl php +# php_phpactor powershell python python_jedi python_ty +# r rego ruby ruby_solargraph rust +# scala solidity swift systemverilog terraform +# toml typescript typescript_vts vue yaml +# zig # (This list may be outdated. For the current list, see values of Language enum here: # https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py # For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) @@ -76,71 +78,34 @@ read_only: false # list of tool names to exclude. # This extends the existing exclusions (e.g. from the global configuration) -# -# Below is the complete list of tools for convenience. -# To make sure you have the latest list of tools, and to view their descriptions, -# execute `uv run scripts/print_tool_overview.py`. -# -# * `activate_project`: Activates a project based on the project name or path. -# * `check_onboarding_performed`: Checks whether project onboarding was already performed. -# * `create_text_file`: Creates/overwrites a file in the project directory. -# * `delete_memory`: Delete a memory file. Should only happen if a user asks for it explicitly, -# for example by saying that the information retrieved from a memory file is no longer correct -# or no longer relevant for the project. -# * `edit_memory`: Replaces content matching a regular expression in a memory. -# * `execute_shell_command`: Executes a shell command. -# * `find_file`: Finds files in the given relative paths -# * `find_referencing_symbols`: Finds symbols that reference the given symbol using the language server backend -# * `find_symbol`: Performs a global (or local) search using the language server backend. -# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. -# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. -# * `initial_instructions`: Provides instructions Serena usage (i.e. the 'Serena Instructions Manual') -# for clients that do not read the initial instructions when the MCP server is connected. -# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. -# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. -# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). -# * `list_memories`: List available memories. Any memory can be read using the `read_memory` tool. -# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). -# * `read_file`: Reads a file within the project directory. -# * `read_memory`: Read the content of a memory file. This tool should only be used if the information -# is relevant to the current task. You can infer whether the information -# is relevant from the memory file name. -# You should not read the same memory file multiple times in the same conversation. -# * `rename_memory`: Renames or moves a memory. Moving between project and global scope is supported -# (e.g., renaming "global/foo" to "bar" moves it from global to project scope). -# * `rename_symbol`: Renames a symbol throughout the codebase using language server refactoring capabilities. -# For JB, we use a separate tool. -# * `replace_content`: Replaces content in a file (optionally using regular expressions). -# * `replace_symbol_body`: Replaces the full definition of a symbol using the language server backend. -# * `safe_delete_symbol`: -# * `search_for_pattern`: Performs a search for a pattern in the project. -# * `write_memory`: Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format. -# The memory name should be meaningful. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html excluded_tools: [] # list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). # This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html included_optional_tools: [] # fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. # This cannot be combined with non-empty excluded_tools or included_optional_tools. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html fixed_tools: [] -# list of mode names to that are always to be included in the set of active modes -# The full set of modes to be activated is base_modes + default_modes. -# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. -# Otherwise, this setting overrides the global configuration. -# Set this to [] to disable base modes for this project. -# Set this to a list of mode names to always include the respective modes for this project. -base_modes: - -# list of mode names that are to be activated by default. -# The full set of modes to be activated is base_modes + default_modes. -# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# list of mode names that are to be activated by default, overriding the setting in the global configuration. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. # Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply +# for this project. # This setting can, in turn, be overridden by CLI parameters (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes default_modes: +# list of mode names to be activated additionally for this project, e.g. ["query-projects"] +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: + # initial prompt for the project. It will always be given to the LLM upon activating the project # (contrary to the memories, which are loaded on demand). initial_prompt: "" diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml index 18974358..93036b96 100644 --- a/src/serena/resources/serena_config.template.yml +++ b/src/serena/resources/serena_config.template.yml @@ -37,13 +37,15 @@ gui_log_window: False # Further information: https://oraios.github.io/serena/02-usage/060_dashboard.html web_dashboard: True -# whether to open the Dashboard window/browser tab when Serena starts (provided that web_dashboard is enabled). -# If set to false, you can still open the dashboard manually by clicking on the Serena icon in your system -# tray on Windows and macOS. On Linux, there is no system tray support, so you can only open the dashboard by -# a) telling the LLM to "open the dashboard" (provided that the open_dashboard tool is enabled) or by -# b) manually navigating to http://localhost:24282/dashboard/ in your web browser (actual port -# may be higher if you have multiple instances running; try ports 24283, 24284, etc.) -# See also: https://oraios.github.io/serena/02-usage/060_dashboard.html +# whether to open the Dashboard window/browser tab when Serena starts (provided that `web_dashboard` is enabled). +# If set to false, you can still open the dashboard manually: +# * When using an interface that supports a tray icon (see setting `web_dashboard_interface`), +# you can conveniently open the dashboard from the system tray. +# * When using the `browser` interface (no tray icon), so you can only open the dashboard by +# a) telling the LLM to "open the dashboard" (provided that the open_dashboard tool is enabled) or by +# b) manually navigating to http://localhost:24282/dashboard/ in your web browser (actual port +# may be higher if you have multiple instances running; try ports 24283, 24284, etc.) +# Further information: https://oraios.github.io/serena/02-usage/060_dashboard.html web_dashboard_open_on_launch: True # defines the interface (application mode) used for the web dashboard (if enabled). @@ -59,6 +61,7 @@ web_dashboard_open_on_launch: True # opening the dashboard in browser tabs when selected from the tray menu. # This is EXPERIMENTAL. It is tested on Windows only. We will establish macOS support, but it is yet untested. # On Linux, this cannot be universally supported, but it may work in some desktop environments. +# See https://oraios.github.io/serena/02-usage/060_dashboard.html web_dashboard_interface: # the address the web dashboard will listen on (bind address). @@ -112,19 +115,23 @@ included_optional_tools: [] # This cannot be combined with non-empty excluded_tools or included_optional_tools. fixed_tools: [] -# list of mode names to that are always to be included in the set of active modes -# The full set of modes to be activated is base_modes + default_modes. -# If this is undefined, no base modes are included. -# The project configuration (project.yml) may override this setting. +# list of mode names to that are always to be included in the set of active modes. +# The full set of modes to be activated is base_modes + default_modes + added_modes, +# where added_modes can be defined by projects/CLI parameters. +# If this is undefined/empty, no base modes are included. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes base_modes: - -# list of mode names that are to be activated by default. -# The full set of modes to be activated is base_modes + default_modes. -# These modes can be overridden by the project configuration (project.yml) or through the CLI (--mode). -default_modes: - interactive - editing +# list of mode names that are to be activated by default (but can be overridden by projects/CLI params). +# The full set of modes to be activated is base_modes + default_modes + added_modes, +# where added_modes are defined by projects/CLI parameters. +# If this is undefined/empty, no default modes are defined. +# These modes can be overridden by the project configuration (project.yml) or through the CLI (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +default_modes: + # 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 # through the global configuration. diff --git a/src/serena/symbol.py b/src/serena/symbol.py index 4a1b14cc..8bae7d14 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -3,6 +3,7 @@ import json import logging import os from abc import ABC, abstractmethod +from collections import OrderedDict from collections.abc import Callable, Iterable, Iterator, Sequence from dataclasses import asdict, dataclass from time import perf_counter @@ -11,7 +12,7 @@ from typing import Any, Generic, Literal, NotRequired, Self, TypedDict, TypeVar from sensai.util.string import ToStringMixin import serena.jetbrains.jetbrains_types as jb -from solidlsp import SolidLanguageServer +from solidlsp import SolidLanguageServer, ls_types from solidlsp.ls import LSPFileBuffer from solidlsp.ls import ReferenceInSymbol as LSPReferenceInSymbol from solidlsp.ls_types import Position, SymbolKind, UnifiedSymbolInformation @@ -309,6 +310,12 @@ class LanguageServerSymbol(Symbol, ToStringMixin): end_line = end_pos["line"] if end_pos else None return start_line, end_line + def get_body_line_numbers_or_raise(self) -> tuple[int, int]: + start_line, end_line = self.get_body_line_numbers() + if start_line is None or end_line is None: + raise ValueError(f"Body line numbers could not be determined for {self.get_name_path()}") + return start_line, end_line + @property def line(self) -> int | None: """ @@ -869,6 +876,264 @@ class LanguageServerSymbolRetriever: return [ReferenceInLanguageServerSymbol.from_lsp_reference(r) for r in references] + def find_implementing_symbols( + self, + name_path: str, + relative_file_path: str, + include_body: bool = False, + include_kinds: Sequence[SymbolKind] | None = None, + exclude_kinds: Sequence[SymbolKind] | None = None, + ) -> list[LanguageServerSymbol]: + """ + Find all symbols that implement the specified symbol, which is assumed to be unique. + + :param name_path: the name path of the symbol to find implementations for. 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 implemented symbol is defined. + :param include_body: whether to include the body of all symbols in the result. + :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 = self.find_unique(name_path, substring_matching=False, within_relative_path=relative_file_path) + return self.find_implementing_symbols_by_location( + symbol.location, + include_body=include_body, + include_kinds=include_kinds, + exclude_kinds=exclude_kinds, + ) + + def find_implementing_symbols_by_location( + self, + symbol_location: LanguageServerSymbolLocation, + include_body: bool = False, + include_kinds: Sequence[SymbolKind] | None = None, + exclude_kinds: Sequence[SymbolKind] | None = None, + ) -> list[LanguageServerSymbol]: + """ + Find all symbols that implement the symbol at the given location. + + :param symbol_location: the location of the symbol for which to find implementations. + Does not need to include an end_line, as it is unused in the search. + :param include_body: whether to include the body of all symbols in the result. + :param include_kinds: an optional sequence of ints representing the LSP symbol kind. + If provided, only symbols of the given kinds will be included in the result. + :param exclude_kinds: If provided, symbols of the given kinds will be excluded from the result. + Takes precedence over include_kinds. + :return: a list of symbols that implement the given symbol + """ + if not symbol_location.has_position_in_file(): + raise ValueError("Symbol location does not contain a valid position in a file") + assert symbol_location.relative_path is not None + assert symbol_location.line is not None + assert symbol_location.column is not None + lang_server = self.get_language_server(symbol_location.relative_path) + implementing_symbols = lang_server.request_implementing_symbols( + relative_file_path=symbol_location.relative_path, + line=symbol_location.line, + column=symbol_location.column, + include_body=include_body, + ) + + if include_kinds is not None: + implementing_symbols = [s for s in implementing_symbols if s["kind"] in include_kinds] + + if exclude_kinds is not None: + implementing_symbols = [s for s in implementing_symbols if s["kind"] not in exclude_kinds] + + return [LanguageServerSymbol(s) for s in implementing_symbols] + + def find_declaration( + self, + relative_file_path: str, + line: int, + column: int, + include_body: bool = False, + ) -> LanguageServerSymbol | None: + """ + Find the declaration/definition of the symbol at the given file position. + + :param relative_file_path: the relative path to the file in which the symbol usage occurs. + :param line: the 0-based line number of the symbol usage. + :param column: the 0-based column number of the symbol usage. + :param include_body: whether to include the body of the defining symbol in the result. + :return: the defining symbol, or None if no definition could be resolved. + """ + lang_server = self.get_language_server(relative_file_path) + defining_symbol = lang_server.request_defining_symbol( + relative_file_path=relative_file_path, + line=line, + column=column, + include_body=include_body, + ) + if defining_symbol is None: + return None + return LanguageServerSymbol(defining_symbol) + + def get_file_diagnostics( + self, + relative_file_path: str, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + ) -> list[ls_types.Diagnostic]: + """ + Get diagnostics for a file, optionally restricted to a line range and minimum severity. + + :param relative_file_path: the relative path to the file. + :param start_line: the first 0-based line to include. + :param end_line: the last 0-based line to include. `-1` means until end of file. + :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. + :return: the diagnostics matching the requested constraints. + """ + lang_server = self.get_language_server(relative_file_path) + return lang_server.request_text_document_diagnostics( + relative_file_path=relative_file_path, + start_line=start_line, + end_line=end_line, + min_severity=min_severity, + ) + + @staticmethod + def _symbol_identity(symbol: LanguageServerSymbol) -> tuple[str | None, int | None, int | None, str]: + return (symbol.relative_path, symbol.line, symbol.column, symbol.get_name_path()) + + @staticmethod + def _normalize_symbol_for_diagnostics(symbol: LanguageServerSymbol) -> LanguageServerSymbol: + current_symbol = symbol + while current_symbol.is_low_level(): + parent_symbol = current_symbol.get_parent() + if parent_symbol is None: + break + current_symbol = parent_symbol + return current_symbol + + def find_diagnostic_owner_symbol(self, relative_file_path: str, line: int, column: int) -> LanguageServerSymbol | None: + """ + Find the symbol that should own a diagnostic at the given position. + + This prefers the structural container of the diagnostic over low-level symbols such as + local variables, because diagnostics are typically more meaningful when grouped by the + surrounding function, method, class, or analogous construct. + + :param relative_file_path: the relative path to the file containing the diagnostic. + :param line: the 0-based line of the diagnostic. + :param column: the 0-based column of the diagnostic. + :return: the owning symbol, or None if no symbol could be resolved. + """ + lang_server = self.get_language_server(relative_file_path) + symbol_dict = lang_server.request_symbol_at_location( + relative_file_path=relative_file_path, + line=line, + column=column, + ) + if symbol_dict is None: + return None + return self._normalize_symbol_for_diagnostics(LanguageServerSymbol(symbol_dict)) + + def _get_diagnostics_for_symbol(self, symbol: LanguageServerSymbol, min_severity: int) -> list[ls_types.Diagnostic]: + relative_path = symbol.relative_path + if relative_path is None: + return [] + + start_line, end_line = symbol.get_body_line_numbers() + if start_line is None: + if symbol.line is None: + return [] + start_line = symbol.line + if end_line is None: + end_line = start_line + + return self.get_file_diagnostics( + relative_file_path=relative_path, + start_line=start_line, + end_line=end_line, + min_severity=min_severity, + ) + + def get_symbol_diagnostics( + self, + name_path: str, + reference_file: str | None = None, + check_symbol_references: bool = False, + min_severity: int = 4, + ) -> dict[LanguageServerSymbol, list[ls_types.Diagnostic]]: + """ + Get diagnostics for the specified symbol and, optionally, for all symbols that reference it. + + :param name_path: the name path of the symbol to find. It should usually be unique. + :param reference_file: optional file path used to disambiguate the symbol search. + :param check_symbol_references: whether to additionally collect diagnostics for referencing symbols. + :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. + :return: a mapping from symbols to the diagnostics that overlap their body ranges. + """ + symbol = self.find_unique(name_path, substring_matching=False, within_relative_path=reference_file or None) + return self.get_symbol_diagnostics_by_location( + symbol.location, + check_symbol_references=check_symbol_references, + min_severity=min_severity, + ) + + def get_symbol_diagnostics_by_location( + self, + symbol_location: LanguageServerSymbolLocation, + check_symbol_references: bool = False, + min_severity: int = 4, + ) -> dict[LanguageServerSymbol, list[ls_types.Diagnostic]]: + """ + Get diagnostics for the symbol at the given location and, optionally, for all referencing symbols. + + :param symbol_location: location of the symbol to inspect. + :param check_symbol_references: whether to additionally collect diagnostics for referencing symbols. + :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. + :return: an ordered mapping from symbols to the diagnostics that overlap their body ranges. + """ + if not symbol_location.has_position_in_file(): + raise ValueError("Symbol location does not contain a valid position in a file") + + symbol = self.find_by_location(symbol_location) + if symbol is None: + assert symbol_location.relative_path is not None + assert symbol_location.line is not None + assert symbol_location.column is not None + lang_server = self.get_language_server(symbol_location.relative_path) + symbol_dict = lang_server.request_symbol_at_location( + relative_file_path=symbol_location.relative_path, + line=symbol_location.line, + column=symbol_location.column, + ) + if symbol_dict is None: + return {} + symbol = LanguageServerSymbol(symbol_dict) + + symbols_to_check: "OrderedDict[tuple[str | None, int | None, int | None, str], LanguageServerSymbol]" = OrderedDict() + symbols_to_check[self._symbol_identity(symbol)] = symbol + + if check_symbol_references: + reference_symbols = self.find_referencing_symbols_by_location( + symbol.location, + include_body=False, + exclude_kinds=[SymbolKind.File, SymbolKind.Module, SymbolKind.Package, SymbolKind.Namespace], + ) + for reference in reference_symbols: + reference_relative_path = reference.get_relative_path() + normalized_reference_symbol = None + if reference_relative_path is not None: + normalized_reference_symbol = self.find_diagnostic_owner_symbol( + relative_file_path=reference_relative_path, + line=reference.line, + column=reference.character, + ) + if normalized_reference_symbol is None: + normalized_reference_symbol = self._normalize_symbol_for_diagnostics(reference.symbol) + symbols_to_check.setdefault(self._symbol_identity(normalized_reference_symbol), normalized_reference_symbol) + + result: dict[LanguageServerSymbol, list[ls_types.Diagnostic]] = {} + for current_symbol in symbols_to_check.values(): + diagnostics = self._get_diagnostics_for_symbol(current_symbol, min_severity=min_severity) + if diagnostics: + result[current_symbol] = diagnostics + return result + def get_symbol_overview(self, relative_path: str) -> dict[str, list[LanguageServerSymbol]]: """ :param relative_path: the path of the file for which to get the symbol overview diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py index 4e7268e0..4b4313eb 100644 --- a/src/serena/tools/file_tools.py +++ b/src/serena/tools/file_tools.py @@ -12,7 +12,7 @@ from fnmatch import fnmatch from pathlib import Path from typing import Literal -from serena.tools import SUCCESS_RESULT, EditedFileContext, Tool, ToolMarkerCanEdit, ToolMarkerOptional +from serena.tools import SUCCESS_RESULT, EditedFileContext, EditingToolWithDiagnostics, Tool, ToolMarkerOptional from serena.util.file_system import scan_directory from serena.util.text_utils import ContentReplacer, search_files @@ -48,7 +48,7 @@ class ReadFileTool(Tool): return self._limit_length(result, max_answer_chars) -class CreateTextFileTool(Tool, ToolMarkerCanEdit): +class CreateTextFileTool(EditingToolWithDiagnostics): """ Creates/overwrites a file in the project directory. """ @@ -61,23 +61,27 @@ class CreateTextFileTool(Tool, ToolMarkerCanEdit): :param content: the (appropriately encoded) content to write to the file :return: a message indicating success or failure """ - project_root = self.get_project_root() - abs_path = (Path(project_root) / relative_path).resolve() - will_overwrite_existing = abs_path.exists() + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + # validating the destination path + project_root = self.get_project_root() + abs_path = (Path(project_root) / relative_path).resolve() + will_overwrite_existing = abs_path.exists() - if will_overwrite_existing: - self.project.validate_relative_path(relative_path, require_not_ignored=True) - else: - assert abs_path.is_relative_to(self.get_project_root()), ( - f"Cannot create file outside of the project directory, got {relative_path=}" - ) + if will_overwrite_existing: + self.project.validate_relative_path(relative_path, require_not_ignored=True) + else: + assert abs_path.is_relative_to(self.get_project_root()), ( + f"Cannot create file outside of the project directory, got {relative_path=}" + ) - abs_path.parent.mkdir(parents=True, exist_ok=True) - abs_path.write_text(content, encoding=self.project.project_config.encoding, newline=self.project.line_ending.newline_str) - answer = f"File created: {relative_path}." - if will_overwrite_existing: - answer += " Overwrote existing file." - return answer + # writing the file + abs_path.parent.mkdir(parents=True, exist_ok=True) + abs_path.write_text(content, encoding=self.project.project_config.encoding, newline=self.project.line_ending.newline_str) + answer = f"File created: {relative_path}." + if will_overwrite_existing: + answer += " Overwrote existing file." + + return diagnostics_context.format_result(answer) class ListDirTool(Tool): @@ -156,7 +160,7 @@ class FindFileTool(Tool): return result -class ReplaceContentTool(Tool, ToolMarkerCanEdit): +class ReplaceContentTool(EditingToolWithDiagnostics): """ Replaces content in a file (optionally using regular expressions). """ @@ -212,16 +216,17 @@ class ReplaceContentTool(Tool, ToolMarkerCanEdit): Performs the replacement, with additional options not exposed in the tool. This function can be used internally by other tools. """ - self.project.validate_relative_path(relative_path, require_not_ignored=require_not_ignored) - with EditedFileContext(relative_path, self.create_code_editor()) as context: - original_content = context.get_original_content() - replacer = ContentReplacer(mode=mode, allow_multiple_occurrences=allow_multiple_occurrences) - updated_content = replacer.replace(original_content, needle, repl) - context.set_updated_content(updated_content) - return SUCCESS_RESULT + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + self.project.validate_relative_path(relative_path, require_not_ignored=require_not_ignored) + with EditedFileContext(relative_path, self.create_code_editor()) as context: + original_content = context.get_original_content() + replacer = ContentReplacer(mode=mode, allow_multiple_occurrences=allow_multiple_occurrences) + updated_content = replacer.replace(original_content, needle, repl) + context.set_updated_content(updated_content) + return diagnostics_context.format_result(SUCCESS_RESULT) -class DeleteLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): +class DeleteLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional): """ Deletes a range of lines within a file. """ @@ -241,12 +246,13 @@ class DeleteLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): :param start_line: the 0-based index of the first line to be deleted :param end_line: the 0-based index of the last line to be deleted """ - code_editor = self.create_code_editor() - code_editor.delete_lines(relative_path, start_line, end_line) - return SUCCESS_RESULT + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + code_editor = self.create_code_editor() + code_editor.delete_lines(relative_path, start_line, end_line) + return diagnostics_context.format_result(SUCCESS_RESULT) -class ReplaceLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): +class ReplaceLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional): """ Replaces a range of lines within a file with new content. """ @@ -268,16 +274,19 @@ class ReplaceLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): :param end_line: the 0-based index of the last line to be deleted :param content: the content to insert """ + # normalizing the replacement content if not content.endswith("\n"): content += "\n" - result = self.agent.get_tool(DeleteLinesTool).apply(relative_path, start_line, end_line) - if result != SUCCESS_RESULT: - return result - self.agent.get_tool(InsertAtLineTool).apply(relative_path, start_line, content) - return SUCCESS_RESULT + + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + code_editor = self.create_code_editor() + code_editor.delete_lines(relative_path, start_line, end_line) + code_editor.insert_at_line(relative_path, start_line, content) + + return diagnostics_context.format_result(SUCCESS_RESULT) -class InsertAtLineTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): +class InsertAtLineTool(EditingToolWithDiagnostics, ToolMarkerOptional): """ Inserts content at a given line in a file. """ @@ -298,11 +307,15 @@ class InsertAtLineTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): :param line: the 0-based index of the line to insert content at :param content: the content to be inserted """ + # normalizing the inserted content if not content.endswith("\n"): content += "\n" - code_editor = self.create_code_editor() - code_editor.insert_at_line(relative_path, line, content) - return SUCCESS_RESULT + + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + code_editor = self.create_code_editor() + code_editor.insert_at_line(relative_path, line, content) + + return diagnostics_context.format_result(SUCCESS_RESULT) class SearchForPatternTool(Tool): diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index 0664fb03..74a05359 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -595,3 +595,76 @@ class JetBrainsDebugTool(Tool, ToolMarkerOptional, ToolMarkerBeta): else: response = client.debug_close(repl_key=repl_key) return response.get("result", str(response)) + + +class JetBrainsRunInspectionsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): + """ + Runs JetBrains IDE inspections on a file and returns the results. + """ + + def apply( + self, + relative_path: str, + min_severity: str | None = None, + inspection_names: list[str] | None = None, + start_line: int | None = None, + end_line: int | None = None, + max_answer_chars: int = -1, + ) -> str: + """ + Runs IDE inspections (code analysis) on the given file and returns the problems found. + This leverages the full power of JetBrains' static analysis engine, including language-specific + inspections, type checking, potential bugs, code style issues, and more. + + :param relative_path: the relative path to the file to inspect. + :param min_severity: minimum severity level to include in results (e.g. "ERROR", "WARNING", "WEAK_WARNING", "INFO"). + If not specified, all severities are returned. + :param inspection_names: optional list of specific inspection names to run (e.g. ["UnusedImport", "TypeMismatch"]). + If not specified, all applicable inspections are run. + :param start_line: optional 1-based start line to restrict the inspection range. + :param end_line: optional 1-based end line to restrict the inspection range. + :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 with inspection results including severity, message, and location. + """ + with JetBrainsPluginClient.from_project(self.project) as client: + response_dict = client.run_inspections( + relative_path=relative_path, + min_severity=min_severity, + inspection_names=inspection_names, + start_line=start_line, + end_line=end_line, + ) + result = self._to_json(response_dict) + return self._limit_length(result, max_answer_chars) + + +class JetBrainsListInspectionsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): + """ + Lists available JetBrains IDE inspections, optionally filtered by language or group. + """ + + def apply( + self, + language: str | None = None, + group_path_contains: str | None = None, + max_answer_chars: int = -1, + ) -> str: + """ + Lists the available IDE inspections. Use this to discover which inspections can be passed + to the run_inspections tool's `inspection_names` parameter. + + :param language: optional language to filter by (e.g. "Java", "Python", "Kotlin"). + :param group_path_contains: optional substring to match against the inspection group path + (e.g. "probable bugs", "code style"). + :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 with the list of available inspections including name, group path, and language. + """ + with JetBrainsPluginClient.from_project(self.project) as client: + response_dict = client.list_inspections( + language=language, + group_path_contains=group_path_contains, + ) + result = self._to_json(response_dict) + return self._limit_length(result, max_answer_chars) diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index a19a99b9..caa42b3a 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -6,20 +6,24 @@ import copy import os from collections import Counter, defaultdict from collections.abc import Sequence +from typing import Any from serena.symbol import LanguageServerSymbol, LanguageServerSymbolDictGrouper from serena.tools import ( SUCCESS_RESULT, + EditingToolWithDiagnostics, Tool, ToolMarkerSymbolicEdit, ToolMarkerSymbolicRead, ) from serena.tools.tools_base import ToolMarkerOptional +from serena.util.ls_diagnostics import GroupedDiagnostics +from serena.util.text_utils import find_text_coordinates from solidlsp.ls_types import SymbolKind class RestartLanguageServerTool(Tool, ToolMarkerOptional): - """Restarts the language server, may be necessary when edits not through Serena happen.""" + """Restarts the language server(s).""" def apply(self) -> str: """Use this tool only on explicit user request or after confirmation. @@ -324,7 +328,242 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): return self._limit_length(result_json, max_answer_chars, shortened_result_factories=shortened_results) -class ReplaceSymbolBodyTool(Tool, ToolMarkerSymbolicEdit): +class FindImplementationsTool(Tool, ToolMarkerSymbolicRead): + """ + Finds symbols that implement the given symbol using the language server backend. + """ + + # noinspection PyDefaultArgument + def apply( + self, + name_path: str, + relative_path: str, + include_info: bool = False, + include_kinds: list[int] = [], # noqa: B006 + exclude_kinds: list[int] = [], # noqa: B006 + max_answer_chars: int = -1, + ) -> str: + """ + Finds implementations of the symbol at the given `name_path`. + + :param name_path: for finding the symbol to find implementations for, same logic as in the `find_symbol` tool. + :param relative_path: the relative path to the file containing the symbol for which to find implementations. + Note that here you can't pass a directory but must pass a file. + :param include_info: whether to include additional info (hover-like, typically including docstring and signature), + about the implementing symbols. + :param include_kinds: same as in the `find_symbol` tool. + :param exclude_kinds: same as in the `find_symbol` tool. + :param max_answer_chars: same as in the `find_symbol` tool. + :return: a list of JSON objects with the symbols implementing the requested symbol + """ + include_body = False + 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() + + implementing_symbols = symbol_retriever.find_implementing_symbols( + name_path, + relative_file_path=relative_path, + include_body=include_body, + include_kinds=parsed_include_kinds, + exclude_kinds=parsed_exclude_kinds, + ) + + symbol_dicts = [ + dict(s.to_dict(kind=True, relative_path=True, depth=0, body=include_body, body_location=True)) for s in implementing_symbols + ] + if include_info: + info_by_symbol = symbol_retriever.request_info_for_symbol_batch(implementing_symbols) + for s, s_dict in zip(implementing_symbols, symbol_dicts, strict=True): + if symbol_info := info_by_symbol.get(s): + s_dict["info"] = symbol_info + s_dict.pop("name", None) # name is included in the info + + result = self._to_json(symbol_dicts) + return self._limit_length(result, max_answer_chars) + + +class FindDeclarationTool(Tool, ToolMarkerSymbolicRead): + """ + Finds the declaration/definition of a symbol + """ + + def apply( + self, + relative_path: str, + regex: str, + containing_symbol_name_path: str | None = None, + include_body: bool = False, + include_info: bool = False, + ) -> str: + r""" + Finds the declaration of a symbol. + + :param relative_path: the relative path to the source file containing the symbol for which to find the declaration. + :param regex: a regular expression with one group, where the group matches the symbol for which to perform the lookup. + For example, to find the declaration of the `process` method in a call like `obj.process()`, + pass an expression like "obj\.(process)\(process_input_arg=37\)". + Prefer regexes with sufficiently large context around the group to render the match unambiguous. + Uses Python syntax with MULTILINE and DOTALL flags enabled. + :param containing_symbol_name_path: optional name path of a containing symbol whose body shall be searched instead of the full file. + :param include_body: whether to include the symbol's body in the result. Default False. + :param include_info: whether to include additional info (hover-like). Default False. + """ + symbol_retriever = self.create_language_server_symbol_retriever() + relative_path = self._sanitize_input_param(relative_path) + regex = self._sanitize_input_param(regex) + + # find relevant location for lookup + editor = self.create_code_editor() + if not containing_symbol_name_path: + content = editor.read_file(relative_path) + coords = find_text_coordinates(content, regex, require_unique=True) + assert coords is not None + else: + symbol = symbol_retriever.find_unique(name_path_pattern=containing_symbol_name_path, within_relative_path=relative_path) + body_line_numers = symbol.get_body_line_numbers_or_raise() + content = editor.read_file(relative_path, lines=body_line_numers) + coords = find_text_coordinates(content, regex, require_unique=True) + assert coords is not None + coords.line += body_line_numers[0] + + # retrieve declaration + defining_symbol = symbol_retriever.find_declaration( + relative_file_path=relative_path, + line=coords.line, + column=coords.col, + include_body=include_body, + ) + if defining_symbol is None: + raise ValueError( + f"No symbol declaration found at the location of the regex match. Location: {relative_path}:{coords.line}:{coords.col}." + ) + + # create output + symbol_dict = self._defining_symbol_to_result_dict( + symbol_retriever, + defining_symbol, + include_body, + include_info, + ) + result = self._to_json(symbol_dict) + return result + + @staticmethod + def _defining_symbol_to_result_dict( + symbol_retriever: Any, + defining_symbol: LanguageServerSymbol, + include_body: bool, + include_info: bool, + ) -> dict[str, Any]: + symbol_dict = dict(defining_symbol.to_dict(kind=True, relative_path=True, depth=0, body=include_body, body_location=True)) + if not include_body and include_info: + if symbol_info := symbol_retriever.request_info_for_symbol(defining_symbol): + symbol_dict["info"] = symbol_info + symbol_dict.pop("name", None) + return symbol_dict + + +class GetDiagnosticsForFileTool(Tool, ToolMarkerSymbolicRead): + """ + Gets diagnostics for a file, optionally restricted to a line range, grouped by file, severity, and containing symbol. + """ + + FILE_LEVEL_DIAGNOSTIC_BUCKET = "" + + def apply( + self, + relative_path: str, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + max_answer_chars: int = -1, + ) -> str: + """ + Gets diagnostics for a file. Diagnostics are grouped as `relative_path -> severity -> name_path -> diagnostics_results`. + If a diagnostic cannot be mapped to a symbol, it is grouped under the special name path ``. + + :param relative_path: the relative path to the file to inspect. + :param start_line: the first 0-based line to include. Defaults to 0. + :param end_line: the last 0-based line to include. Defaults to -1, which means until the end of the file. + :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. + Diagnostics with lower-or-equal numeric severity are returned. + :param max_answer_chars: same as in the `find_symbol` tool. + :return: grouped diagnostics for the requested file. + """ + symbol_retriever = self.create_language_server_symbol_retriever() + diagnostics = symbol_retriever.get_file_diagnostics( + relative_file_path=relative_path, + start_line=start_line, + end_line=end_line, + min_severity=min_severity, + ) + + grouped_diagnostics = GroupedDiagnostics() + for diagnostic in diagnostics: + diag_range = diagnostic["range"]["start"] + name_path = self.FILE_LEVEL_DIAGNOSTIC_BUCKET + owner_symbol = symbol_retriever.find_diagnostic_owner_symbol( + relative_file_path=relative_path, + line=diag_range["line"], + column=diag_range["character"], + ) + if owner_symbol is not None: + name_path = owner_symbol.get_name_path() + grouped_diagnostics.add(relative_path, name_path, diagnostic) + + result = self._to_json(grouped_diagnostics.get_dict()) + return self._limit_length(result, max_answer_chars) + + +class GetDiagnosticsForSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): + """ + Gets diagnostics for a symbol and, optionally, for symbols that reference it. + """ + + def apply( + self, + name_path: str, + reference_file: str = "", + check_symbol_references: bool = False, + min_severity: int = 4, + max_answer_chars: int = -1, + ) -> str: + """ + Gets diagnostics for the specified symbol. When `check_symbol_references` is true, diagnostics for all + referencing symbols are also included. The result is grouped as + `relative_path -> severity -> name_path -> diagnostics_results`. + + :param name_path: the name path of the symbol to inspect. + :param reference_file: optional file path used to disambiguate the symbol search. + :param check_symbol_references: whether to additionally collect diagnostics for symbols that reference the symbol. + :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. + Diagnostics with lower-or-equal numeric severity are returned. + :param max_answer_chars: same as in the `find_symbol` tool. + :return: grouped diagnostics for the requested symbol and, optionally, its referencing symbols. + """ + symbol_retriever = self.create_language_server_symbol_retriever() + diagnostics_by_symbol = symbol_retriever.get_symbol_diagnostics( + name_path=name_path, + reference_file=reference_file or None, + check_symbol_references=check_symbol_references, + min_severity=min_severity, + ) + + grouped_diagnostics = GroupedDiagnostics() + for symbol, diagnostics in diagnostics_by_symbol.items(): + relative_path = symbol.relative_path + if relative_path is None: + continue + symbol_name_path = symbol.get_name_path() + for diagnostic in diagnostics: + grouped_diagnostics.add(relative_path, symbol_name_path, diagnostic) + + result = self._to_json(grouped_diagnostics.get_dict()) + return self._limit_length(result, max_answer_chars) + + +class ReplaceSymbolBodyTool(EditingToolWithDiagnostics): """ Replaces the full definition of a symbol using the language server backend. """ @@ -348,16 +587,17 @@ class ReplaceSymbolBodyTool(Tool, ToolMarkerSymbolicEdit): in the programming language, including e.g. the signature line for functions. IMPORTANT: The body does NOT include any preceding docstrings/comments or imports, in particular. """ - code_editor = self.create_code_editor() - code_editor.replace_body( - name_path, - relative_file_path=relative_path, - body=body, - ) - return SUCCESS_RESULT + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + code_editor = self.create_code_editor() + code_editor.replace_body( + name_path, + relative_file_path=relative_path, + body=body, + ) + return diagnostics_context.format_result(SUCCESS_RESULT) -class InsertAfterSymbolTool(Tool, ToolMarkerSymbolicEdit): +class InsertAfterSymbolTool(EditingToolWithDiagnostics): """ Inserts content after the end of the definition of a given symbol. """ @@ -377,12 +617,13 @@ class InsertAfterSymbolTool(Tool, ToolMarkerSymbolicEdit): :param body: the body/content to be inserted. The inserted code shall begin with the next line after the symbol. """ - code_editor = self.create_code_editor() - code_editor.insert_after_symbol(name_path, relative_file_path=relative_path, body=body) - return SUCCESS_RESULT + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + code_editor = self.create_code_editor() + code_editor.insert_after_symbol(name_path, relative_file_path=relative_path, body=body) + return diagnostics_context.format_result(SUCCESS_RESULT) -class InsertBeforeSymbolTool(Tool, ToolMarkerSymbolicEdit): +class InsertBeforeSymbolTool(EditingToolWithDiagnostics): """ Inserts content before the beginning of the definition of a given symbol. """ @@ -402,9 +643,10 @@ class InsertBeforeSymbolTool(Tool, ToolMarkerSymbolicEdit): :param relative_path: the relative path to the file containing the symbol :param body: the body/content to be inserted before the line in which the referenced symbol is defined """ - code_editor = self.create_code_editor() - code_editor.insert_before_symbol(name_path, relative_file_path=relative_path, body=body) - return SUCCESS_RESULT + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + code_editor = self.create_code_editor() + code_editor.insert_before_symbol(name_path, relative_file_path=relative_path, body=body) + return diagnostics_context.format_result(SUCCESS_RESULT) class RenameSymbolTool(Tool, ToolMarkerSymbolicEdit): diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index c13e4cd4..565a4055 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -5,7 +5,7 @@ from collections.abc import Callable, Iterable from dataclasses import dataclass from functools import cached_property from types import TracebackType -from typing import TYPE_CHECKING, Any, Protocol, Self, TypeVar, cast +from typing import TYPE_CHECKING, Any, Optional, Protocol, Self, TypeVar, cast from mcp import Implementation from mcp.server.fastmcp import Context @@ -18,6 +18,7 @@ from serena.project import MemoriesManager, Project from serena.prompt_factory import PromptFactory from serena.util.class_decorators import singleton from serena.util.inspection import iter_subclasses +from serena.util.ls_diagnostics import DiagnosticsDiff, EditedFilePath, PublishedDiagnosticsSnapshot from solidlsp.ls_exceptions import SolidLSPException if TYPE_CHECKING: @@ -319,7 +320,7 @@ class Tool(Component): if client_str != self.get_last_tool_call_client_str(): log.debug(f"Updating client info: {client_info}") self.set_last_tool_call_client_str(client_str) - except BaseException as e: + except Exception as e: log.info(f"Failed to get client info: {e}.") def task() -> str: @@ -403,6 +404,62 @@ class Tool(Component): return json.dumps(x, ensure_ascii=False) +class EditingToolWithDiagnostics(Tool, ToolMarkerCanEdit): + """ + Base class for editing tools that want to capture and report changes in LSP diagnostics before and after the edit. + """ + + ENABLE_DIAGNOSTICS: bool = False + """ + Global flag to enable/disable diagnostics for LSP-based editing tools derived from this class. + The feature is currently disabled, because per-edit diagnostics are a questionable feature, since individual + edits often intentionally introduce diagnostics (e.g. function signature mismatches or even syntax errors) that + are then resolved in subsequent edits. + """ + + DIAGNOSTICS_KEY = "diagnostics[warning-or-higher]" + + class DiagnosticsContext: + def __init__(self, tool: "EditingToolWithDiagnostics", *edited_relative_paths: str) -> None: + self._tool = tool + self._is_diagnostics_enabled = tool.ENABLE_DIAGNOSTICS and tool.agent.is_using_language_server() + self._edited_files = [EditedFilePath(path, path) for path in edited_relative_paths] + self._before_edit_diagnostics_snapshot: PublishedDiagnosticsSnapshot | None = None + self._symbol_retriever: Optional["LanguageServerSymbolRetriever"] | None = None + if self._is_diagnostics_enabled: + self._symbol_retriever = tool.create_language_server_symbol_retriever() + self._before_edit_diagnostics_snapshot = PublishedDiagnosticsSnapshot(self._edited_files, self._symbol_retriever) + + def __enter__(self) -> Self: + return self + + def __exit__(self, exc_type, exc_val, exc_tb): # type: ignore + pass + + def format_result( + self, + base_result: str, + ) -> str: + if not self._is_diagnostics_enabled: + return base_result + + if self._before_edit_diagnostics_snapshot is None: + return base_result + + assert self._symbol_retriever is not None + diagnostics_diff = DiagnosticsDiff(self._before_edit_diagnostics_snapshot, self._edited_files, self._symbol_retriever) + grouped_diagnostics = diagnostics_diff.get_grouped_diagnostics().get_dict() + + if not grouped_diagnostics: + return base_result + else: + result_dict = { + "result": base_result, + EditingToolWithDiagnostics.DIAGNOSTICS_KEY: grouped_diagnostics, + } + return self._tool._to_json(result_dict) + + class EditedFileContext: """ Context manager for file editing. diff --git a/src/serena/util/file_system.py b/src/serena/util/file_system.py index 8240653d..6ec1582e 100644 --- a/src/serena/util/file_system.py +++ b/src/serena/util/file_system.py @@ -173,6 +173,9 @@ class GitignoreParser: except PermissionError as ex: log.debug(f"Skipping entry due to permission error: {entry.path}", exc_info=ex) continue + except FileNotFoundError as ex: + log.debug(f"Skipping entry due to file not found error (possibly a broken link): {entry.path}", exc_info=ex) + continue while queue: next_abs_path = queue.pop(0) diff --git a/src/serena/util/ls_diagnostics.py b/src/serena/util/ls_diagnostics.py new file mode 100644 index 00000000..13b505ea --- /dev/null +++ b/src/serena/util/ls_diagnostics.py @@ -0,0 +1,203 @@ +import json +from collections.abc import Iterable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from solidlsp import ls_types +from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticSeverity + +if TYPE_CHECKING: + from serena.symbol import LanguageServerSymbolRetriever + + +@dataclass(frozen=True) +class EditedFilePath: + before_relative_path: str + after_relative_path: str + + +@dataclass(frozen=True) +class DiagnosticIdentity: + message: str + start_line: int + start_character: int + end_line: int + end_character: int + severity: int | None + code_repr: str | None + source: str | None + + @classmethod + def from_diagnostic(cls, diagnostic: ls_types.Diagnostic) -> "DiagnosticIdentity": + diagnostic_range = diagnostic["range"] + start = diagnostic_range["start"] + end = diagnostic_range["end"] + return DiagnosticIdentity( + message=diagnostic["message"], + start_line=start["line"], + start_character=start["character"], + end_line=end["line"], + end_character=end["character"], + severity=diagnostic.get("severity"), + code_repr=cls._diagnostic_code_repr(diagnostic.get("code")), + source=diagnostic.get("source"), + ) + + @staticmethod + def _diagnostic_code_repr(code: Any) -> str | None: + if code is None: + return None + try: + return json.dumps(code, sort_keys=True, ensure_ascii=False) + except TypeError: + return repr(code) + + +class PublishedDiagnosticsSnapshot: + def __init__(self, edited_file_paths: Iterable[EditedFilePath], symbol_retriever: "LanguageServerSymbolRetriever"): + generation_by_after_path: dict[str, int] = {} + warning_identities_by_before_path: dict[str, set[DiagnosticIdentity]] = {} + + for edited_file_path in edited_file_paths: + try: + language_server = symbol_retriever.get_language_server(edited_file_path.after_relative_path) + except Exception: + continue + + generation_by_after_path[edited_file_path.after_relative_path] = language_server.get_published_diagnostics_generation( + edited_file_path.after_relative_path + ) + + cached_diagnostics = language_server.get_cached_published_text_document_diagnostics( + edited_file_path.before_relative_path, + min_severity=2, + ) + if cached_diagnostics is None: + try: + cached_diagnostics = language_server.request_text_document_diagnostics( + edited_file_path.before_relative_path, + min_severity=2, + ) + except Exception: + cached_diagnostics = [] + warning_identities_by_before_path[edited_file_path.before_relative_path] = { + DiagnosticIdentity.from_diagnostic(diagnostic) for diagnostic in cached_diagnostics or [] + } + + self.generation_by_after_path = generation_by_after_path + self.warning_identities_by_before_path = warning_identities_by_before_path + + +class GroupedDiagnostics: + def __init__(self) -> None: + self._grouped_diagnostics: dict[str, dict[str, dict[str, list[dict[str, Any]]]]] = {} + + def add(self, relative_path: str, name_path: str, diagnostic: ls_types.Diagnostic) -> None: + severity_name = self._diagnostic_severity_name(diagnostic.get("severity")) + self._grouped_diagnostics.setdefault(relative_path, {}).setdefault(severity_name, {}).setdefault(name_path, []).append( + self._diagnostic_output_dict(diagnostic) + ) + + def get_dict(self) -> dict[str, dict[str, dict[str, list[dict[str, Any]]]]]: + """ + Returns a nested dictionary of the form: + { + relative_file_path: { + severity_name: { + name_path: [ + diagnostic_dict, + ... + ], + ... + }, + ... + }, + ... + } + where: + - relative_file_path is the relative path of the file containing the diagnostic + - severity_name is the name of the diagnostic severity (e.g. "Warning", "Error") + - name_path is the name path of the symbol that owns the diagnostic (or "" if no owner symbol was found) + - diagnostic_dict is a dictionary containing the diagnostic's message, range, and optionally code and source + """ + return self._grouped_diagnostics + + @staticmethod + def _diagnostic_severity_name(severity: int | None) -> str: + if severity is None: + return "Unknown" + try: + return DiagnosticSeverity(severity).name + except ValueError: + return f"Severity_{severity}" + + @staticmethod + def _diagnostic_output_dict(diagnostic: ls_types.Diagnostic) -> dict[str, Any]: + result: dict[str, Any] = { + "message": diagnostic["message"], + "range": diagnostic["range"], + } + if "code" in diagnostic: + result["code"] = diagnostic["code"] + if "source" in diagnostic: + result["source"] = diagnostic["source"] + return result + + +class DiagnosticsDiff: + def __init__( + self, + before_snapshot: PublishedDiagnosticsSnapshot, + edited_files: Iterable[EditedFilePath], + symbol_retriever: "LanguageServerSymbolRetriever", + ): + grouped_diagnostics = GroupedDiagnostics() + + for edited_file_path in edited_files: + try: + language_server = symbol_retriever.get_language_server(edited_file_path.after_relative_path) + except: + continue + + published_diagnostics = language_server.request_published_text_document_diagnostics( + relative_file_path=edited_file_path.after_relative_path, + after_generation=before_snapshot.generation_by_after_path.get(edited_file_path.after_relative_path, -1), + timeout=2.5, + min_severity=2, + allow_cached=True, + ) + if not published_diagnostics: + try: + published_diagnostics = language_server.request_text_document_diagnostics( + edited_file_path.after_relative_path, + min_severity=2, + ) + except: + published_diagnostics = None + if published_diagnostics is None: + continue + + existing_warning_identities = before_snapshot.warning_identities_by_before_path.get( + edited_file_path.before_relative_path, set() + ) + new_warning_identities: set[DiagnosticIdentity] = set() + + for diagnostic in published_diagnostics: + diagnostic_identity = DiagnosticIdentity.from_diagnostic(diagnostic) + if diagnostic_identity in existing_warning_identities or diagnostic_identity in new_warning_identities: + continue + new_warning_identities.add(diagnostic_identity) + + diagnostic_start = diagnostic["range"]["start"] + owner_symbol = symbol_retriever.find_diagnostic_owner_symbol( + relative_file_path=edited_file_path.after_relative_path, + line=diagnostic_start["line"], + column=diagnostic_start["character"], + ) + name_path = owner_symbol.get_name_path() if owner_symbol is not None else "" + grouped_diagnostics.add(edited_file_path.after_relative_path, name_path, diagnostic) + + self._grouped_diagnostics = grouped_diagnostics + + def get_grouped_diagnostics(self) -> GroupedDiagnostics: + return self._grouped_diagnostics diff --git a/src/solidlsp/language_servers/al_language_server.py b/src/solidlsp/language_servers/al_language_server.py index ed3cf9c7..438b1d3d 100644 --- a/src/solidlsp/language_servers/al_language_server.py +++ b/src/solidlsp/language_servers/al_language_server.py @@ -28,11 +28,28 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) -AL_EXTENSION_VERSION = "18.0.2242655" -AL_EXTENSION_URL = ( - f"https://marketplace.visualstudio.com/_apis/public/gallery/publishers/ms-dynamics-smb/vsextensions/al/{AL_EXTENSION_VERSION}/vspackage" -) -AL_EXTENSION_SHA256 = "3971995e61a59dc4fcce4a65053072a67991ed624a16635c4f2911f12564b2b9" +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_AL_EXTENSION_VERSION = "18.0.2242655" +INITIAL_AL_EXTENSION_SHA256 = "3971995e61a59dc4fcce4a65053072a67991ed624a16635c4f2911f12564b2b9" +DEFAULT_AL_EXTENSION_VERSION = "18.0.2242655" +DEFAULT_AL_EXTENSION_SHA256 = "3971995e61a59dc4fcce4a65053072a67991ed624a16635c4f2911f12564b2b9" + + +def _al_extension_sha(version: str) -> str | None: + if version == INITIAL_AL_EXTENSION_VERSION: + return INITIAL_AL_EXTENSION_SHA256 + if version == DEFAULT_AL_EXTENSION_VERSION: + return DEFAULT_AL_EXTENSION_SHA256 + return None + + +def _al_extension_dirname(version: str) -> str: + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + return "al-extension" if version == INITIAL_AL_EXTENSION_VERSION else f"al-extension-{version}" + + AL_EXTENSION_ALLOWED_HOSTS = ("marketplace.visualstudio.com",) @@ -131,21 +148,13 @@ class ALLanguageServer(SolidLanguageServer): return path.replace("\\", "/") @classmethod - def _download_al_extension(cls, url: str, target_dir: str) -> bool: + def _download_al_extension(cls, url: str, target_dir: str, expected_sha256: str | None) -> bool: """ Download and extract the AL extension from VS Code marketplace. The VS Code marketplace packages extensions as .vsix files (which are ZIP archives). This method downloads the VSIX file and extracts it to get the language server binaries. - Args: - logger: Logger for tracking download progress - url: VS Code marketplace URL for the AL extension - target_dir: Directory where the extension will be extracted - - Returns: - True if successful, False otherwise - Note: The download includes progress tracking and proper user-agent headers to ensure compatibility with the VS Code marketplace. @@ -158,7 +167,7 @@ class ALLanguageServer(SolidLanguageServer): url, target_dir, "zip", - expected_sha256=AL_EXTENSION_SHA256 if url == AL_EXTENSION_URL else None, + expected_sha256=expected_sha256, allowed_hosts=AL_EXTENSION_ALLOWED_HOSTS, ) log.info("AL extension extracted successfully") @@ -231,8 +240,10 @@ class ALLanguageServer(SolidLanguageServer): elif env_path: 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") + # Check the resolved-version download location (versioned for non-INITIAL, legacy "al-extension" for INITIAL) + al_settings = solidlsp_settings.get_ls_specific_settings(Language.AL) + al_extension_version = al_settings.get("al_extension_version", DEFAULT_AL_EXTENSION_VERSION) + default_path = os.path.join(cls.ls_resources_dir(solidlsp_settings), _al_extension_dirname(al_extension_version), "extension") if os.path.exists(default_path): log.debug(f"Found AL extension in default location: {default_path}") return default_path @@ -255,9 +266,9 @@ class ALLanguageServer(SolidLanguageServer): Path to installed extension or None if download failed """ - al_extension_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "al-extension") al_settings = solidlsp_settings.get_ls_specific_settings(Language.AL) - al_extension_version = al_settings.get("al_extension_version", AL_EXTENSION_VERSION) + al_extension_version = al_settings.get("al_extension_version", DEFAULT_AL_EXTENSION_VERSION) + al_extension_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), _al_extension_dirname(al_extension_version)) al_extension_url = ( "https://marketplace.visualstudio.com/_apis/public/gallery/publishers/ms-dynamics-smb/" f"vsextensions/al/{al_extension_version}/vspackage" @@ -265,7 +276,7 @@ class ALLanguageServer(SolidLanguageServer): log.info(f"Downloading AL extension from: {al_extension_url}") - if cls._download_al_extension(al_extension_url, al_extension_dir): + if cls._download_al_extension(al_extension_url, al_extension_dir, _al_extension_sha(al_extension_version)): extension_path = os.path.join(al_extension_dir, "extension") if os.path.exists(extension_path): log.info("AL extension downloaded and installed successfully") diff --git a/src/solidlsp/language_servers/ansible_language_server.py b/src/solidlsp/language_servers/ansible_language_server.py index 2b3da993..3fb1a3b6 100644 --- a/src/solidlsp/language_servers/ansible_language_server.py +++ b/src/solidlsp/language_servers/ansible_language_server.py @@ -20,6 +20,12 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_ANSIBLE_LANGUAGE_SERVER_VERSION = "1.2.3" +DEFAULT_ANSIBLE_LANGUAGE_SERVER_VERSION = "1.2.3" + def _deep_merge(base: dict, override: dict) -> None: """Recursively merge *override* into *base*, modifying *base* in-place.""" @@ -162,7 +168,9 @@ class AnsibleLanguageServer(SolidLanguageServer): assert is_node_installed, "node is not installed or isn't in PATH. Please install Node.js 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." - ansible_language_server_version = self._custom_settings.get("ansible_language_server_version", "1.2.3") + ansible_language_server_version = self._custom_settings.get( + "ansible_language_server_version", DEFAULT_ANSIBLE_LANGUAGE_SERVER_VERSION + ) npm_registry = self._custom_settings.get("npm_registry") deps = RuntimeDependencyCollection( @@ -180,8 +188,13 @@ class AnsibleLanguageServer(SolidLanguageServer): ] ) - # install ansible-language-server if not already installed - ansible_ls_dir = os.path.join(self._ls_resources_dir, "ansible-lsp") + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + ls_dirname = ( + "ansible-lsp" + if ansible_language_server_version == INITIAL_ANSIBLE_LANGUAGE_SERVER_VERSION + else f"ansible-lsp-{ansible_language_server_version}" + ) + ansible_ls_dir = os.path.join(self._ls_resources_dir, ls_dirname) ansible_executable_path = os.path.join(ansible_ls_dir, "node_modules", ".bin", "ansible-language-server") # handle Windows executable extension diff --git a/src/solidlsp/language_servers/bash_language_server.py b/src/solidlsp/language_servers/bash_language_server.py index c618884b..0338a5ef 100644 --- a/src/solidlsp/language_servers/bash_language_server.py +++ b/src/solidlsp/language_servers/bash_language_server.py @@ -18,12 +18,72 @@ from solidlsp.ls import ( SolidLanguageServer, ) from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_utils import FileUtils, PlatformId, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) +# ShellCheck binary release info; download directly from upstream GitHub releases rather than +# relying on the `shellcheck` npm wrapper (which lazily downloads on first invocation and +# turned the bash LS install into a fragile, network-dependent step). +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_BASH_LANGUAGE_SERVER_VERSION = "5.6.0" +DEFAULT_BASH_LANGUAGE_SERVER_VERSION = "5.6.0" + +# ShellCheck binary path already encodes _SHELLCHECK_VERSION (see _shellcheck_binary_path), +# so version bumps trigger reinstall correctly without further intervention. +_SHELLCHECK_VERSION = "0.10.0" +_SHELLCHECK_RELEASE_BASE = f"https://github.com/koalaman/shellcheck/releases/download/v{_SHELLCHECK_VERSION}" +_SHELLCHECK_ALLOWED_HOSTS = ( + "github.com", + "objects.githubusercontent.com", + "release-assets.githubusercontent.com", +) +# Per-platform archive metadata: tar.xz on POSIX (extracts to shellcheck-v/shellcheck), +# zip on Windows (extracts to shellcheck.exe at archive root). +_SHELLCHECK_DEPENDENCIES: dict[PlatformId, dict[str, str]] = { + PlatformId.LINUX_x64: { + "url": f"{_SHELLCHECK_RELEASE_BASE}/shellcheck-v{_SHELLCHECK_VERSION}.linux.x86_64.tar.xz", + "sha256": "6c881ab0698e4e6ea235245f22832860544f17ba386442fe7e9d629f8cbedf87", + }, + PlatformId.LINUX_arm64: { + "url": f"{_SHELLCHECK_RELEASE_BASE}/shellcheck-v{_SHELLCHECK_VERSION}.linux.aarch64.tar.xz", + "sha256": "324a7e89de8fa2aed0d0c28f3dab59cf84c6d74264022c00c22af665ed1a09bb", + }, + PlatformId.OSX_x64: { + "url": f"{_SHELLCHECK_RELEASE_BASE}/shellcheck-v{_SHELLCHECK_VERSION}.darwin.x86_64.tar.xz", + "sha256": "ef27684f23279d112d8ad84e0823642e43f838993bbb8c0963db9b58a90464c2", + }, + PlatformId.OSX_arm64: { + "url": f"{_SHELLCHECK_RELEASE_BASE}/shellcheck-v{_SHELLCHECK_VERSION}.darwin.aarch64.tar.xz", + "sha256": "bbd2f14826328eee7679da7221f2bc3afb011f6a928b848c80c321f6046ddf81", + }, + PlatformId.WIN_x64: { + "url": f"{_SHELLCHECK_RELEASE_BASE}/shellcheck-v{_SHELLCHECK_VERSION}.zip", + "sha256": "eb6cd53a54ea97a56540e9d296ce7e2fa68715aa507ff23574646c1e12b2e143", + }, +} + + +def _shellcheck_install_dir(bash_ls_dir: str) -> str: + return os.path.join(bash_ls_dir, "shellcheck") + + +def _shellcheck_binary_path(bash_ls_dir: str) -> str: + """ + Returns the path to the extracted ShellCheck binary. POSIX archives extract under + ``shellcheck-v/shellcheck``; the Windows zip drops ``shellcheck.exe`` at archive root. + """ + install_dir = _shellcheck_install_dir(bash_ls_dir) + if os.name == "nt": + return os.path.join(install_dir, "shellcheck.exe") + return os.path.join(install_dir, f"shellcheck-v{_SHELLCHECK_VERSION}", "shellcheck") + + class BashLanguageServer(SolidLanguageServer): """ Provides Bash specific instantiation of the LanguageServer class using bash-language-server. @@ -53,37 +113,37 @@ class BashLanguageServer(SolidLanguageServer): """ Setup runtime dependencies for Bash Language Server and return the command to start the server. """ - # Verify both node and npm are installed + # verify node + npm are available for the bash-language-server install 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." - bash_language_server_version = self._custom_settings.get("bash_language_server_version", "5.6.0") + bash_language_server_version = self._custom_settings.get("bash_language_server_version", DEFAULT_BASH_LANGUAGE_SERVER_VERSION) npm_registry = self._custom_settings.get("npm_registry") - deps = RuntimeDependencyCollection( - [ - RuntimeDependency( - id="bash-language-server", - description="bash-language-server package", - command=build_npm_install_command("bash-language-server", bash_language_server_version, npm_registry), - platform_id="any", - ), - ] - ) - - # Install bash-language-server if not already installed - bash_ls_dir = os.path.join(self._ls_resources_dir, "bash-lsp") - bash_executable_path = os.path.join(bash_ls_dir, "node_modules", ".bin", "bash-language-server") - - # Handle Windows executable extension + bash_ls_dir = self._resolve_bash_ls_dir(bash_language_server_version) + managed_bin_dir = os.path.join(bash_ls_dir, "node_modules", ".bin") + bash_executable_path = os.path.join(managed_bin_dir, "bash-language-server") if os.name == "nt": bash_executable_path += ".cmd" + # install bash-language-server via npm if not os.path.exists(bash_executable_path): - 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") + bls_deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="bash-language-server", + description="bash-language-server package", + command=build_npm_install_command("bash-language-server", bash_language_server_version, npm_registry), + platform_id="any", + ), + ] + ) + log.info("Installing bash-language-server...") + bls_deps.install(bash_ls_dir) + + # install ShellCheck binary directly from upstream releases for the current platform + self._install_shellcheck_if_missing(bash_ls_dir) if not os.path.exists(bash_executable_path): raise FileNotFoundError( @@ -92,6 +152,59 @@ class BashLanguageServer(SolidLanguageServer): return bash_executable_path + @staticmethod + def _install_shellcheck_if_missing(bash_ls_dir: str) -> None: + """ + Downloads and extracts the platform-appropriate ShellCheck release into + ``${bash_ls_dir}/shellcheck`` if the binary is not already present. + """ + binary_path = _shellcheck_binary_path(bash_ls_dir) + if os.path.exists(binary_path): + return + + install_dir = _shellcheck_install_dir(bash_ls_dir) + os.makedirs(install_dir, exist_ok=True) + + release = _SHELLCHECK_DEPENDENCIES.get(PlatformUtils.get_platform_id()) + if release is None: + raise RuntimeError(f"ShellCheck has no upstream binary release for platform {PlatformUtils.get_platform_id().value}") + + archive_type = "zip" if os.name == "nt" else "xztar" + log.info(f"Downloading ShellCheck v{_SHELLCHECK_VERSION} for {PlatformUtils.get_platform_id().value}") + FileUtils.download_and_extract_archive_verified( + release["url"], + install_dir, + archive_type, + expected_sha256=release["sha256"], + allowed_hosts=_SHELLCHECK_ALLOWED_HOSTS, + ) + + if not os.path.exists(binary_path): + raise FileNotFoundError(f"ShellCheck binary not found at {binary_path} after extraction; archive layout may have changed.") + + # ensure the binary is executable on POSIX (zip extraction does not preserve perms) + if os.name != "nt": + current = os.stat(binary_path).st_mode + os.chmod(binary_path, current | 0o111) + + def create_launch_command_env(self) -> dict[str, str]: + bash_language_server_version = self._custom_settings.get("bash_language_server_version", DEFAULT_BASH_LANGUAGE_SERVER_VERSION) + bash_ls_dir = self._resolve_bash_ls_dir(bash_language_server_version) + managed_bin_dir = os.path.join(bash_ls_dir, "node_modules", ".bin") + return { + "PATH": managed_bin_dir + os.pathsep + os.environ.get("PATH", ""), + "SHELLCHECK_PATH": _shellcheck_binary_path(bash_ls_dir), + } + + def _resolve_bash_ls_dir(self, bash_language_server_version: str) -> str: + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + ls_dirname = ( + "bash-lsp" + if bash_language_server_version == INITIAL_BASH_LANGUAGE_SERVER_VERSION + else f"bash-lsp-{bash_language_server_version}" + ) + return os.path.join(self._ls_resources_dir, ls_dirname) + def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "start"] diff --git a/src/solidlsp/language_servers/clangd_language_server.py b/src/solidlsp/language_servers/clangd_language_server.py index 0dc5ed9c..7b7d6839 100644 --- a/src/solidlsp/language_servers/clangd_language_server.py +++ b/src/solidlsp/language_servers/clangd_language_server.py @@ -34,6 +34,39 @@ class ClangdLanguageServer(SolidLanguageServer): (default: the bundled Serena version). """ + @staticmethod + def _determine_log_level(line: str) -> int: + """ + Classify a clangd stderr line using clangd's explicit level prefix. + + See `clang::clangd::Logger::indicator` for details: + https://clang.llvm.org/extra/doxygen/classclang_1_1clangd_1_1Logger.html + + Clangd emits each log record prefixed by a single indicator character + followed by a timestamp in square brackets, e.g. ``I[12:27:16.234]``. + + The indicators are ``D`` (Debug), ``I`` (Info), ``E`` (Error) and + ``F`` (Fatal). Continuation lines of multi-line records carry no + prefix and are treated as informational. + + Without this override, the base implementation scans the line for + the substrings ``error`` and ``exception``, which produces false + positives on clangd's reconstructed compile commands in some cases + (e.g. ``-DNO_EXCEPTIONS``, ``-fno-exceptions``). + """ + # classify by clangd's level indicator character + if len(line) >= 2 and line[1] == "[": + indicator = line[0] + if indicator in ("E", "F"): + return logging.ERROR + if indicator == "I": + return logging.INFO + if indicator == "D": + return logging.DEBUG + + # continuation line or non-prefixed output: default to INFO, do not keyword-scan + return logging.INFO + 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. diff --git a/src/solidlsp/language_servers/clojure_lsp.py b/src/solidlsp/language_servers/clojure_lsp.py index 4fdc3ce9..dc238256 100644 --- a/src/solidlsp/language_servers/clojure_lsp.py +++ b/src/solidlsp/language_servers/clojure_lsp.py @@ -21,7 +21,35 @@ from .common import RuntimeDependency, RuntimeDependencyCollection log = logging.getLogger(__name__) -CLOJURE_LSP_VERSION = "2026.02.20-16.08.58" +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_CLOJURE_LSP_VERSION = "2026.02.20-16.08.58" +INITIAL_CLOJURE_LSP_SHA256_BY_PLATFORM = { + "osx-arm64": "a14d4db074f665378214e2dc888472e186c228dfa065c777b0534bfda5571669", + "osx-x64": "5507434c27104ab816e096d3336d8191641de8a65b57d76afb585d07167a3cf2", + "linux-arm64": "f8f09fa07dd4b6743b5c57270ccf1ee5cdbc5fca09dbca8b6a3b22705b5da4e1", + "linux-x64": "52e8bf4fd4cf171df0a3077c8bb5a3bf598d4c621e94b4876dab943a61267309", + "win-x64": "817b1271288817c954fb9e595278b1f25003827ce31f8785f253dc4ac911041f", +} +DEFAULT_CLOJURE_LSP_VERSION = "2026.02.20-16.08.58" +DEFAULT_CLOJURE_LSP_SHA256_BY_PLATFORM = { + "osx-arm64": "a14d4db074f665378214e2dc888472e186c228dfa065c777b0534bfda5571669", + "osx-x64": "5507434c27104ab816e096d3336d8191641de8a65b57d76afb585d07167a3cf2", + "linux-arm64": "f8f09fa07dd4b6743b5c57270ccf1ee5cdbc5fca09dbca8b6a3b22705b5da4e1", + "linux-x64": "52e8bf4fd4cf171df0a3077c8bb5a3bf598d4c621e94b4876dab943a61267309", + "win-x64": "817b1271288817c954fb9e595278b1f25003827ce31f8785f253dc4ac911041f", +} + + +def _clojure_lsp_sha(version: str, platform_key: str) -> str | None: + if version == INITIAL_CLOJURE_LSP_VERSION: + return INITIAL_CLOJURE_LSP_SHA256_BY_PLATFORM[platform_key] + if version == DEFAULT_CLOJURE_LSP_VERSION: + return DEFAULT_CLOJURE_LSP_SHA256_BY_PLATFORM[platform_key] + return None + + CLOJURE_LSP_ALLOWED_HOSTS = ( "github.com", "release-assets.githubusercontent.com", @@ -58,7 +86,6 @@ class ClojureLSP(SolidLanguageServer): by Serena (default: the bundled Serena version). """ - CLOJURE_LSP_VERSION = CLOJURE_LSP_VERSION CLOJURE_LSP_ALLOWED_HOSTS = CLOJURE_LSP_ALLOWED_HOSTS @override @@ -69,7 +96,6 @@ class ClojureLSP(SolidLanguageServer): @classmethod def _runtime_dependencies(cls, version: str) -> RuntimeDependencyCollection: clojure_lsp_releases = f"https://github.com/clojure-lsp/clojure-lsp/releases/download/{version}" - default_version = version == cls.CLOJURE_LSP_VERSION return RuntimeDependencyCollection( [ RuntimeDependency( @@ -78,7 +104,7 @@ class ClojureLSP(SolidLanguageServer): platform_id="osx-arm64", archive_type="zip", binary_name="clojure-lsp", - sha256="a14d4db074f665378214e2dc888472e186c228dfa065c777b0534bfda5571669" if default_version else None, + sha256=_clojure_lsp_sha(version, "osx-arm64"), allowed_hosts=CLOJURE_LSP_ALLOWED_HOSTS, ), RuntimeDependency( @@ -87,7 +113,7 @@ class ClojureLSP(SolidLanguageServer): platform_id="osx-x64", archive_type="zip", binary_name="clojure-lsp", - sha256="5507434c27104ab816e096d3336d8191641de8a65b57d76afb585d07167a3cf2" if default_version else None, + sha256=_clojure_lsp_sha(version, "osx-x64"), allowed_hosts=CLOJURE_LSP_ALLOWED_HOSTS, ), RuntimeDependency( @@ -96,7 +122,7 @@ class ClojureLSP(SolidLanguageServer): platform_id="linux-arm64", archive_type="zip", binary_name="clojure-lsp", - sha256="f8f09fa07dd4b6743b5c57270ccf1ee5cdbc5fca09dbca8b6a3b22705b5da4e1" if default_version else None, + sha256=_clojure_lsp_sha(version, "linux-arm64"), allowed_hosts=CLOJURE_LSP_ALLOWED_HOSTS, ), RuntimeDependency( @@ -105,7 +131,7 @@ class ClojureLSP(SolidLanguageServer): platform_id="linux-x64", archive_type="zip", binary_name="clojure-lsp", - sha256="52e8bf4fd4cf171df0a3077c8bb5a3bf598d4c621e94b4876dab943a61267309" if default_version else None, + sha256=_clojure_lsp_sha(version, "linux-x64"), allowed_hosts=CLOJURE_LSP_ALLOWED_HOSTS, ), RuntimeDependency( @@ -114,7 +140,7 @@ class ClojureLSP(SolidLanguageServer): platform_id="win-x64", archive_type="zip", binary_name="clojure-lsp.exe", - sha256="817b1271288817c954fb9e595278b1f25003827ce31f8785f253dc4ac911041f" if default_version else None, + sha256=_clojure_lsp_sha(version, "win-x64"), allowed_hosts=CLOJURE_LSP_ALLOWED_HOSTS, ), ] @@ -143,16 +169,22 @@ class ClojureLSP(SolidLanguageServer): def _get_or_install_core_dependency(self) -> str: """Setup runtime dependencies for clojure-lsp and return the path to the executable.""" verify_clojure_cli() - clojure_lsp_version = self._custom_settings.get("clojure_lsp_version", ClojureLSP.CLOJURE_LSP_VERSION) + clojure_lsp_version = self._custom_settings.get("clojure_lsp_version", DEFAULT_CLOJURE_LSP_VERSION) deps = ClojureLSP._runtime_dependencies(clojure_lsp_version) dependency = deps.get_single_dep_for_current_platform() - clojurelsp_executable_path = deps.binary_path(self._ls_resources_dir) + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + install_dir = ( + self._ls_resources_dir + if clojure_lsp_version == INITIAL_CLOJURE_LSP_VERSION + else os.path.join(self._ls_resources_dir, f"clojure-lsp-{clojure_lsp_version}") + ) + clojurelsp_executable_path = deps.binary_path(install_dir) if not os.path.exists(clojurelsp_executable_path): log.info( - f"Downloading and extracting clojure-lsp from {dependency.url} to {self._ls_resources_dir}", + f"Downloading and extracting clojure-lsp from {dependency.url} to {install_dir}", ) - deps.install(self._ls_resources_dir) + deps.install(install_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) diff --git a/src/solidlsp/language_servers/csharp_language_server.py b/src/solidlsp/language_servers/csharp_language_server.py index 59b1fc9e..51bed54e 100644 --- a/src/solidlsp/language_servers/csharp_language_server.py +++ b/src/solidlsp/language_servers/csharp_language_server.py @@ -206,6 +206,10 @@ class CSharpLanguageServer(SolidLanguageServer): scripts. If you have a custom .NET installation, ensure 'dotnet' is available in PATH with version 10 or higher. """ + @classmethod + def supports_implementation_request(cls) -> bool: + return True + 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. diff --git a/src/solidlsp/language_servers/dart_language_server.py b/src/solidlsp/language_servers/dart_language_server.py index f82627c6..800f69eb 100644 --- a/src/solidlsp/language_servers/dart_language_server.py +++ b/src/solidlsp/language_servers/dart_language_server.py @@ -18,6 +18,34 @@ log = logging.getLogger(__name__) DART_ALLOWED_HOSTS = ("storage.googleapis.com",) +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_DART_SDK_VERSION = "3.7.1" +INITIAL_DART_SDK_SHA256_BY_PLATFORM = { + "linux-x64": "2813959e7d9650334015b927cc533f5beadfbf7fa48248beec471f8942a0ee71", + "win-x64": "f56c03122e17abe5be1429eee0a975fb8ed511b6731ec90c6475992d3dee4ea5", + "win-arm64": "fada411c6538d0ac24c35d6360767241f1298f64cbc5e88716387d54757a105a", + "osx-x64": "a2765917b6ae49d1ac119553df9584989f9c441a46e8f18c129ba52489658d2e", + "osx-arm64": "f57c25163092bac818f8ca6250a0d8b2c56344c6a075a1bd7c60da7ac28b32a4", +} +DEFAULT_DART_SDK_VERSION = "3.7.1" +DEFAULT_DART_SDK_SHA256_BY_PLATFORM = { + "linux-x64": "2813959e7d9650334015b927cc533f5beadfbf7fa48248beec471f8942a0ee71", + "win-x64": "f56c03122e17abe5be1429eee0a975fb8ed511b6731ec90c6475992d3dee4ea5", + "win-arm64": "fada411c6538d0ac24c35d6360767241f1298f64cbc5e88716387d54757a105a", + "osx-x64": "a2765917b6ae49d1ac119553df9584989f9c441a46e8f18c129ba52489658d2e", + "osx-arm64": "f57c25163092bac818f8ca6250a0d8b2c56344c6a075a1bd7c60da7ac28b32a4", +} + + +def _dart_sdk_sha(version: str, platform_key: str) -> str | None: + if version == INITIAL_DART_SDK_VERSION: + return INITIAL_DART_SDK_SHA256_BY_PLATFORM[platform_key] + if version == DEFAULT_DART_SDK_VERSION: + return DEFAULT_DART_SDK_SHA256_BY_PLATFORM[platform_key] + return None + class DartLanguageServer(SolidLanguageServer): """ @@ -49,7 +77,7 @@ class DartLanguageServer(SolidLanguageServer): @classmethod def _setup_runtime_dependencies(cls, solidlsp_settings: SolidLSPSettings) -> str: dart_settings = solidlsp_settings.get_ls_specific_settings(Language.DART) - dart_sdk_version = dart_settings.get("dart_sdk_version", "3.7.1") + dart_sdk_version = dart_settings.get("dart_sdk_version", DEFAULT_DART_SDK_VERSION) deps = RuntimeDependencyCollection( [ RuntimeDependency( @@ -59,7 +87,7 @@ class DartLanguageServer(SolidLanguageServer): platform_id="linux-x64", archive_type="zip", binary_name="dart-sdk/bin/dart", - sha256="2813959e7d9650334015b927cc533f5beadfbf7fa48248beec471f8942a0ee71" if dart_sdk_version == "3.7.1" else None, + sha256=_dart_sdk_sha(dart_sdk_version, "linux-x64"), allowed_hosts=DART_ALLOWED_HOSTS, ), RuntimeDependency( @@ -69,7 +97,7 @@ class DartLanguageServer(SolidLanguageServer): platform_id="win-x64", archive_type="zip", binary_name="dart-sdk/bin/dart.exe", - sha256="f56c03122e17abe5be1429eee0a975fb8ed511b6731ec90c6475992d3dee4ea5" if dart_sdk_version == "3.7.1" else None, + sha256=_dart_sdk_sha(dart_sdk_version, "win-x64"), allowed_hosts=DART_ALLOWED_HOSTS, ), RuntimeDependency( @@ -79,7 +107,7 @@ class DartLanguageServer(SolidLanguageServer): platform_id="win-arm64", archive_type="zip", binary_name="dart-sdk/bin/dart.exe", - sha256="fada411c6538d0ac24c35d6360767241f1298f64cbc5e88716387d54757a105a" if dart_sdk_version == "3.7.1" else None, + sha256=_dart_sdk_sha(dart_sdk_version, "win-arm64"), allowed_hosts=DART_ALLOWED_HOSTS, ), RuntimeDependency( @@ -89,7 +117,7 @@ class DartLanguageServer(SolidLanguageServer): platform_id="osx-x64", archive_type="zip", binary_name="dart-sdk/bin/dart", - sha256="a2765917b6ae49d1ac119553df9584989f9c441a46e8f18c129ba52489658d2e" if dart_sdk_version == "3.7.1" else None, + sha256=_dart_sdk_sha(dart_sdk_version, "osx-x64"), allowed_hosts=DART_ALLOWED_HOSTS, ), RuntimeDependency( @@ -99,13 +127,18 @@ class DartLanguageServer(SolidLanguageServer): platform_id="osx-arm64", archive_type="zip", binary_name="dart-sdk/bin/dart", - sha256="f57c25163092bac818f8ca6250a0d8b2c56344c6a075a1bd7c60da7ac28b32a4" if dart_sdk_version == "3.7.1" else None, + sha256=_dart_sdk_sha(dart_sdk_version, "osx-arm64"), allowed_hosts=DART_ALLOWED_HOSTS, ), ] ) - dart_ls_dir = cls.ls_resources_dir(solidlsp_settings) + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + dart_ls_dir = ( + cls.ls_resources_dir(solidlsp_settings) + if dart_sdk_version == INITIAL_DART_SDK_VERSION + else os.path.join(cls.ls_resources_dir(solidlsp_settings), f"dart-sdk-{dart_sdk_version}") + ) dart_executable_path = deps.binary_path(dart_ls_dir) if not os.path.exists(dart_executable_path): diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py index d90afd6d..646174a5 100644 --- a/src/solidlsp/language_servers/eclipse_jdtls.py +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -7,9 +7,12 @@ import hashlib import logging import os import pathlib +import platform +import re import shutil +import subprocess import threading -from pathlib import PurePath +from pathlib import Path, PurePath from time import sleep from typing import cast @@ -18,6 +21,7 @@ from overrides import override from solidlsp import ls_types from solidlsp.ls import LanguageServerDependencyProvider, LSPFileBuffer, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_types import UnifiedSymbolInformation from solidlsp.ls_utils import FileUtils, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, InitializeParams, SymbolInformation @@ -28,42 +32,121 @@ log = logging.getLogger(__name__) GRADLE_ALLOWED_HOSTS = ("services.gradle.org", "github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") GRADLE_SHA256 = "7197a12f450794931532469d4ff21a59ea2c1cd59a3ec3f89c035c3c420a6999" VSCODE_JAVA_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") -VSCODE_JAVA_SHA256_BY_PLATFORM = { +INTELLICODE_ALLOWED_HOSTS = ( + "visualstudioexptteam.gallery.vsassets.io", + "marketplace.visualstudio.com", + "download.visualstudio.microsoft.com", +) + +# Version pinning convention (read this before bumping anything below): +# +# INITIAL_* — the very first version we shipped runtime-dependency support for, paired with +# its SHA. NEVER edited. The legacy unversioned install directory (e.g. "vscode-java/") +# is reserved exclusively for this version, so users who installed under the original +# layout keep their existing cache forever. +# +# DEFAULT_* — the version (and matching SHA) used when the user does not override via +# custom_settings. THIS is what we edit to upgrade: copy the new version + new SHA over +# the existing DEFAULT_* literals, leave INITIAL_* alone. +# +# Resolution at install time: +# resolved_version = custom_settings.get("..._version", DEFAULT_*_VERSION) +# - resolved == INITIAL_* -> legacy unversioned dir, SHA = INITIAL_*_SHA256 +# - resolved == DEFAULT_* -> versioned dir "{name}-{resolved}", SHA = DEFAULT_*_SHA256 +# - any other version -> versioned dir "{name}-{resolved}", SHA verification skipped +# +# Consequences of this scheme: +# - Bumping DEFAULT_* makes new installs land in a new versioned subdir => no silent reuse +# of stale binaries (the bug this scheme exists to fix). +# - Previously-downloaded versions are not lost: a user can request any past version via +# custom_settings and the matching cached subdir is picked up without re-downloading. +# - INITIAL_* and DEFAULT_* hold identical literals when first introduced; they diverge on +# the first DEFAULT_* bump and stay independent thereafter. +INITIAL_VSCODE_JAVA_VERSION = "1.42.0-561" +INITIAL_VSCODE_JAVA_SHA256_BY_PLATFORM = { "osx-arm64": "bc00c2699d4b8d478eb9a1621db9d6d3a12ea0dcc247a9cd8040e8ac19c03933", "osx-x64": "03ae1db1a22c15561a620f1b722d6797d35d4faaa7c4666dbe6ca2715089852f", "linux-arm64": "e15bc9b2a665d3453203402621b5441062aa41b0ec2d140661f439326fd248c1", "linux-x64": "7660b7b527be6fda46a917966b34d828e7416d5cc84287b29b88e7b99c1737f9", "win-x64": "ef195b45bd260976ad2e84618f4044b5d7248deed41d647573f0ee22c4233df3", } -INTELLICODE_ALLOWED_HOSTS = ( - "visualstudioexptteam.gallery.vsassets.io", - "marketplace.visualstudio.com", - "download.visualstudio.microsoft.com", -) -INTELLICODE_SHA256 = "7f61a7f96d101cdf230f96821be3fddd8f890ebfefb3695d18beee43004ae251" +DEFAULT_VSCODE_JAVA_VERSION = "1.42.0-561" +DEFAULT_VSCODE_JAVA_SHA256_BY_PLATFORM = { + "osx-arm64": "bc00c2699d4b8d478eb9a1621db9d6d3a12ea0dcc247a9cd8040e8ac19c03933", + "osx-x64": "03ae1db1a22c15561a620f1b722d6797d35d4faaa7c4666dbe6ca2715089852f", + "linux-arm64": "e15bc9b2a665d3453203402621b5441062aa41b0ec2d140661f439326fd248c1", + "linux-x64": "7660b7b527be6fda46a917966b34d828e7416d5cc84287b29b88e7b99c1737f9", + "win-x64": "ef195b45bd260976ad2e84618f4044b5d7248deed41d647573f0ee22c4233df3", +} + +INITIAL_INTELLICODE_VERSION = "1.2.30" +INITIAL_INTELLICODE_SHA256 = "7f61a7f96d101cdf230f96821be3fddd8f890ebfefb3695d18beee43004ae251" +DEFAULT_INTELLICODE_VERSION = "1.2.30" +DEFAULT_INTELLICODE_SHA256 = "7f61a7f96d101cdf230f96821be3fddd8f890ebfefb3695d18beee43004ae251" + +# Mapping from Serena's platform identifiers to upstream JDTLS config_ directory names +JDTLS_CONFIG_DIR_BY_PLATFORM = { + "osx-arm64": "config_mac_arm", + "darwin-arm64": "config_mac_arm", + "osx-x64": "config_mac", + "linux-arm64": "config_linux_arm", + "linux-x64": "config_linux", + "win-x64": "config_win", +} + +# Minimum supported JDK version for running JDTLS itself +JDTLS_MIN_JDK_VERSION = 21 @dataclasses.dataclass class RuntimeDependencyPaths: """ - Stores the paths to the runtime dependencies of EclipseJDTLS + Stores the paths to the runtime dependencies of EclipseJDTLS. + + In the default mode (vscode-java VSIX), all paths are populated. + In the upstream-jdtls mode (when ``jdtls_path`` and ``lombok_path`` are set), + fields that have no upstream equivalent (gradle distribution, IntelliCode bundle) + are set to None. """ - gradle_path: str - lombok_jar_path: str jre_path: str jre_home_path: str jdtls_launcher_jar_path: str jdtls_readonly_config_path: str - intellicode_jar_path: str - intellisense_members_path: str + lombok_jar_path: str + gradle_path: str | None = None + intellicode_jar_path: str | None = None + intellisense_members_path: str | None = None class EclipseJDTLS(SolidLanguageServer): r""" The EclipseJDTLS class provides a Java specific implementation of the LanguageServer class + Two installation modes are supported: + + 1. **Default vscode-java VSIX mode** (no extra config required) — Serena downloads the platform-specific + vscode-java VSIX bundle (~500 MB: JDTLS + bundled JRE 21 + Lombok + IntelliCode), Gradle distribution + and IntelliCode VSIX from public hosts. Suitable when public network access is available. + + 2. **Upstream JDTLS mode** (activated by setting both ``jdtls_path`` and ``lombok_path``) — uses an + existing JDTLS installation and the system JDK. Nothing is downloaded. Suitable for restricted-network + environments. Requires JDK 21+ available via ``java_home`` setting / ``JAVA_HOME`` env / PATH. + Maven projects work out of the box (m2e bundled in JDTLS uses Maven Embedder); Gradle projects + need ``./gradlew`` in the project or a system-installed Gradle (Buildship default discovery). + You can configure the following options in ls_specific_settings (in serena_config.yml): + Upstream JDTLS mode (mutually exclusive group — set both to activate): + - jdtls_path: Path to upstream JDTLS root (containing plugins/ and config_/). + Get via 'brew install jdtls' or extract jdt-language-server-*.tar.gz from + https://download.eclipse.org/jdtls/snapshots/. + - lombok_path: Path to lombok jar (e.g. ~/.m2/repository/org/projectlombok/lombok//lombok-.jar + or download from https://projectlombok.org/downloads/). + + Optional in upstream-jdtls mode: + - java_home: Path to JDK 21+ home directory. Falls back to JAVA_HOME env, then 'which java'. + + General settings (apply in both modes): - maven_user_settings: Path to Maven settings.xml file (default: ~/.m2/settings.xml) - gradle_user_home: Path to Gradle user home directory (default: ~/.gradle) - gradle_wrapper_enabled: Whether to use the project's Gradle wrapper (default: false) @@ -77,7 +160,16 @@ class EclipseJDTLS(SolidLanguageServer): - vscode_java_version: Override the pinned vscode-java runtime bundle version downloaded by Serena - intellicode_version: Override the pinned IntelliCode VSIX version downloaded by Serena - Example configuration in ~/.serena/serena_config.yml: + Example configuration for upstream JDTLS mode (no downloads, suitable for offline/corporate): + ```yaml + ls_specific_settings: + java: + jdtls_path: "/opt/homebrew/Cellar/jdtls/1.50.0/libexec" # or extracted tar.gz path + lombok_path: "/Users/me/.m2/repository/org/projectlombok/lombok/1.18.36/lombok-1.18.36.jar" + # java_home: "/opt/homebrew/opt/openjdk@21" # optional, JAVA_HOME env / PATH used otherwise + ``` + + Example configuration for default vscode-java VSIX mode (auto-download): ```yaml ls_specific_settings: java: @@ -98,6 +190,10 @@ class EclipseJDTLS(SolidLanguageServer): ``` """ + @classmethod + def supports_implementation_request(cls) -> bool: + return True + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a new EclipseJDTLS instance initializing the language server settings appropriately. @@ -154,15 +250,60 @@ class EclipseJDTLS(SolidLanguageServer): ) -> RuntimeDependencyPaths: """ Setup runtime dependencies for EclipseJDTLS and return the paths. + + Two modes are supported: + + * **Upstream JDTLS mode** (activated when both ``jdtls_path`` and ``lombok_path`` are set + in ``ls_specific_settings.java``): uses an existing JDTLS installation (e.g. via + ``brew install jdtls`` or extracted ``jdt-language-server-*.tar.gz``) and the system JDK. + Nothing is downloaded by Serena. Suitable for restricted-network/corporate environments. + * **Default vscode-java VSIX mode** (activated otherwise): downloads the platform-specific + vscode-java VSIX bundle (containing JDTLS, bundled JRE 21, Lombok, IntelliCode), Gradle + distribution and IntelliCode VSIX from public hosts. Original behaviour, unchanged. """ + jdtls_path = custom_settings.get("jdtls_path") + lombok_path = custom_settings.get("lombok_path") + if jdtls_path or lombok_path: + # both must be set together to activate upstream mode + if not (jdtls_path and lombok_path): + raise SolidLSPException( + "Both 'jdtls_path' and 'lombok_path' must be set together in " + "ls_specific_settings.java to use the upstream JDTLS mode. " + "Set both, or remove both to use the default vscode-java VSIX mode." + ) + return EclipseJDTLS.DependencyProvider._setup_from_existing_install(str(jdtls_path), str(lombok_path), custom_settings) + platformId = PlatformUtils.get_platform_id() gradle_version = custom_settings.get("gradle_version", "8.14.2") - vscode_java_version = custom_settings.get("vscode_java_version", "1.42.0-561") + vscode_java_version = custom_settings.get("vscode_java_version", DEFAULT_VSCODE_JAVA_VERSION) vscode_java_tag = f"v{vscode_java_version.rsplit('-', 1)[0]}" - intellicode_version = custom_settings.get("intellicode_version", "1.2.30") + intellicode_version = custom_settings.get("intellicode_version", DEFAULT_INTELLICODE_VERSION) default_gradle_version = gradle_version == "8.14.2" - default_vscode_java_version = vscode_java_version == "1.42.0-561" - default_intellicode_version = intellicode_version == "1.2.30" + + # install-dir name per the version-pinning convention (see module-level block): + # INITIAL -> legacy unversioned dir; everything else -> "{name}-{resolved}" subdir + vscode_java_dirname = ( + "vscode-java" if vscode_java_version == INITIAL_VSCODE_JAVA_VERSION else f"vscode-java-{vscode_java_version}" + ) + intellicode_dirname = ( + "intellicode" if intellicode_version == INITIAL_INTELLICODE_VERSION else f"intellicode-{intellicode_version}" + ) + + # SHA is only known for our two pinned versions (INITIAL and current DEFAULT); + # for any other user-supplied version we skip verification (returns None) + def vscode_java_sha(platform_key: str) -> str | None: + if vscode_java_version == INITIAL_VSCODE_JAVA_VERSION: + return INITIAL_VSCODE_JAVA_SHA256_BY_PLATFORM[platform_key] + if vscode_java_version == DEFAULT_VSCODE_JAVA_VERSION: + return DEFAULT_VSCODE_JAVA_SHA256_BY_PLATFORM[platform_key] + return None + + def intellicode_sha() -> str | None: + if intellicode_version == INITIAL_INTELLICODE_VERSION: + return INITIAL_INTELLICODE_SHA256 + if intellicode_version == DEFAULT_INTELLICODE_VERSION: + return DEFAULT_INTELLICODE_SHA256 + return None runtime_dependencies: dict[str, dict[str, dict[str, object]]] = { "gradle": { @@ -178,15 +319,15 @@ class EclipseJDTLS(SolidLanguageServer): "darwin-arm64": { "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-darwin-arm64-{vscode_java_version}.vsix", "archiveType": "zip", - "relative_extraction_path": "vscode-java", - "sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["osx-arm64"] if default_vscode_java_version else None, + "relative_extraction_path": vscode_java_dirname, + "sha256": vscode_java_sha("osx-arm64"), "allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS, }, "osx-arm64": { "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-darwin-arm64-{vscode_java_version}.vsix", "archiveType": "zip", - "relative_extraction_path": "vscode-java", - "sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["osx-arm64"] if default_vscode_java_version else None, + "relative_extraction_path": vscode_java_dirname, + "sha256": vscode_java_sha("osx-arm64"), "allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS, "jre_home_path": "extension/jre/21.0.7-macosx-aarch64", "jre_path": "extension/jre/21.0.7-macosx-aarch64/bin/java", @@ -197,8 +338,8 @@ class EclipseJDTLS(SolidLanguageServer): "osx-x64": { "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-darwin-x64-{vscode_java_version}.vsix", "archiveType": "zip", - "relative_extraction_path": "vscode-java", - "sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["osx-x64"] if default_vscode_java_version else None, + "relative_extraction_path": vscode_java_dirname, + "sha256": vscode_java_sha("osx-x64"), "allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS, "jre_home_path": "extension/jre/21.0.7-macosx-x86_64", "jre_path": "extension/jre/21.0.7-macosx-x86_64/bin/java", @@ -209,8 +350,8 @@ class EclipseJDTLS(SolidLanguageServer): "linux-arm64": { "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-linux-arm64-{vscode_java_version}.vsix", "archiveType": "zip", - "relative_extraction_path": "vscode-java", - "sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["linux-arm64"] if default_vscode_java_version else None, + "relative_extraction_path": vscode_java_dirname, + "sha256": vscode_java_sha("linux-arm64"), "allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS, "jre_home_path": "extension/jre/21.0.7-linux-aarch64", "jre_path": "extension/jre/21.0.7-linux-aarch64/bin/java", @@ -221,8 +362,8 @@ class EclipseJDTLS(SolidLanguageServer): "linux-x64": { "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-linux-x64-{vscode_java_version}.vsix", "archiveType": "zip", - "relative_extraction_path": "vscode-java", - "sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["linux-x64"] if default_vscode_java_version else None, + "relative_extraction_path": vscode_java_dirname, + "sha256": vscode_java_sha("linux-x64"), "allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS, "jre_home_path": "extension/jre/21.0.7-linux-x86_64", "jre_path": "extension/jre/21.0.7-linux-x86_64/bin/java", @@ -233,8 +374,8 @@ class EclipseJDTLS(SolidLanguageServer): "win-x64": { "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-win32-x64-{vscode_java_version}.vsix", "archiveType": "zip", - "relative_extraction_path": "vscode-java", - "sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["win-x64"] if default_vscode_java_version else None, + "relative_extraction_path": vscode_java_dirname, + "sha256": vscode_java_sha("win-x64"), "allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS, "jre_home_path": "extension/jre/21.0.7-win32-x86_64", "jre_path": "extension/jre/21.0.7-win32-x86_64/bin/java.exe", @@ -248,8 +389,8 @@ class EclipseJDTLS(SolidLanguageServer): "url": f"https://VisualStudioExptTeam.gallery.vsassets.io/_apis/public/gallery/publisher/VisualStudioExptTeam/extension/vscodeintellicode/{intellicode_version}/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage", "alternate_url": f"https://marketplace.visualstudio.com/_apis/public/gallery/publishers/VisualStudioExptTeam/vsextensions/vscodeintellicode/{intellicode_version}/vspackage", "archiveType": "zip", - "relative_extraction_path": "intellicode", - "sha256": INTELLICODE_SHA256 if default_intellicode_version else None, + "relative_extraction_path": intellicode_dirname, + "sha256": intellicode_sha(), "allowed_hosts": INTELLICODE_ALLOWED_HOSTS, "intellicode_jar_path": "extension/dist/com.microsoft.jdtls.intellicode.core-0.7.0.jar", "intellisense_members_path": "extension/dist/bundledModels/java_intellisense-members", @@ -346,11 +487,255 @@ class EclipseJDTLS(SolidLanguageServer): intellisense_members_path=intellisense_members_path, ) + @staticmethod + def _setup_from_existing_install( + jdtls_path: str, lombok_path: str, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> RuntimeDependencyPaths: + """ + Builds RuntimeDependencyPaths from an already-installed upstream JDTLS distribution + and the system-installed JDK. No downloads are performed. + + :param jdtls_path: absolute path to the JDTLS root (containing ``plugins/`` and ``config_/``) + :param lombok_path: absolute path to the Lombok jar (mandatory; agent is always attached) + :param custom_settings: language-server-specific settings from ls_specific_settings.java + :return: populated RuntimeDependencyPaths with gradle/intellicode fields set to None + """ + # validate jdtls_path structure (root + plugins dir) + jdtls_root = Path(jdtls_path) + if not jdtls_root.is_dir(): + raise SolidLSPException( + f"Provided jdtls_path '{jdtls_path}' is not an existing directory.\n" + f"Fix: extract jdt-language-server-*.tar.gz from " + f"https://download.eclipse.org/jdtls/snapshots/ or run 'brew install jdtls', " + f"then set ls_specific_settings.java.jdtls_path to the extracted directory." + ) + plugins_dir = jdtls_root / "plugins" + if not plugins_dir.is_dir(): + raise SolidLSPException( + f"Invalid jdtls_path '{jdtls_path}': 'plugins/' directory not found.\n" + f"Expected upstream JDTLS layout (plugins/, config_/, features/) at the root. " + f"If you pointed at a vscode-java extension, use '/server' instead." + ) + + # resolve the main equinox launcher jar (excluding platform-specific native fragments) + launcher_jar = EclipseJDTLS.DependencyProvider._resolve_launcher_jar(plugins_dir) + + # resolve the platform-specific config directory under jdtls_root + config_dir = EclipseJDTLS.DependencyProvider._resolve_config_dir(jdtls_root) + + # validate lombok jar exists + if not Path(lombok_path).is_file(): + raise SolidLSPException( + f"Provided lombok_path '{lombok_path}' does not exist or is not a file.\n" + f"Fix: download lombok jar from https://projectlombok.org/downloads/ or use the one " + f"from your local Maven cache (~/.m2/repository/org/projectlombok/lombok//lombok-.jar)." + ) + + # resolve system JDK (priority: java_home setting -> JAVA_HOME env -> which java), + # interrogate the JVM for its real java.home and validate version >= 21 + jre_home_path, jre_path = EclipseJDTLS.DependencyProvider._resolve_system_jdk(custom_settings) + + log.info( + f"Using upstream JDTLS at '{jdtls_path}' with system JDK '{jre_home_path}'. " + f"Launcher: {launcher_jar.name}; config: {config_dir.name}; lombok: {lombok_path}" + ) + + return RuntimeDependencyPaths( + jre_path=jre_path, + jre_home_path=jre_home_path, + jdtls_launcher_jar_path=str(launcher_jar), + jdtls_readonly_config_path=str(config_dir), + lombok_jar_path=lombok_path, + gradle_path=None, + intellicode_jar_path=None, + intellisense_members_path=None, + ) + + @staticmethod + def _resolve_launcher_jar(plugins_dir: Path) -> Path: + """ + Locates the main Equinox launcher jar in JDTLS's ``plugins/`` directory, excluding + platform-specific native fragments like ``org.eclipse.equinox.launcher.cocoa.macosx.*``. + + :return: path to the main launcher jar (e.g. ``org.eclipse.equinox.launcher_1.7.0.v....jar``) + """ + # main launcher matches: org.eclipse.equinox.launcher_...jar (single underscore, + # immediately followed by version digits — fragments have additional dotted segments). + pattern = re.compile(r"^org\.eclipse\.equinox\.launcher_\d.*\.jar$") + matches = sorted(p for p in plugins_dir.glob("org.eclipse.equinox.launcher_*.jar") if pattern.match(p.name)) + if not matches: + raise SolidLSPException( + f"No main Equinox launcher jar found in '{plugins_dir}'. " + f"Expected file like 'org.eclipse.equinox.launcher_.jar'. " + f"Verify the JDTLS extraction is complete and not corrupted." + ) + # if multiple versions are present (rare), pick the highest by name + return matches[-1] + + @staticmethod + def _resolve_config_dir(jdtls_root: Path) -> Path: + """ + Locates the platform-specific OSGi configuration directory inside the JDTLS root. + + :return: path to ``config_/`` directory matching the current OS/arch + """ + platform_id = PlatformUtils.get_platform_id().value + config_dir_name = JDTLS_CONFIG_DIR_BY_PLATFORM.get(platform_id) + if config_dir_name is None: + raise SolidLSPException( + f"Unsupported platform '{platform_id}' for upstream JDTLS mode. " + f"Supported platforms: {sorted(set(JDTLS_CONFIG_DIR_BY_PLATFORM.values()))}." + ) + config_dir = jdtls_root / config_dir_name + if not config_dir.is_dir(): + raise SolidLSPException( + f"Config directory '{config_dir}' not found. " + f"This JDTLS distribution does not support platform '{platform_id}'. " + f"Verify you downloaded the correct tar.gz for your OS/architecture." + ) + return config_dir + + @staticmethod + def _resolve_system_jdk(custom_settings: SolidLSPSettings.CustomLSSettings) -> tuple[str, str]: + """ + Resolves the system-installed JDK home and ``java`` executable, validates the version. + + The ``java`` executable is located by priority: ``java_home`` setting -> + ``JAVA_HOME`` env var -> ``java`` in PATH. The actual JDK home directory and + major version are then discovered by querying the JVM itself via + ``java -XshowSettings:properties -version`` — this is the single source of truth + and works correctly even when the locator is a system stub (e.g. ``/usr/bin/java`` + on macOS, which delegates to ``/usr/libexec/java_home`` under the hood and does not + resolve to the real JDK home via simple path traversal). + + :return: (jdk_home_directory, java_executable_path) + """ + # locate a java executable to interrogate + java_exe_name = "java.exe" if platform.system() == "Windows" else "java" + java_exe: str | None = None + source: str + + if explicit_home := custom_settings.get("java_home"): + candidate = str(Path(explicit_home) / "bin" / java_exe_name) + if not os.path.exists(candidate): + raise SolidLSPException( + f"java_home='{explicit_home}' is invalid: '{candidate}' does not exist. " + f"Set ls_specific_settings.java.java_home to a JDK home that contains bin/{java_exe_name}." + ) + java_exe = candidate + source = f"java_home setting ({explicit_home})" + elif env_home := os.environ.get("JAVA_HOME"): + candidate = str(Path(env_home) / "bin" / java_exe_name) + if os.path.exists(candidate): + java_exe = candidate + source = f"JAVA_HOME env ({env_home})" + else: + log.warning(f"JAVA_HOME='{env_home}' invalid (no '{candidate}'), falling back to PATH.") + + if java_exe is None: + java_in_path = shutil.which("java") + if java_in_path is None: + raise SolidLSPException( + "Could not locate a Java installation for JDTLS. " + "Set ls_specific_settings.java.java_home, set JAVA_HOME environment variable, " + f"or ensure 'java' is on PATH. Required: JDK {JDTLS_MIN_JDK_VERSION}+." + ) + java_exe = java_in_path + source = f"PATH ({java_in_path})" + + # interrogate the JVM for its real java.home and version (single source of truth) + real_jdk_home, major_version = EclipseJDTLS.DependencyProvider._inspect_java(java_exe) + + # validate version + if major_version < JDTLS_MIN_JDK_VERSION: + raise SolidLSPException( + f"JDTLS requires JDK {JDTLS_MIN_JDK_VERSION}+ but '{java_exe}' is JDK {major_version} " + f"(located via {source}, java.home={real_jdk_home}). " + f"Install a newer JDK and update ls_specific_settings.java.java_home or JAVA_HOME." + ) + log.info(f"Resolved JDK {major_version} via {source}; java.home={real_jdk_home}; java_exe={java_exe}.") + + # prefer to use bin/java from the *real* JDK home (so JDTLS subprocesses that read JAVA_HOME + # find a consistent layout); only fall back to the original locator if the real-home variant + # is missing for some reason. + real_java = str(Path(real_jdk_home) / "bin" / java_exe_name) + if os.path.exists(real_java): + java_exe = real_java + + return real_jdk_home, java_exe + + @staticmethod + def _inspect_java(java_exe: str) -> tuple[str, int]: + """ + Runs ``java -XshowSettings:properties -version`` and parses ``java.home`` and the + major version from the output. This is the most reliable cross-platform way to + discover the JDK home (works around macOS ``/usr/bin/java`` stub issue). + + :return: (java_home_directory_reported_by_jvm, major_version) + """ + try: + # both -XshowSettings:properties and -version write to stderr by convention + result = subprocess.run( + [java_exe, "-XshowSettings:properties", "-version"], + capture_output=True, + text=True, + timeout=15, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise SolidLSPException(f"Failed to run '{java_exe} -XshowSettings:properties -version': {exc}") from exc + + output = (result.stderr or "") + "\n" + (result.stdout or "") + + # parse java.home from " java.home = /path/to/jdk" + home_match = re.search(r"java\.home\s*=\s*(.+)", output) + if not home_match: + raise SolidLSPException( + f"Could not parse java.home from '{java_exe}' output. " + f"This usually means '{java_exe}' is not a valid JDK installation. " + f"First lines of output: {output.strip().splitlines()[:5]}" + ) + real_home = home_match.group(1).strip() + + # parse major version from version line: 'openjdk version "21.0.2"' or 'java version "21.0.2"' + version_match = re.search(r'version "(\d+)(?:\.\d+)*"', output) + if not version_match: + raise SolidLSPException( + f"Could not parse Java version from '{java_exe}' output. " + f"Required: JDK {JDTLS_MIN_JDK_VERSION}+. " + f"First lines of output: {output.strip().splitlines()[:5]}" + ) + major = int(version_match.group(1)) + return real_home, major + + @staticmethod + def _compute_workspace_hash( + repository_root_path: str, + jdtls_launcher_jar_path: str, + custom_settings: SolidLSPSettings.CustomLSSettings, + ) -> str: + """ + Compute the JDTLS workspace directory name. + + Default mode hashes the project path only — preserves backwards compatibility with + workspaces created before upstream-JDTLS support. Upstream mode (when ``jdtls_path`` + is set) mixes in the launcher path so switching between default and upstream + installations (or between different upstream JDTLS versions) lands in a separate + ws_dir and avoids stale OSGi configs blocking startup. + """ + if custom_settings.get("jdtls_path"): + ws_hash_input = (repository_root_path + "|" + jdtls_launcher_jar_path).encode() + else: + ws_hash_input = repository_root_path.encode() + return hashlib.md5(ws_hash_input).hexdigest() + def create_launch_command(self) -> list[str]: # ws_dir is the workspace directory for the EclipseJDTLS server. - # Use a deterministic hash of the project path so the workspace - # (and its cached index) can be reused across restarts. - project_hash = hashlib.md5(self._repository_root_path.encode()).hexdigest() + project_hash = EclipseJDTLS.DependencyProvider._compute_workspace_hash( + self._repository_root_path, + self.runtime_dependency_paths.jdtls_launcher_jar_path, + self._custom_settings, + ) ws_dir = str( PurePath( self._solidlsp_settings.ls_resources_dir, @@ -820,8 +1205,15 @@ class EclipseJDTLS(SolidLanguageServer): } initialize_params["initializationOptions"]["workspaceFolders"] = [repo_uri] # type: ignore - bundles = [self.runtime_dependency_paths.intellicode_jar_path] - initialize_params["initializationOptions"]["bundles"] = bundles # type: ignore + + # IntelliCode bundle: only attached in default vscode-java VSIX mode. + # In upstream-jdtls mode (jdtls_path set) we don't ship IntelliCode — agentic Serena workflows + # don't use completion ranking, so the bundle would be inert dead weight. + if self.runtime_dependency_paths.intellicode_jar_path is not None: + initialize_params["initializationOptions"]["bundles"] = [self.runtime_dependency_paths.intellicode_jar_path] # type: ignore + else: + initialize_params["initializationOptions"]["bundles"] = [] # type: ignore + initialize_params["initializationOptions"]["settings"]["java"]["configuration"]["runtimes"] = [ # type: ignore {"name": "JavaSE-21", "path": self.runtime_dependency_paths.jre_home_path, "default": True} ] @@ -832,7 +1224,10 @@ class EclipseJDTLS(SolidLanguageServer): assert os.path.exists(runtime["path"]), f"Runtime required for eclipse_jdtls at path {runtime['path']} does not exist" gradle_settings = initialize_params["initializationOptions"]["settings"]["java"]["import"]["gradle"] # type: ignore - gradle_settings["home"] = self.runtime_dependency_paths.gradle_path + # In upstream-jdtls mode we don't ship a Gradle distribution — Buildship will use the project's + # ./gradlew wrapper or a system-installed Gradle via its standard discovery rules. + if self.runtime_dependency_paths.gradle_path is not None: + gradle_settings["home"] = self.runtime_dependency_paths.gradle_path gradle_settings["java"] = {"home": gradle_java_home if gradle_java_home is not None else self.runtime_dependency_paths.jre_path} return cast(InitializeParams, initialize_params) @@ -899,17 +1294,22 @@ class EclipseJDTLS(SolidLanguageServer): self.server.notify.workspace_did_change_configuration({"settings": initialize_params["initializationOptions"]["settings"]}) # type: ignore - self._intellicode_enable_command_available.wait() + # IntelliCode enablement is only relevant in the default vscode-java VSIX mode where the + # IntelliCode bundle is shipped. In upstream-jdtls mode it's absent and the + # 'java.intellicode.enable' command will never be registered, so we skip the wait/call. + if self.runtime_dependency_paths.intellicode_jar_path is not None: + self._intellicode_enable_command_available.wait() - java_intellisense_members_path = self.runtime_dependency_paths.intellisense_members_path - assert os.path.exists(java_intellisense_members_path) - intellicode_enable_result = self.server.send.execute_command( - { - "command": "java.intellicode.enable", - "arguments": [True, java_intellisense_members_path], - } - ) - assert intellicode_enable_result + java_intellisense_members_path = self.runtime_dependency_paths.intellisense_members_path + assert java_intellisense_members_path is not None + assert os.path.exists(java_intellisense_members_path) + intellicode_enable_result = self.server.send.execute_command( + { + "command": "java.intellicode.enable", + "arguments": [True, java_intellisense_members_path], + } + ) + assert intellicode_enable_result if not self._service_ready_event.is_set(): log.info("Waiting for service to be ready ...") diff --git a/src/solidlsp/language_servers/elm_language_server.py b/src/solidlsp/language_servers/elm_language_server.py index e1960c32..e5823811 100644 --- a/src/solidlsp/language_servers/elm_language_server.py +++ b/src/solidlsp/language_servers/elm_language_server.py @@ -11,6 +11,7 @@ import threading 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 Language, LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams @@ -21,6 +22,14 @@ from .common import RuntimeDependency, RuntimeDependencyCollection, build_npm_in log = logging.getLogger(__name__) +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_ELM_LANGUAGE_SERVER_VERSION = "2.8.0" +DEFAULT_ELM_LANGUAGE_SERVER_VERSION = "2.8.0" +INITIAL_ELM_COMPILER_VERSION = "0.19.1-6" +DEFAULT_ELM_COMPILER_VERSION = "0.19.1-6" + class ElmLanguageServer(SolidLanguageServer): """ @@ -32,9 +41,10 @@ class ElmLanguageServer(SolidLanguageServer): 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(config, solidlsp_settings) + managed_bin_dir = os.path.join(self.ls_resources_dir(solidlsp_settings), "elm-lsp", "node_modules", ".bin") # Resolve ELM_HOME to absolute path if it's set to a relative path - env = {} + env = {"PATH": managed_bin_dir + os.pathsep + os.environ.get("PATH", "")} elm_home = os.environ.get("ELM_HOME") if elm_home: if not os.path.isabs(elm_home): @@ -65,9 +75,15 @@ class ElmLanguageServer(SolidLanguageServer): """ Setup runtime dependencies for Elm Language Server and return the command to start the server. """ - # Check if elm-language-server is already installed globally + elm_config = solidlsp_settings.get_ls_specific_settings(Language.ELM) + elm_language_server_version = elm_config.get("elm_language_server_version", DEFAULT_ELM_LANGUAGE_SERVER_VERSION) + elm_compiler_version = elm_config.get("elm_compiler_version", DEFAULT_ELM_COMPILER_VERSION) + npm_registry = elm_config.get("npm_registry") + + # Check if elm-language-server and elm are already installed globally system_elm_ls = shutil.which("elm-language-server") - if system_elm_ls: + system_elm = shutil.which("elm") + if system_elm_ls and system_elm: log.info(f"Found system-installed elm-language-server at {system_elm_ls}") return [system_elm_ls, "--stdio"] @@ -76,10 +92,6 @@ class ElmLanguageServer(SolidLanguageServer): 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." - elm_config = solidlsp_settings.get_ls_specific_settings(Language.ELM) - elm_language_server_version = elm_config.get("elm_language_server_version", "2.8.0") - npm_registry = elm_config.get("npm_registry") - deps = RuntimeDependencyCollection( [ RuntimeDependency( @@ -88,16 +100,30 @@ class ElmLanguageServer(SolidLanguageServer): command=build_npm_install_command("@elm-tooling/elm-language-server", elm_language_server_version, npm_registry), platform_id="any", ), + RuntimeDependency( + id="elm", + description="Elm compiler package", + command=build_npm_install_command("elm", elm_compiler_version, npm_registry), + platform_id="any", + ), ] ) - # Install elm-language-server if not already installed - elm_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "elm-lsp") + # legacy unversioned dir reserved for INITIAL pair; any other version combination goes into a versioned subdir + is_initial = ( + elm_language_server_version == INITIAL_ELM_LANGUAGE_SERVER_VERSION and elm_compiler_version == INITIAL_ELM_COMPILER_VERSION + ) + ls_dirname = "elm-lsp" if is_initial else f"elm-lsp-{elm_language_server_version}-{elm_compiler_version}" + elm_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), ls_dirname) 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): 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) + elif not system_elm: + log.info("Elm compiler not found on PATH. Installing a managed Elm compiler...") + with LogTime("Installation of Elm compiler dependency", logger=log): + deps.install(elm_ls_dir) if not os.path.exists(elm_ls_executable_path): raise FileNotFoundError( @@ -128,6 +154,7 @@ class ElmLanguageServer(SolidLanguageServer): "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "codeAction": {"dynamicRegistration": True}, "rename": {"dynamicRegistration": True}, + "publishDiagnostics": {"relatedInformation": True}, }, "workspace": { "workspaceFolders": True, @@ -203,3 +230,26 @@ class ElmLanguageServer(SolidLanguageServer): @override def _get_wait_time_for_cross_file_referencing(self) -> float: return 1.0 + + @override + def request_text_document_diagnostics( + self, + relative_file_path: str, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + ) -> list[ls_types.Diagnostic]: + uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) + + with self.open_file(relative_file_path): + diagnostics = self._wait_for_relevant_published_diagnostics( + uri=uri, + after_generation=-1, + timeout=10.0, + allow_cached=True, + ) + + if diagnostics is None: + return [] + + return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity) diff --git a/src/solidlsp/language_servers/fortran_language_server.py b/src/solidlsp/language_servers/fortran_language_server.py index 2cd6b099..a80efc68 100644 --- a/src/solidlsp/language_servers/fortran_language_server.py +++ b/src/solidlsp/language_servers/fortran_language_server.py @@ -11,7 +11,7 @@ import shutil from overrides import override from solidlsp import ls_types -from solidlsp.ls import DocumentSymbols, LSPFileBuffer, SolidLanguageServer +from solidlsp.ls import DocumentSymbols, LSPConstants, LSPFileBuffer, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo @@ -220,6 +220,7 @@ class FortranLanguageServer(SolidLanguageServer): "formatting": {"dynamicRegistration": True}, "rangeFormatting": {"dynamicRegistration": True}, "codeAction": {"dynamicRegistration": True}, + "publishDiagnostics": {"relatedInformation": True}, }, "workspace": { "workspaceFolders": True, @@ -284,3 +285,34 @@ class FortranLanguageServer(SolidLanguageServer): # Fortran Language Server is ready after initialization log.info("Fortran Language Server initialization complete") + + @override + def request_text_document_diagnostics( + self, + relative_file_path: str, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + ) -> list[ls_types.Diagnostic]: + uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) + diagnostics_before_request = self._get_published_diagnostics_generation(uri) + + with self.open_file(relative_file_path): + self.server.notify.did_save_text_document( + { + LSPConstants.TEXT_DOCUMENT: { # type: ignore + LSPConstants.URI: uri, + } + } + ) + diagnostics = self._wait_for_relevant_published_diagnostics( + uri=uri, + after_generation=diagnostics_before_request, + timeout=self._get_published_diagnostics_wait_timeout(True), + allow_cached=True, + ) + + if diagnostics is None: + return [] + + return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity) diff --git a/src/solidlsp/language_servers/fsharp_language_server.py b/src/solidlsp/language_servers/fsharp_language_server.py index 79970bae..a8cd6847 100644 --- a/src/solidlsp/language_servers/fsharp_language_server.py +++ b/src/solidlsp/language_servers/fsharp_language_server.py @@ -22,7 +22,12 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) -FSAUTOCOMPLETE_VERSION = "0.83.0" +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_FSAUTOCOMPLETE_VERSION = "0.83.0" +DEFAULT_FSAUTOCOMPLETE_VERSION = "0.83.0" +FSAUTOCOMPLETE_VERSION = DEFAULT_FSAUTOCOMPLETE_VERSION class FSharpLanguageServer(SolidLanguageServer): @@ -69,7 +74,7 @@ class FSharpLanguageServer(SolidLanguageServer): Setup runtime dependencies for F# Language Server and return the command to start the server. """ fsharp_settings = solidlsp_settings.get_ls_specific_settings(Language.FSHARP) - fsautocomplete_version = fsharp_settings.get("fsautocomplete_version", FSAUTOCOMPLETE_VERSION) + fsautocomplete_version = fsharp_settings.get("fsautocomplete_version", DEFAULT_FSAUTOCOMPLETE_VERSION) dotnet_exe = DotNETUtil("8.0", allow_higher_version=True).get_dotnet_path_or_raise() RuntimeDependencyCollection( @@ -84,7 +89,9 @@ class FSharpLanguageServer(SolidLanguageServer): ) # Install FsAutoComplete if not already installed - fsharp_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "fsharp-lsp") + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + ls_dirname = "fsharp-lsp" if fsautocomplete_version == INITIAL_FSAUTOCOMPLETE_VERSION else f"fsharp-lsp-{fsautocomplete_version}" + fsharp_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), ls_dirname) fsautocomplete_path = os.path.join(fsharp_ls_dir, "fsautocomplete") # Handle Windows executable extension diff --git a/src/solidlsp/language_servers/gopls.py b/src/solidlsp/language_servers/gopls.py index c855b836..3c098178 100644 --- a/src/solidlsp/language_servers/gopls.py +++ b/src/solidlsp/language_servers/gopls.py @@ -23,6 +23,10 @@ class Gopls(SolidLanguageServer): Provides Go specific instantiation of the LanguageServer class using gopls. """ + @classmethod + def supports_implementation_request(cls) -> bool: + return True + @override def is_ignored_dirname(self, dirname: str) -> bool: # For Go projects, we should ignore: diff --git a/src/solidlsp/language_servers/groovy_language_server.py b/src/solidlsp/language_servers/groovy_language_server.py index 66bcacc7..2370e128 100644 --- a/src/solidlsp/language_servers/groovy_language_server.py +++ b/src/solidlsp/language_servers/groovy_language_server.py @@ -18,13 +18,34 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) VSCODE_JAVA_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") -VSCODE_JAVA_SHA256_BY_PLATFORM = { + +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_VSCODE_JAVA_VERSION = "1.42.0-561" +INITIAL_VSCODE_JAVA_SHA256_BY_PLATFORM = { "win-x64": "ef195b45bd260976ad2e84618f4044b5d7248deed41d647573f0ee22c4233df3", "linux-x64": "7660b7b527be6fda46a917966b34d828e7416d5cc84287b29b88e7b99c1737f9", "linux-arm64": "e15bc9b2a665d3453203402621b5441062aa41b0ec2d140661f439326fd248c1", "osx-x64": "03ae1db1a22c15561a620f1b722d6797d35d4faaa7c4666dbe6ca2715089852f", "osx-arm64": "bc00c2699d4b8d478eb9a1621db9d6d3a12ea0dcc247a9cd8040e8ac19c03933", } +DEFAULT_VSCODE_JAVA_VERSION = "1.42.0-561" +DEFAULT_VSCODE_JAVA_SHA256_BY_PLATFORM = { + "win-x64": "ef195b45bd260976ad2e84618f4044b5d7248deed41d647573f0ee22c4233df3", + "linux-x64": "7660b7b527be6fda46a917966b34d828e7416d5cc84287b29b88e7b99c1737f9", + "linux-arm64": "e15bc9b2a665d3453203402621b5441062aa41b0ec2d140661f439326fd248c1", + "osx-x64": "03ae1db1a22c15561a620f1b722d6797d35d4faaa7c4666dbe6ca2715089852f", + "osx-arm64": "bc00c2699d4b8d478eb9a1621db9d6d3a12ea0dcc247a9cd8040e8ac19c03933", +} + + +def _vscode_java_sha(version: str, platform_key: str) -> str | None: + if version == INITIAL_VSCODE_JAVA_VERSION: + return INITIAL_VSCODE_JAVA_SHA256_BY_PLATFORM[platform_key] + if version == DEFAULT_VSCODE_JAVA_VERSION: + return DEFAULT_VSCODE_JAVA_SHA256_BY_PLATFORM[platform_key] + return None @dataclasses.dataclass @@ -93,7 +114,7 @@ class GroovyLanguageServer(SolidLanguageServer): """ platform_id = PlatformUtils.get_platform_id() groovy_settings = solidlsp_settings.get_ls_specific_settings(Language.GROOVY) - vscode_java_version = groovy_settings.get("vscode_java_version", "1.42.0-561") + vscode_java_version = groovy_settings.get("vscode_java_version", DEFAULT_VSCODE_JAVA_VERSION) vscode_java_tag = f"v{vscode_java_version.rsplit('-', 1)[0]}" # Verify platform support @@ -128,35 +149,35 @@ class GroovyLanguageServer(SolidLanguageServer): "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", - "sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["win-x64"] if vscode_java_version == "1.42.0-561" else None, + "sha256": _vscode_java_sha(vscode_java_version, "win-x64"), }, "linux-x64": { "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-linux-x64-{vscode_java_version}.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", - "sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["linux-x64"] if vscode_java_version == "1.42.0-561" else None, + "sha256": _vscode_java_sha(vscode_java_version, "linux-x64"), }, "linux-arm64": { "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-linux-arm64-{vscode_java_version}.vsix", "archiveType": "zip", "java_home_path": "extension/jre/21.0.7-linux-aarch64", "java_path": "extension/jre/21.0.7-linux-aarch64/bin/java", - "sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["linux-arm64"] if vscode_java_version == "1.42.0-561" else None, + "sha256": _vscode_java_sha(vscode_java_version, "linux-arm64"), }, "osx-x64": { "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-darwin-x64-{vscode_java_version}.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", - "sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["osx-x64"] if vscode_java_version == "1.42.0-561" else None, + "sha256": _vscode_java_sha(vscode_java_version, "osx-x64"), }, "osx-arm64": { "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-darwin-arm64-{vscode_java_version}.vsix", "archiveType": "zip", "java_home_path": "extension/jre/21.0.7-macosx-aarch64", "java_path": "extension/jre/21.0.7-macosx-aarch64/bin/java", - "sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["osx-arm64"] if vscode_java_version == "1.42.0-561" else None, + "sha256": _vscode_java_sha(vscode_java_version, "osx-arm64"), }, }, } @@ -174,7 +195,9 @@ class GroovyLanguageServer(SolidLanguageServer): 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") + # legacy unversioned "java" dir reserved for INITIAL; every other version goes into a versioned subdir + java_dirname = "java" if vscode_java_version == INITIAL_VSCODE_JAVA_VERSION else f"java-{vscode_java_version}" + java_dir = os.path.join(static_dir, java_dirname) os.makedirs(java_dir, exist_ok=True) java_home_path = os.path.join(java_dir, java_home_relative_path) diff --git a/src/solidlsp/language_servers/haxe_language_server.py b/src/solidlsp/language_servers/haxe_language_server.py index 41c63a2e..24ca6fe7 100644 --- a/src/solidlsp/language_servers/haxe_language_server.py +++ b/src/solidlsp/language_servers/haxe_language_server.py @@ -27,6 +27,22 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_VSHAXE_VERSION = "2.34.2" +INITIAL_VSHAXE_SHA256 = "104d785e3f7b57a7f3debf520d9751f7e7abf3a7e78d203db1a8ff3dc7ca30e2" +DEFAULT_VSHAXE_VERSION = "2.34.2" +DEFAULT_VSHAXE_SHA256 = "104d785e3f7b57a7f3debf520d9751f7e7abf3a7e78d203db1a8ff3dc7ca30e2" + + +def _vshaxe_sha(version: str) -> str | None: + if version == INITIAL_VSHAXE_VERSION: + return INITIAL_VSHAXE_SHA256 + if version == DEFAULT_VSHAXE_VERSION: + return DEFAULT_VSHAXE_SHA256 + return None + class HaxeLanguageServer(SolidLanguageServer): """Haxe language server integration using vshaxe/haxe-language-server. @@ -58,8 +74,6 @@ class HaxeLanguageServer(SolidLanguageServer): class DependencyProvider(LanguageServerDependencyProviderSinglePath): # Downloaded from Open VSX (not the VS Code Marketplace) because Open VSX # provides stable versioned URLs and SHA256 checksums for integrity verification. - _DEFAULT_VSHAXE_VERSION = "2.34.2" - _DEFAULT_VSHAXE_SHA256 = "104d785e3f7b57a7f3debf520d9751f7e7abf3a7e78d203db1a8ff3dc7ca30e2" @override def _get_or_install_core_dependency(self) -> str: @@ -77,7 +91,9 @@ class HaxeLanguageServer(SolidLanguageServer): return vscode_server_path # 3. Check resource dir / download from Open VSX - haxe_ls_dir = os.path.join(self._ls_resources_dir, "haxe-lsp") + version = self._custom_settings.get("version", DEFAULT_VSHAXE_VERSION) + ls_dirname = "haxe-lsp" if version == INITIAL_VSHAXE_VERSION else f"haxe-lsp-{version}" + haxe_ls_dir = os.path.join(self._ls_resources_dir, ls_dirname) server_js_path = os.path.join(haxe_ls_dir, "bin", "server.js") if os.path.exists(server_js_path): log.info(f"Found Haxe Language Server at {server_js_path}") @@ -92,7 +108,6 @@ class HaxeLanguageServer(SolidLanguageServer): " 3. Set ls_path in serena_config.yml under ls_specific_settings.haxe" ) - version = self._custom_settings.get("version", self._DEFAULT_VSHAXE_VERSION) downloaded_path = self._download_from_open_vsx(haxe_ls_dir, version) if downloaded_path: return downloaded_path @@ -129,16 +144,17 @@ class HaxeLanguageServer(SolidLanguageServer): vsix_path = os.path.join(tempfile.gettempdir(), "vshaxe.vsix") urllib.request.urlretrieve(download_url, vsix_path) - # Verify SHA256 checksum only for the default (pinned) version - if version == cls._DEFAULT_VSHAXE_VERSION: + # Verify SHA256 checksum only when the resolved version is one of our pinned ones (INITIAL or current DEFAULT) + expected_sha = _vshaxe_sha(version) + if expected_sha is not None: sha256 = hashlib.sha256() with open(vsix_path, "rb") as f: for chunk in iter(lambda: f.read(8192), b""): sha256.update(chunk) - if sha256.hexdigest().lower() != cls._DEFAULT_VSHAXE_SHA256: + if sha256.hexdigest().lower() != expected_sha: os.remove(vsix_path) raise RuntimeError( - f"SHA256 checksum mismatch for vshaxe VSIX. Expected {cls._DEFAULT_VSHAXE_SHA256}, " + f"SHA256 checksum mismatch for vshaxe VSIX. Expected {expected_sha}, " f"got {sha256.hexdigest()}. The file may be corrupted or tampered with." ) log.info("SHA256 checksum verified") @@ -215,7 +231,14 @@ class HaxeLanguageServer(SolidLanguageServer): "locale": "en", "capabilities": { "textDocument": { - "synchronization": {"didSave": True}, + "publishDiagnostics": { + "relatedInformation": True, + "versionSupport": False, + "tagSupport": {"valueSet": [1, 2]}, + "codeDescriptionSupport": True, + "dataSupport": True, + }, + "synchronization": {"dynamicRegistration": True, "didSave": True}, "completion": {"completionItem": {"snippetSupport": True}}, "definition": {}, "references": {}, diff --git a/src/solidlsp/language_servers/hlsl_language_server.py b/src/solidlsp/language_servers/hlsl_language_server.py index b6eb7e05..cab201dd 100644 --- a/src/solidlsp/language_servers/hlsl_language_server.py +++ b/src/solidlsp/language_servers/hlsl_language_server.py @@ -25,15 +25,32 @@ from .common import RuntimeDependency, RuntimeDependencyCollection log = logging.getLogger(__name__) -# GitHub release version to download when not installed locally -_DEFAULT_VERSION = "1.3.1" _GITHUB_RELEASE_BASE = "https://github.com/antaalt/shader-sense/releases/download" _HLSL_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") -_HLSL_SHA256_BY_ASSET = { + +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# _INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# _DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +_INITIAL_VERSION = "1.3.1" +_INITIAL_HLSL_SHA256_BY_ASSET = { "shader-language-server-x86_64-pc-windows-msvc.zip": "49081c5547ddde1b8b3b17295282a80ddacbca1d6f5dcd834e2788c02bafa997", "shader-language-server-x86_64-unknown-linux-gnu.zip": "61710df7ca17a2d063b598936c57c56c49fbf837707a1aa886f9b0193a35be3c", "shader-language-server-aarch64-pc-windows-msvc.zip": "a3b3799affe2cad27652e788376b46fe76e1a6c2ce45946a486dcb26c9091412", } +_DEFAULT_VERSION = "1.3.1" +_DEFAULT_HLSL_SHA256_BY_ASSET = { + "shader-language-server-x86_64-pc-windows-msvc.zip": "49081c5547ddde1b8b3b17295282a80ddacbca1d6f5dcd834e2788c02bafa997", + "shader-language-server-x86_64-unknown-linux-gnu.zip": "61710df7ca17a2d063b598936c57c56c49fbf837707a1aa886f9b0193a35be3c", + "shader-language-server-aarch64-pc-windows-msvc.zip": "a3b3799affe2cad27652e788376b46fe76e1a6c2ce45946a486dcb26c9091412", +} + + +def _hlsl_sha(version: str, asset_name: str) -> str | None: + if version == _INITIAL_VERSION: + return _INITIAL_HLSL_SHA256_BY_ASSET.get(asset_name) + if version == _DEFAULT_VERSION: + return _DEFAULT_HLSL_SHA256_BY_ASSET.get(asset_name) + return None class HlslLanguageServer(SolidLanguageServer): @@ -78,9 +95,7 @@ class HlslLanguageServer(SolidLanguageServer): platform_id="win-x64", archive_type="zip", binary_name="shader-language-server.exe", - sha256=_HLSL_SHA256_BY_ASSET["shader-language-server-x86_64-pc-windows-msvc.zip"] - if version == _DEFAULT_VERSION - else None, + sha256=_hlsl_sha(version, "shader-language-server-x86_64-pc-windows-msvc.zip"), allowed_hosts=_HLSL_ALLOWED_HOSTS, ), RuntimeDependency( @@ -90,9 +105,7 @@ class HlslLanguageServer(SolidLanguageServer): platform_id="linux-x64", archive_type="zip", binary_name="shader-language-server", - sha256=_HLSL_SHA256_BY_ASSET["shader-language-server-x86_64-unknown-linux-gnu.zip"] - if version == _DEFAULT_VERSION - else None, + sha256=_hlsl_sha(version, "shader-language-server-x86_64-unknown-linux-gnu.zip"), allowed_hosts=_HLSL_ALLOWED_HOSTS, ), RuntimeDependency( @@ -102,9 +115,7 @@ class HlslLanguageServer(SolidLanguageServer): platform_id="win-arm64", archive_type="zip", binary_name="shader-language-server.exe", - sha256=_HLSL_SHA256_BY_ASSET["shader-language-server-aarch64-pc-windows-msvc.zip"] - if version == _DEFAULT_VERSION - else None, + sha256=_hlsl_sha(version, "shader-language-server-aarch64-pc-windows-msvc.zip"), allowed_hosts=_HLSL_ALLOWED_HOSTS, ), RuntimeDependency( @@ -139,7 +150,9 @@ class HlslLanguageServer(SolidLanguageServer): "See https://github.com/antaalt/shader-sense for more details." ) - install_dir = os.path.join(self._ls_resources_dir, "shader-language-server") + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + ls_dirname = "shader-language-server" if version == _INITIAL_VERSION else f"shader-language-server-{version}" + install_dir = os.path.join(self._ls_resources_dir, ls_dirname) executable_path = deps.binary_path(install_dir) if not os.path.exists(executable_path): diff --git a/src/solidlsp/language_servers/intelephense.py b/src/solidlsp/language_servers/intelephense.py index 0c77af6d..47212695 100644 --- a/src/solidlsp/language_servers/intelephense.py +++ b/src/solidlsp/language_servers/intelephense.py @@ -21,6 +21,12 @@ from .common import RuntimeDependency, RuntimeDependencyCollection, build_npm_in log = logging.getLogger(__name__) +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_INTELEPHENSE_VERSION = "1.14.4" +DEFAULT_INTELEPHENSE_VERSION = "1.14.4" + class Intelephense(SolidLanguageServer): """ @@ -59,11 +65,12 @@ class Intelephense(SolidLanguageServer): 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." - intelephense_version = self._custom_settings.get("intelephense_version", "1.14.4") + intelephense_version = self._custom_settings.get("intelephense_version", DEFAULT_INTELEPHENSE_VERSION) npm_registry = self._custom_settings.get("npm_registry") - # Install intelephense if not already installed - intelephense_ls_dir = os.path.join(self._ls_resources_dir, "php-lsp") + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + ls_dirname = "php-lsp" if intelephense_version == INITIAL_INTELEPHENSE_VERSION else f"php-lsp-{intelephense_version}" + intelephense_ls_dir = os.path.join(self._ls_resources_dir, ls_dirname) os.makedirs(intelephense_ls_dir, exist_ok=True) intelephense_executable_path = os.path.join(intelephense_ls_dir, "node_modules", ".bin", "intelephense") if not os.path.exists(intelephense_executable_path): diff --git a/src/solidlsp/language_servers/json_language_server.py b/src/solidlsp/language_servers/json_language_server.py index bf46992a..f764418e 100644 --- a/src/solidlsp/language_servers/json_language_server.py +++ b/src/solidlsp/language_servers/json_language_server.py @@ -17,6 +17,12 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_JSON_LANGUAGE_SERVER_VERSION = "1.3.4" +DEFAULT_JSON_LANGUAGE_SERVER_VERSION = "1.3.4" + class JsonLanguageServer(SolidLanguageServer): """ @@ -54,7 +60,7 @@ class JsonLanguageServer(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." - json_language_server_version = self._custom_settings.get("json_language_server_version", "1.3.4") + json_language_server_version = self._custom_settings.get("json_language_server_version", DEFAULT_JSON_LANGUAGE_SERVER_VERSION) npm_registry = self._custom_settings.get("npm_registry") deps = RuntimeDependencyCollection( @@ -68,7 +74,13 @@ class JsonLanguageServer(SolidLanguageServer): ] ) - json_ls_dir = os.path.join(self._ls_resources_dir, "json-lsp") + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + ls_dirname = ( + "json-lsp" + if json_language_server_version == INITIAL_JSON_LANGUAGE_SERVER_VERSION + else f"json-lsp-{json_language_server_version}" + ) + json_ls_dir = os.path.join(self._ls_resources_dir, ls_dirname) json_executable_path = os.path.join(json_ls_dir, "node_modules", ".bin", "vscode-json-languageserver") if os.name == "nt": diff --git a/src/solidlsp/language_servers/julia_server.py b/src/solidlsp/language_servers/julia_server.py index 41d23c02..ff3acd66 100644 --- a/src/solidlsp/language_servers/julia_server.py +++ b/src/solidlsp/language_servers/julia_server.py @@ -10,7 +10,7 @@ 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.lsp_types import DiagnosticTag, InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -115,6 +115,21 @@ class JuliaLanguageServer(SolidLanguageServer): """Define language-specific directories to ignore for Julia projects.""" return super().is_ignored_dirname(dirname) or dirname in [".julia", "build", "dist"] + @override + def _supports_pull_diagnostics(self) -> bool: + # LanguageServer.jl raises an unhandled error on textDocument/diagnostic which crashes + # the server process. Force the published-diagnostics path instead. + return False + + @override + def _get_published_diagnostics_wait_timeout(self, pull_diagnostics_failed: bool) -> float: + # LanguageServer.jl needs significant warm-up after init before it emits the first + # publishDiagnostics: workspace/configuration round-trip + initial linter pass typically + # land 13-15s after the file is opened in a cold LS. Module-scoped fixtures usually have + # the cache pre-populated and return immediately, so this ceiling only kicks in for cold + # runs (standalone test invocations, CI cold start). + return 30.0 + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Julia Language Server. @@ -125,8 +140,19 @@ class JuliaLanguageServer(SolidLanguageServer): "rootPath": repository_absolute_path, "rootUri": root_uri, "capabilities": { - "workspace": {"workspaceFolders": True}, + # workspace.configuration MUST be true: LanguageServer.jl pulls all julia.lint.* + # settings via workspace/configuration after initialize. Without it, the server + # skips that codepath and runlinter stays disabled, so no diagnostics ever arrive. + "workspace": {"workspaceFolders": True, "configuration": True}, "textDocument": { + "publishDiagnostics": { + "relatedInformation": True, + "versionSupport": False, + "tagSupport": {"valueSet": [DiagnosticTag.Unnecessary, DiagnosticTag.Deprecated]}, + "codeDescriptionSupport": True, + "dataSupport": True, + }, + "synchronization": {"dynamicRegistration": True, "didSave": True}, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "documentSymbol": {"dynamicRegistration": True}, @@ -150,6 +176,19 @@ class JuliaLanguageServer(SolidLanguageServer): def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") + def workspace_configuration(params: dict) -> list[Any]: + """ + Respond to LanguageServer.jl's workspace/configuration pull request. + + The server requests a flat list of julia.* settings (in particular julia.lint.run, + position 11 in the response). Returning ``None`` for each entry causes the server to + apply its built-in defaults — including ``runlinter = true`` — which is what we want + for diagnostics to be produced. + """ + items = params.get("items", []) if isinstance(params, dict) else [] + return [None for _ in items] + + self.server.on_request("workspace/configuration", workspace_configuration) self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) @@ -166,4 +205,10 @@ class JuliaLanguageServer(SolidLanguageServer): assert "documentSymbolProvider" in init_response["capabilities"] self.server.notify.initialized({}) + + # nudge the server to pull config: LanguageServer.jl only invokes request_julia_config + # from its workspace/didChangeConfiguration handler, so without this notification the + # server never asks us for julia.lint.run and runlinter stays disabled (no diagnostics). + self.server.notify.workspace_did_change_configuration({"settings": {}}) + 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 bd2d3035..3998dafa 100644 --- a/src/solidlsp/language_servers/kotlin_language_server.py +++ b/src/solidlsp/language_servers/kotlin_language_server.py @@ -41,16 +41,36 @@ log = logging.getLogger(__name__) # -Xmx2G: 2GB heap is sufficient for most projects; override via ls_specific_settings for large codebases DEFAULT_KOTLIN_JVM_OPTIONS = "-Xmx2G" -# Default Kotlin Language Server version (can be overridden via ls_specific_settings) -DEFAULT_KOTLIN_LSP_VERSION = "261.13587.0" KOTLIN_LSP_ALLOWED_HOSTS = ("download-cdn.jetbrains.com",) -KOTLIN_LSP_SHA256_BY_SUFFIX = { + +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_KOTLIN_LSP_VERSION = "261.13587.0" +INITIAL_KOTLIN_LSP_SHA256_BY_SUFFIX = { "win-x64": "2806c2bd4810bd8e7ccc27d8c0ca4a5232a1c4f26ea1f4ba40e578b60860ccad", "linux-x64": "dc0ed2e70cb0d61fdabb26aefce8299b7a75c0dcfffb9413715e92caec6e83ec", "linux-aarch64": "d1dceb000fe06c5e2c30b95e7f4ab01d05101bd03ed448167feeb544a9f1d651", "mac-x64": "a3972f27229eba2c226060e54baea1c958c82c326dfc971bf53f72a74d0564a3", "mac-aarch64": "d4ea28b22b29cf906fe16d23698a8468f11646a6a66dcb15584f306aaefbee6c", } +DEFAULT_KOTLIN_LSP_VERSION = "261.13587.0" +DEFAULT_KOTLIN_LSP_SHA256_BY_SUFFIX = { + "win-x64": "2806c2bd4810bd8e7ccc27d8c0ca4a5232a1c4f26ea1f4ba40e578b60860ccad", + "linux-x64": "dc0ed2e70cb0d61fdabb26aefce8299b7a75c0dcfffb9413715e92caec6e83ec", + "linux-aarch64": "d1dceb000fe06c5e2c30b95e7f4ab01d05101bd03ed448167feeb544a9f1d651", + "mac-x64": "a3972f27229eba2c226060e54baea1c958c82c326dfc971bf53f72a74d0564a3", + "mac-aarch64": "d4ea28b22b29cf906fe16d23698a8468f11646a6a66dcb15584f306aaefbee6c", +} + + +def _kotlin_lsp_sha(version: str, kotlin_suffix: str) -> str | None: + if version == INITIAL_KOTLIN_LSP_VERSION: + return INITIAL_KOTLIN_LSP_SHA256_BY_SUFFIX.get(kotlin_suffix) + if version == DEFAULT_KOTLIN_LSP_VERSION: + return DEFAULT_KOTLIN_LSP_SHA256_BY_SUFFIX.get(kotlin_suffix) + return None + # Platform-specific Kotlin LSP download suffixes PLATFORM_KOTLIN_SUFFIX = { @@ -108,8 +128,14 @@ class KotlinLanguageServer(SolidLanguageServer): kotlin_suffix = PLATFORM_KOTLIN_SUFFIX.get(platform_id.value) assert kotlin_suffix, f"Unsupported platform for Kotlin LSP: {platform_id.value}" - # Setup paths for dependencies - static_dir = os.path.join(self._ls_resources_dir, "kotlin_language_server") + # Setup paths for dependencies; legacy unversioned dir reserved for INITIAL only + kotlin_lsp_version = self._custom_settings.get("kotlin_lsp_version", DEFAULT_KOTLIN_LSP_VERSION) + ls_dirname = ( + "kotlin_language_server" + if kotlin_lsp_version == INITIAL_KOTLIN_LSP_VERSION + else f"kotlin_language_server-{kotlin_lsp_version}" + ) + static_dir = os.path.join(self._ls_resources_dir, ls_dirname) os.makedirs(static_dir, exist_ok=True) # Setup Kotlin Language Server @@ -117,11 +143,8 @@ class KotlinLanguageServer(SolidLanguageServer): kotlin_script = os.path.join(static_dir, kotlin_script_name) if not os.path.exists(kotlin_script): - kotlin_lsp_version = self._custom_settings.get("kotlin_lsp_version", DEFAULT_KOTLIN_LSP_VERSION) kotlin_url = f"https://download-cdn.jetbrains.com/kotlin-lsp/{kotlin_lsp_version}/kotlin-lsp-{kotlin_lsp_version}-{kotlin_suffix}.zip" - expected_sha256 = None - if kotlin_lsp_version == DEFAULT_KOTLIN_LSP_VERSION: - expected_sha256 = KOTLIN_LSP_SHA256_BY_SUFFIX[kotlin_suffix] + expected_sha256 = _kotlin_lsp_sha(kotlin_lsp_version, kotlin_suffix) log.info("Downloading Kotlin Language Server...") FileUtils.download_and_extract_archive_verified( kotlin_url, diff --git a/src/solidlsp/language_servers/lean4_language_server.py b/src/solidlsp/language_servers/lean4_language_server.py index 463e4992..961a65b6 100644 --- a/src/solidlsp/language_servers/lean4_language_server.py +++ b/src/solidlsp/language_servers/lean4_language_server.py @@ -124,6 +124,7 @@ class Lean4LanguageServer(SolidLanguageServer): "documentationFormat": ["markdown", "plaintext"], }, }, + "publishDiagnostics": {"relatedInformation": True}, }, "workspace": { "workspaceFolders": True, diff --git a/src/solidlsp/language_servers/lua_ls.py b/src/solidlsp/language_servers/lua_ls.py index ef5e8674..3176e41f 100644 --- a/src/solidlsp/language_servers/lua_ls.py +++ b/src/solidlsp/language_servers/lua_ls.py @@ -26,15 +26,42 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) -LUA_LS_VERSION = "3.15.0" LUA_LS_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") -LUA_LS_SHA256_BY_ASSET = { + +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_LUA_LS_VERSION = "3.15.0" +INITIAL_LUA_LS_SHA256_BY_ASSET = { "lua-language-server-3.15.0-linux-x64.tar.gz": "4877b874c52fb7587707898da9026cc3a6c854d9bbab115ef49ac4e6a1b88007", "lua-language-server-3.15.0-linux-arm64.tar.gz": "7dff8edfed4f34cf6325ff384791287d95f9a8dd9615a5279c7c6af81cf8c45d", "lua-language-server-3.15.0-darwin-x64.tar.gz": "01d28a31e264434e51662814a68f584af068393caecfa158c4df5f7fdc3ca2f7", "lua-language-server-3.15.0-darwin-arm64.tar.gz": "050f5f493f65112afc116e31281a9f73918546782d3696485dc052724838f58b", "lua-language-server-3.15.0-win32-x64.zip": "76a10c05e8c947a448f00a61acead4240484cd1e2e8c66d54401c67d99b77535", } +DEFAULT_LUA_LS_VERSION = "3.15.0" +DEFAULT_LUA_LS_SHA256_BY_ASSET = { + "lua-language-server-3.15.0-linux-x64.tar.gz": "4877b874c52fb7587707898da9026cc3a6c854d9bbab115ef49ac4e6a1b88007", + "lua-language-server-3.15.0-linux-arm64.tar.gz": "7dff8edfed4f34cf6325ff384791287d95f9a8dd9615a5279c7c6af81cf8c45d", + "lua-language-server-3.15.0-darwin-x64.tar.gz": "01d28a31e264434e51662814a68f584af068393caecfa158c4df5f7fdc3ca2f7", + "lua-language-server-3.15.0-darwin-arm64.tar.gz": "050f5f493f65112afc116e31281a9f73918546782d3696485dc052724838f58b", + "lua-language-server-3.15.0-win32-x64.zip": "76a10c05e8c947a448f00a61acead4240484cd1e2e8c66d54401c67d99b77535", +} + + +def _lua_ls_sha(version: str, asset_name: str) -> str | None: + if version == INITIAL_LUA_LS_VERSION: + return INITIAL_LUA_LS_SHA256_BY_ASSET.get(asset_name) + if version == DEFAULT_LUA_LS_VERSION: + return DEFAULT_LUA_LS_SHA256_BY_ASSET.get(asset_name) + return None + + +def _lua_ls_install_dir(ls_resources_dir: str, version: str) -> Path: + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + if version == INITIAL_LUA_LS_VERSION: + return Path(ls_resources_dir) / "lua" + return Path(ls_resources_dir) / f"lua-{version}" class LuaLanguageServer(SolidLanguageServer): @@ -67,7 +94,9 @@ class LuaLanguageServer(SolidLanguageServer): ] if solidlsp_settings is not None: - ls_resource_dir = Path(LuaLanguageServer.ls_resources_dir(solidlsp_settings)) / "lua" + lua_settings = solidlsp_settings.get_ls_specific_settings(Language.LUA) + lua_ls_version = lua_settings.get("lua_language_server_version", DEFAULT_LUA_LS_VERSION) + ls_resource_dir = _lua_ls_install_dir(LuaLanguageServer.ls_resources_dir(solidlsp_settings), lua_ls_version) possible_paths.extend( [ ls_resource_dir / "bin" / "lua-language-server", @@ -93,7 +122,7 @@ class LuaLanguageServer(SolidLanguageServer): def _download_lua_ls(solidlsp_settings: SolidLSPSettings) -> str: """Download and install lua-language-server if not present.""" lua_settings = solidlsp_settings.get_ls_specific_settings(Language.LUA) - lua_ls_version = lua_settings.get("lua_language_server_version", LUA_LS_VERSION) + lua_ls_version = lua_settings.get("lua_language_server_version", DEFAULT_LUA_LS_VERSION) system = platform.system() machine = platform.machine().lower() @@ -122,7 +151,7 @@ class LuaLanguageServer(SolidLanguageServer): download_url = f"https://github.com/LuaLS/lua-language-server/releases/download/{lua_ls_version}/{download_name}" - install_dir = Path(LuaLanguageServer.ls_resources_dir(solidlsp_settings)) / "lua" + install_dir = _lua_ls_install_dir(LuaLanguageServer.ls_resources_dir(solidlsp_settings), lua_ls_version) install_dir.mkdir(parents=True, exist_ok=True) log.info("Downloading lua-language-server from %s", download_url) @@ -131,7 +160,7 @@ class LuaLanguageServer(SolidLanguageServer): download_url, str(install_dir), archive_type, - expected_sha256=LUA_LS_SHA256_BY_ASSET.get(download_name) if lua_ls_version == LUA_LS_VERSION else None, + expected_sha256=_lua_ls_sha(lua_ls_version, download_name), allowed_hosts=LUA_LS_ALLOWED_HOSTS, ) diff --git a/src/solidlsp/language_servers/luau_lsp.py b/src/solidlsp/language_servers/luau_lsp.py index 4150374d..f0cb06bf 100644 --- a/src/solidlsp/language_servers/luau_lsp.py +++ b/src/solidlsp/language_servers/luau_lsp.py @@ -37,15 +37,34 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) -# Pin to a known stable release -LUAU_LSP_VERSION = "1.63.0" LUAU_LSP_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") -LUAU_LSP_SHA256_BY_ASSET = { + +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_LUAU_LSP_VERSION = "1.63.0" +INITIAL_LUAU_LSP_SHA256_BY_ASSET = { "luau-lsp-linux-x86_64.zip": "e4b633ad9a2c15437f60f9e721263f79aa0da606867d8458f0e159a325bf2db8", "luau-lsp-linux-arm64.zip": "355be010f337a6772df6255c92e1fb28a59d194abe5c570453f4186472244355", "luau-lsp-macos.zip": "01c1d6dd5fee27295b2968915dabb08c192192c46d9fe9c97bf31a130c96b8cb", "luau-lsp-win64.zip": "eea596d47dc1c94a61ba1b78e6472bb4445bc3309780751515e6ab0a0abba57d", } +DEFAULT_LUAU_LSP_VERSION = "1.63.0" +DEFAULT_LUAU_LSP_SHA256_BY_ASSET = { + "luau-lsp-linux-x86_64.zip": "e4b633ad9a2c15437f60f9e721263f79aa0da606867d8458f0e159a325bf2db8", + "luau-lsp-linux-arm64.zip": "355be010f337a6772df6255c92e1fb28a59d194abe5c570453f4186472244355", + "luau-lsp-macos.zip": "01c1d6dd5fee27295b2968915dabb08c192192c46d9fe9c97bf31a130c96b8cb", + "luau-lsp-win64.zip": "eea596d47dc1c94a61ba1b78e6472bb4445bc3309780751515e6ab0a0abba57d", +} + + +def _luau_lsp_sha(version: str, asset_name: str) -> str | None: + if version == INITIAL_LUAU_LSP_VERSION: + return INITIAL_LUAU_LSP_SHA256_BY_ASSET.get(asset_name) + if version == DEFAULT_LUAU_LSP_VERSION: + return DEFAULT_LUAU_LSP_SHA256_BY_ASSET.get(asset_name) + return None + # Luau built-in docs CDN LUAU_DOCS_URL = "https://luau-lsp.pages.dev/api-docs/luau-en-us.json" @@ -98,14 +117,19 @@ class LuauLanguageServer(SolidLanguageServer): return cmd def _download_luau_lsp(self) -> str: - install_dir = Path(self._ls_resources_dir) + luau_lsp_version = self._custom_settings.get("luau_lsp_version", DEFAULT_LUAU_LSP_VERSION) + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + install_dir = ( + Path(self._ls_resources_dir) + if luau_lsp_version == INITIAL_LUAU_LSP_VERSION + else Path(self._ls_resources_dir) / f"luau-lsp-{luau_lsp_version}" + ) install_dir.mkdir(parents=True, exist_ok=True) binary_path = self._find_existing_binary(install_dir) if binary_path is not None: return binary_path - luau_lsp_version = self._custom_settings.get("luau_lsp_version", LUAU_LSP_VERSION) asset_name = self._get_luau_lsp_asset_name() download_url = f"https://github.com/JohnnyMorganz/luau-lsp/releases/download/{luau_lsp_version}/{asset_name}" @@ -114,7 +138,7 @@ class LuauLanguageServer(SolidLanguageServer): download_url, str(install_dir), "zip", - expected_sha256=LUAU_LSP_SHA256_BY_ASSET.get(asset_name) if luau_lsp_version == LUAU_LSP_VERSION else None, + expected_sha256=_luau_lsp_sha(luau_lsp_version, asset_name), allowed_hosts=LUAU_LSP_ALLOWED_HOSTS, ) diff --git a/src/solidlsp/language_servers/marksman.py b/src/solidlsp/language_servers/marksman.py index affe2dcb..13c32147 100644 --- a/src/solidlsp/language_servers/marksman.py +++ b/src/solidlsp/language_servers/marksman.py @@ -28,6 +28,34 @@ log = logging.getLogger(__name__) MARKSMAN_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_MARKSMAN_VERSION = "2024-12-18" +INITIAL_MARKSMAN_SHA256_BY_PLATFORM = { + "linux-x64": "b9cb666c643dfd9b699811fdfc445ed4c56be65c1d878c21d46847f0d7b0e475", + "linux-arm64": "b8d6972a56f3f9b7bbbf7c77ef8998e3b66fa82fb03c01398e224144486c9e73", + "osx-x64": "7e18803966231a33ee107d0d26f69b41f2f0dc1332c52dd9729c2e29fb77be83", + "osx-arm64": "7e18803966231a33ee107d0d26f69b41f2f0dc1332c52dd9729c2e29fb77be83", + "win-x64": "39de9df039c8b0d627ac5918a9d8792ad20fc49e2461d1f5c906975c016799ec", +} +DEFAULT_MARKSMAN_VERSION = "2024-12-18" +DEFAULT_MARKSMAN_SHA256_BY_PLATFORM = { + "linux-x64": "b9cb666c643dfd9b699811fdfc445ed4c56be65c1d878c21d46847f0d7b0e475", + "linux-arm64": "b8d6972a56f3f9b7bbbf7c77ef8998e3b66fa82fb03c01398e224144486c9e73", + "osx-x64": "7e18803966231a33ee107d0d26f69b41f2f0dc1332c52dd9729c2e29fb77be83", + "osx-arm64": "7e18803966231a33ee107d0d26f69b41f2f0dc1332c52dd9729c2e29fb77be83", + "win-x64": "39de9df039c8b0d627ac5918a9d8792ad20fc49e2461d1f5c906975c016799ec", +} + + +def _marksman_sha(version: str, platform_key: str) -> str | None: + if version == INITIAL_MARKSMAN_VERSION: + return INITIAL_MARKSMAN_SHA256_BY_PLATFORM[platform_key] + if version == DEFAULT_MARKSMAN_VERSION: + return DEFAULT_MARKSMAN_SHA256_BY_PLATFORM[platform_key] + return None + class Marksman(SolidLanguageServer): """ @@ -39,12 +67,9 @@ class Marksman(SolidLanguageServer): """ class DependencyProvider(LanguageServerDependencyProviderSinglePath): - DEFAULT_MARKSMAN_VERSION = "2024-12-18" - @classmethod def _runtime_dependencies(cls, version: str) -> RuntimeDependencyCollection: marksman_releases = f"https://github.com/artempyanykh/marksman/releases/download/{version}" - default_version = version == cls.DEFAULT_MARKSMAN_VERSION return RuntimeDependencyCollection( [ RuntimeDependency( @@ -53,7 +78,7 @@ class Marksman(SolidLanguageServer): platform_id="linux-x64", archive_type="binary", binary_name="marksman", - sha256="b9cb666c643dfd9b699811fdfc445ed4c56be65c1d878c21d46847f0d7b0e475" if default_version else None, + sha256=_marksman_sha(version, "linux-x64"), allowed_hosts=MARKSMAN_ALLOWED_HOSTS, ), RuntimeDependency( @@ -62,7 +87,7 @@ class Marksman(SolidLanguageServer): platform_id="linux-arm64", archive_type="binary", binary_name="marksman", - sha256="b8d6972a56f3f9b7bbbf7c77ef8998e3b66fa82fb03c01398e224144486c9e73" if default_version else None, + sha256=_marksman_sha(version, "linux-arm64"), allowed_hosts=MARKSMAN_ALLOWED_HOSTS, ), RuntimeDependency( @@ -71,7 +96,7 @@ class Marksman(SolidLanguageServer): platform_id="osx-x64", archive_type="binary", binary_name="marksman", - sha256="7e18803966231a33ee107d0d26f69b41f2f0dc1332c52dd9729c2e29fb77be83" if default_version else None, + sha256=_marksman_sha(version, "osx-x64"), allowed_hosts=MARKSMAN_ALLOWED_HOSTS, ), RuntimeDependency( @@ -80,7 +105,7 @@ class Marksman(SolidLanguageServer): platform_id="osx-arm64", archive_type="binary", binary_name="marksman", - sha256="7e18803966231a33ee107d0d26f69b41f2f0dc1332c52dd9729c2e29fb77be83" if default_version else None, + sha256=_marksman_sha(version, "osx-arm64"), allowed_hosts=MARKSMAN_ALLOWED_HOSTS, ), RuntimeDependency( @@ -89,7 +114,7 @@ class Marksman(SolidLanguageServer): platform_id="win-x64", archive_type="binary", binary_name="marksman.exe", - sha256="39de9df039c8b0d627ac5918a9d8792ad20fc49e2461d1f5c906975c016799ec" if default_version else None, + sha256=_marksman_sha(version, "win-x64"), allowed_hosts=MARKSMAN_ALLOWED_HOSTS, ), ] @@ -97,11 +122,16 @@ class Marksman(SolidLanguageServer): def _get_or_install_core_dependency(self) -> str: """Setup runtime dependencies for marksman and return the command to start the server.""" - marksman_version = self._custom_settings.get("marksman_version", self.DEFAULT_MARKSMAN_VERSION) + marksman_version = self._custom_settings.get("marksman_version", DEFAULT_MARKSMAN_VERSION) deps = self._runtime_dependencies(marksman_version) dependency = deps.get_single_dep_for_current_platform() - marksman_ls_dir = self._ls_resources_dir + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + marksman_ls_dir = ( + self._ls_resources_dir + if marksman_version == INITIAL_MARKSMAN_VERSION + else os.path.join(self._ls_resources_dir, f"marksman-{marksman_version}") + ) marksman_executable_path = deps.binary_path(marksman_ls_dir) if not os.path.exists(marksman_executable_path): log.info( diff --git a/src/solidlsp/language_servers/matlab_language_server.py b/src/solidlsp/language_servers/matlab_language_server.py index d31c2224..5a83a75f 100644 --- a/src/solidlsp/language_servers/matlab_language_server.py +++ b/src/solidlsp/language_servers/matlab_language_server.py @@ -50,11 +50,29 @@ log = logging.getLogger(__name__) # Environment variable for MATLAB installation path MATLAB_PATH_ENV_VAR = "MATLAB_PATH" -MATLAB_EXTENSION_VERSION = "1.3.9" -MATLAB_EXTENSION_URL = f"https://marketplace.visualstudio.com/_apis/public/gallery/publishers/MathWorks/vsextensions/language-matlab/{MATLAB_EXTENSION_VERSION}/vspackage" -MATLAB_EXTENSION_SHA256 = "1da3add2c3a593fa0ebcdf1d15231faee8014de10f549c36915ab9d4f18390f2" MATLAB_EXTENSION_ALLOWED_HOSTS = ("marketplace.visualstudio.com",) +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_MATLAB_EXTENSION_VERSION = "1.3.9" +INITIAL_MATLAB_EXTENSION_SHA256 = "1da3add2c3a593fa0ebcdf1d15231faee8014de10f549c36915ab9d4f18390f2" +DEFAULT_MATLAB_EXTENSION_VERSION = "1.3.9" +DEFAULT_MATLAB_EXTENSION_SHA256 = "1da3add2c3a593fa0ebcdf1d15231faee8014de10f549c36915ab9d4f18390f2" + + +def _matlab_extension_sha(version: str) -> str | None: + if version == INITIAL_MATLAB_EXTENSION_VERSION: + return INITIAL_MATLAB_EXTENSION_SHA256 + if version == DEFAULT_MATLAB_EXTENSION_VERSION: + return DEFAULT_MATLAB_EXTENSION_SHA256 + return None + + +def _matlab_extension_dirname(version: str) -> str: + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + return "matlab-extension" if version == INITIAL_MATLAB_EXTENSION_VERSION else f"matlab-extension-{version}" + class MatlabLanguageServer(SolidLanguageServer): """ @@ -102,20 +120,12 @@ class MatlabLanguageServer(SolidLanguageServer): self._matlab_path: str | None = None @classmethod - def _download_matlab_extension(cls, url: str, target_dir: str) -> bool: + def _download_matlab_extension(cls, url: str, target_dir: str, expected_sha256: str | None) -> bool: """ Download and extract the MATLAB extension from VS Code marketplace. The VS Code marketplace packages extensions as .vsix files (which are ZIP archives). This method downloads the VSIX file and extracts it to get the language server. - - Args: - url: VS Code marketplace URL for the MATLAB extension - target_dir: Directory where the extension will be extracted - - Returns: - True if successful, False otherwise - """ try: log.info(f"Downloading MATLAB extension from {url}") @@ -124,7 +134,7 @@ class MatlabLanguageServer(SolidLanguageServer): url, target_dir, "zip", - expected_sha256=MATLAB_EXTENSION_SHA256 if url == MATLAB_EXTENSION_URL else None, + expected_sha256=expected_sha256, allowed_hosts=MATLAB_EXTENSION_ALLOWED_HOSTS, ) log.info("MATLAB extension extracted successfully") @@ -155,8 +165,9 @@ class MatlabLanguageServer(SolidLanguageServer): elif env_path: log.warning(f"MATLAB_EXTENSION_PATH set but directory not found: {env_path}") - # Check default download location - default_path = os.path.join(self._ls_resources_dir, "matlab-extension", "extension") + # Check the resolved-version download location (versioned for non-INITIAL, legacy "matlab-extension" for INITIAL) + matlab_extension_version = self._custom_settings.get("matlab_extension_version", DEFAULT_MATLAB_EXTENSION_VERSION) + default_path = os.path.join(self._ls_resources_dir, _matlab_extension_dirname(matlab_extension_version), "extension") if os.path.exists(default_path): log.debug(f"Found MATLAB extension in default location: {default_path}") return default_path @@ -182,8 +193,8 @@ class MatlabLanguageServer(SolidLanguageServer): Path to installed extension or None if download failed """ - matlab_extension_dir = os.path.join(self._ls_resources_dir, "matlab-extension") - matlab_extension_version = self._custom_settings.get("matlab_extension_version", MATLAB_EXTENSION_VERSION) + matlab_extension_version = self._custom_settings.get("matlab_extension_version", DEFAULT_MATLAB_EXTENSION_VERSION) + matlab_extension_dir = os.path.join(self._ls_resources_dir, _matlab_extension_dirname(matlab_extension_version)) matlab_extension_url = ( "https://marketplace.visualstudio.com/_apis/public/gallery/publishers/MathWorks/" f"vsextensions/language-matlab/{matlab_extension_version}/vspackage" @@ -191,7 +202,7 @@ class MatlabLanguageServer(SolidLanguageServer): log.info(f"Downloading MATLAB extension from: {matlab_extension_url}") - if self._download_matlab_extension(matlab_extension_url, matlab_extension_dir): + if self._download_matlab_extension(matlab_extension_url, matlab_extension_dir, _matlab_extension_sha(matlab_extension_version)): extension_path = os.path.join(matlab_extension_dir, "extension") if os.path.exists(extension_path): log.info("MATLAB extension downloaded and installed successfully") diff --git a/src/solidlsp/language_servers/ocaml_lsp_server.py b/src/solidlsp/language_servers/ocaml_lsp_server.py index 8c9bc356..b2e9511a 100644 --- a/src/solidlsp/language_servers/ocaml_lsp_server.py +++ b/src/solidlsp/language_servers/ocaml_lsp_server.py @@ -242,7 +242,7 @@ class OcamlLanguageServer(SolidLanguageServer): """ ocaml_ok = self._ocaml_version >= (5, 2, 0) lsp_ok = self._lsp_version >= self.MIN_LSP_VERSION_FOR_CROSS_FILE_REFS - return ocaml_ok and lsp_ok + return ocaml_ok and lsp_ok and self._index_built @staticmethod def _build_ocaml_index_static(repository_root_path: str) -> bool: @@ -340,6 +340,14 @@ class OcamlLanguageServer(SolidLanguageServer): "configuration": True, }, "textDocument": { + "publishDiagnostics": { + "relatedInformation": True, + "versionSupport": False, + "tagSupport": {"valueSet": [1, 2]}, + "codeDescriptionSupport": True, + "dataSupport": True, + }, + "diagnostic": {"dynamicRegistration": True, "relatedDocumentSupport": False}, "synchronization": { "dynamicRegistration": True, "willSave": True, diff --git a/src/solidlsp/language_servers/omnisharp.py b/src/solidlsp/language_servers/omnisharp.py index ddd21698..f4ece965 100644 --- a/src/solidlsp/language_servers/omnisharp.py +++ b/src/solidlsp/language_servers/omnisharp.py @@ -22,6 +22,11 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) OMNISHARP_ALLOWED_HOSTS = ("roslynomnisharp.blob.core.windows.net", "download.visualstudio.microsoft.com") +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_OMNISHARP_VERSION = "1.39.10" +INITIAL_RAZOR_OMNISHARP_VERSION = "7.0.0-preview.23363.1" DEFAULT_OMNISHARP_VERSION = "1.39.10" DEFAULT_RAZOR_OMNISHARP_VERSION = "7.0.0-preview.23363.1" @@ -168,11 +173,11 @@ class OmniSharp(SolidLanguageServer): dependency["installPath"] = dependency["installPath"].replace(DEFAULT_OMNISHARP_VERSION, omnisharp_version) if "installTestPath" in dependency: dependency["installTestPath"] = dependency["installTestPath"].replace(DEFAULT_OMNISHARP_VERSION, omnisharp_version) - if omnisharp_version != DEFAULT_OMNISHARP_VERSION: + if omnisharp_version not in (INITIAL_OMNISHARP_VERSION, DEFAULT_OMNISHARP_VERSION): dependency["integrity"] = None elif dependency["id"] == "RazorOmnisharp": dependency["url"] = dependency["url"].replace(DEFAULT_RAZOR_OMNISHARP_VERSION, razor_omnisharp_version) - if razor_omnisharp_version != DEFAULT_RAZOR_OMNISHARP_VERSION: + if razor_omnisharp_version not in (INITIAL_RAZOR_OMNISHARP_VERSION, DEFAULT_RAZOR_OMNISHARP_VERSION): dependency["integrity"] = None assert platform_id in [ @@ -207,7 +212,9 @@ class OmniSharp(SolidLanguageServer): assert "OmniSharp" in runtime_dependencies assert "RazorOmnisharp" in runtime_dependencies - omnisharp_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "OmniSharp") + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + omnisharp_dirname = "OmniSharp" if omnisharp_version == INITIAL_OMNISHARP_VERSION else f"OmniSharp-{omnisharp_version}" + omnisharp_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), omnisharp_dirname) if not os.path.exists(omnisharp_ls_dir): os.makedirs(omnisharp_ls_dir) FileUtils.download_and_extract_archive_verified( @@ -221,7 +228,10 @@ class OmniSharp(SolidLanguageServer): assert os.path.exists(omnisharp_executable_path) os.chmod(omnisharp_executable_path, 0o755) - razor_omnisharp_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "RazorOmnisharp") + razor_dirname = ( + "RazorOmnisharp" if razor_omnisharp_version == INITIAL_RAZOR_OMNISHARP_VERSION else f"RazorOmnisharp-{razor_omnisharp_version}" + ) + razor_omnisharp_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), razor_dirname) if not os.path.exists(razor_omnisharp_ls_dir): os.makedirs(razor_omnisharp_ls_dir) FileUtils.download_and_extract_archive_verified( diff --git a/src/solidlsp/language_servers/pascal_server.py b/src/solidlsp/language_servers/pascal_server.py index 9de2a926..34c6ff73 100644 --- a/src/solidlsp/language_servers/pascal_server.py +++ b/src/solidlsp/language_servers/pascal_server.py @@ -69,7 +69,34 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) -PASLS_VERSION = "v0.2.0" +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_PASLS_VERSION = "v0.2.0" +INITIAL_PASLS_SHA256_BY_PLATFORM = { + "linux-x64": "517259395b0a385a5e848cf48b967645a984be3dd456118bc08771283a822a5b", + "linux-arm64": "cb4986941cfdcf9cb74ece6bbb53a443390a908e880108a37f4ccf82b2d6c502", + "osx-x64": "0abfcd98f63f77dba74094339a40d4407b69317c1c77b13a26b9b7dbdfd885f1", + "osx-arm64": "d4c2411e406af96ceae12b11e77fdb0c684ca15a68bfd8b4f9c6fe1fbdf515a7", + "win-x64": "1493c31552e6f90a59800b2d44669e01fc4551d3647f3cea5dd105a9f6bc73e5", +} +DEFAULT_PASLS_VERSION = "v0.2.0" +DEFAULT_PASLS_SHA256_BY_PLATFORM = { + "linux-x64": "517259395b0a385a5e848cf48b967645a984be3dd456118bc08771283a822a5b", + "linux-arm64": "cb4986941cfdcf9cb74ece6bbb53a443390a908e880108a37f4ccf82b2d6c502", + "osx-x64": "0abfcd98f63f77dba74094339a40d4407b69317c1c77b13a26b9b7dbdfd885f1", + "osx-arm64": "d4c2411e406af96ceae12b11e77fdb0c684ca15a68bfd8b4f9c6fe1fbdf515a7", + "win-x64": "1493c31552e6f90a59800b2d44669e01fc4551d3647f3cea5dd105a9f6bc73e5", +} +PASLS_VERSION = DEFAULT_PASLS_VERSION + + +def _pasls_sha(version: str, platform_key: str) -> str | None: + if version == INITIAL_PASLS_VERSION: + return INITIAL_PASLS_SHA256_BY_PLATFORM.get(platform_key) + if version == DEFAULT_PASLS_VERSION: + return DEFAULT_PASLS_SHA256_BY_PLATFORM.get(platform_key) + return None class PascalLanguageServer(SolidLanguageServer): @@ -629,7 +656,12 @@ class PascalLanguageServer(SolidLanguageServer): log.info(f"Found pasls in PATH: {pasls_in_path}") return quote_windows_path(pasls_in_path) - pasls_dir = cls.ls_resources_dir(solidlsp_settings) + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + pasls_dir = ( + cls.ls_resources_dir(solidlsp_settings) + if pasls_version == INITIAL_PASLS_VERSION + else os.path.join(cls.ls_resources_dir(solidlsp_settings), f"pasls-{pasls_version}") + ) os.makedirs(pasls_dir, exist_ok=True) # Clean up old files from previous sessions @@ -647,7 +679,7 @@ class PascalLanguageServer(SolidLanguageServer): platform_id="linux-x64", archive_type="gztar", binary_name="pasls", - sha256="517259395b0a385a5e848cf48b967645a984be3dd456118bc08771283a822a5b", + sha256=_pasls_sha(pasls_version, "linux-x64"), ), RuntimeDependency( id="PascalLanguageServer", @@ -656,7 +688,7 @@ class PascalLanguageServer(SolidLanguageServer): platform_id="linux-arm64", archive_type="gztar", binary_name="pasls", - sha256="cb4986941cfdcf9cb74ece6bbb53a443390a908e880108a37f4ccf82b2d6c502", + sha256=_pasls_sha(pasls_version, "linux-arm64"), ), RuntimeDependency( id="PascalLanguageServer", @@ -665,7 +697,7 @@ class PascalLanguageServer(SolidLanguageServer): platform_id="osx-x64", archive_type="zip", binary_name="pasls", - sha256="0abfcd98f63f77dba74094339a40d4407b69317c1c77b13a26b9b7dbdfd885f1", + sha256=_pasls_sha(pasls_version, "osx-x64"), ), RuntimeDependency( id="PascalLanguageServer", @@ -674,7 +706,7 @@ class PascalLanguageServer(SolidLanguageServer): platform_id="osx-arm64", archive_type="zip", binary_name="pasls", - sha256="d4c2411e406af96ceae12b11e77fdb0c684ca15a68bfd8b4f9c6fe1fbdf515a7", + sha256=_pasls_sha(pasls_version, "osx-arm64"), ), RuntimeDependency( id="PascalLanguageServer", @@ -683,7 +715,7 @@ class PascalLanguageServer(SolidLanguageServer): platform_id="win-x64", archive_type="zip", binary_name="pasls.exe", - sha256="1493c31552e6f90a59800b2d44669e01fc4551d3647f3cea5dd105a9f6bc73e5", + sha256=_pasls_sha(pasls_version, "win-x64"), ), ] ) @@ -777,6 +809,14 @@ class PascalLanguageServer(SolidLanguageServer): if value: initialization_options[var] = value + initialization_options.update( + { + "checkSyntax": True, + "publishDiagnostics": True, + "showSyntaxErrors": True, + } + ) + initialize_params = { "locale": "en", "capabilities": { @@ -831,6 +871,7 @@ class PascalLanguageServer(SolidLanguageServer): }, "formatting": {"dynamicRegistration": True}, "rangeFormatting": {"dynamicRegistration": True}, + "publishDiagnostics": {"relatedInformation": True}, }, "workspace": { "workspaceFolders": True, diff --git a/src/solidlsp/language_servers/phpactor.py b/src/solidlsp/language_servers/phpactor.py index 80805c0d..181cb3ee 100644 --- a/src/solidlsp/language_servers/phpactor.py +++ b/src/solidlsp/language_servers/phpactor.py @@ -20,11 +20,24 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) -PHPACTOR_VERSION = "2025.12.21.1" -PHPACTOR_PHAR_URL = f"https://github.com/phpactor/phpactor/releases/download/{PHPACTOR_VERSION}/phpactor.phar" -PHPACTOR_PHAR_SHA256 = "53bbe9625cd9b5e9b394bc2f595fbad13dbbe6dfc96950c56dea3b5d9a246cc3" PHPACTOR_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_PHPACTOR_VERSION = "2025.12.21.1" +INITIAL_PHPACTOR_PHAR_SHA256 = "53bbe9625cd9b5e9b394bc2f595fbad13dbbe6dfc96950c56dea3b5d9a246cc3" +DEFAULT_PHPACTOR_VERSION = "2025.12.21.1" +DEFAULT_PHPACTOR_PHAR_SHA256 = "53bbe9625cd9b5e9b394bc2f595fbad13dbbe6dfc96950c56dea3b5d9a246cc3" + + +def _phpactor_sha(version: str) -> str | None: + if version == INITIAL_PHPACTOR_VERSION: + return INITIAL_PHPACTOR_PHAR_SHA256 + if version == DEFAULT_PHPACTOR_VERSION: + return DEFAULT_PHPACTOR_PHAR_SHA256 + return None + class PhpactorServer(SolidLanguageServer): """ @@ -48,7 +61,7 @@ class PhpactorServer(SolidLanguageServer): """ Setup runtime dependencies for Phpactor and return the path to the PHAR file. """ - phpactor_version = self._custom_settings.get("phpactor_version", PHPACTOR_VERSION) + phpactor_version = self._custom_settings.get("phpactor_version", DEFAULT_PHPACTOR_VERSION) phpactor_phar_url = f"https://github.com/phpactor/phpactor/releases/download/{phpactor_version}/phpactor.phar" # Verify PHP is installed php_path = shutil.which("php") @@ -68,15 +81,20 @@ class PhpactorServer(SolidLanguageServer): else: log.warning("Could not parse PHP version from output. Continuing anyway.") - phpactor_phar_path = os.path.join(self._ls_resources_dir, "phpactor.phar") + # legacy unversioned phar at root reserved for INITIAL; every other version goes into a versioned subdir + if phpactor_version == INITIAL_PHPACTOR_VERSION: + phar_dir = self._ls_resources_dir + else: + phar_dir = os.path.join(self._ls_resources_dir, f"phpactor-{phpactor_version}") + phpactor_phar_path = os.path.join(phar_dir, "phpactor.phar") if not os.path.exists(phpactor_phar_path): - os.makedirs(self._ls_resources_dir, exist_ok=True) + os.makedirs(phar_dir, exist_ok=True) log.info(f"Downloading phpactor PHAR from {phpactor_phar_url}") FileUtils.download_and_extract_archive_verified( phpactor_phar_url, phpactor_phar_path, "binary", - expected_sha256=PHPACTOR_PHAR_SHA256 if phpactor_version == PHPACTOR_VERSION else None, + expected_sha256=_phpactor_sha(phpactor_version), allowed_hosts=PHPACTOR_ALLOWED_HOSTS, ) diff --git a/src/solidlsp/language_servers/powershell_language_server.py b/src/solidlsp/language_servers/powershell_language_server.py index 72f9ac27..70a06d1a 100644 --- a/src/solidlsp/language_servers/powershell_language_server.py +++ b/src/solidlsp/language_servers/powershell_language_server.py @@ -5,6 +5,8 @@ Contains various configurations and settings specific to PowerShell scripting. You can pass the following entries in ``ls_specific_settings["powershell"]``: - pses_version: Override the pinned PowerShell Editor Services version downloaded by Serena (default: the bundled Serena version). + - psscriptanalyzer_version: Override the pinned PSScriptAnalyzer version + saved into the bundled PowerShell Editor Services module path. """ import logging @@ -12,6 +14,7 @@ import os import pathlib import platform import shutil +import subprocess import tempfile import threading from collections.abc import Hashable @@ -19,7 +22,8 @@ from pathlib import Path from overrides import override -from solidlsp.ls import RawDocumentSymbol, SolidLanguageServer +from solidlsp import ls_types +from solidlsp.ls import LSPConstants, RawDocumentSymbol, SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_types import SymbolKind from solidlsp.ls_utils import FileUtils @@ -30,9 +34,32 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) # PowerShell Editor Services version to download -PSES_VERSION = "4.4.0" -PSES_SHA256 = "690b91092989a0f66e6f43986166aaef69d64b559a9fda51feed882e1103fbcc" +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_PSES_VERSION = "4.4.0" +INITIAL_PSES_SHA256 = "690b91092989a0f66e6f43986166aaef69d64b559a9fda51feed882e1103fbcc" +DEFAULT_PSES_VERSION = "4.4.0" +DEFAULT_PSES_SHA256 = "690b91092989a0f66e6f43986166aaef69d64b559a9fda51feed882e1103fbcc" + + +def _pses_sha(version: str) -> str | None: + if version == INITIAL_PSES_VERSION: + return INITIAL_PSES_SHA256 + if version == DEFAULT_PSES_VERSION: + return DEFAULT_PSES_SHA256 + return None + + +def _pses_install_dir(ls_resources_dir: str, version: str) -> Path: + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + if version == INITIAL_PSES_VERSION: + return Path(ls_resources_dir) / "powershell" + return Path(ls_resources_dir) / f"powershell-{version}" + + PSES_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") +PSSCRIPTANALYZER_VERSION = "1.25.0" class PowerShellLanguageServer(SolidLanguageServer): @@ -94,7 +121,9 @@ class PowerShellLanguageServer(SolidLanguageServer): @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" + ps_settings = solidlsp_settings.get_ls_specific_settings(Language.POWERSHELL) + pses_version = ps_settings.get("pses_version", DEFAULT_PSES_VERSION) + install_dir = _pses_install_dir(cls.ls_resources_dir(solidlsp_settings), pses_version) start_script = install_dir / "PowerShellEditorServices" / "Start-EditorServices.ps1" if start_script.exists(): @@ -106,13 +135,13 @@ class PowerShellLanguageServer(SolidLanguageServer): def _download_pses(cls, solidlsp_settings: SolidLSPSettings) -> str: """Download and install PowerShell Editor Services.""" ps_settings = solidlsp_settings.get_ls_specific_settings(Language.POWERSHELL) - pses_version = ps_settings.get("pses_version", PSES_VERSION) + pses_version = ps_settings.get("pses_version", DEFAULT_PSES_VERSION) 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" + # Create installation directory; legacy unversioned dir reserved for INITIAL only + install_dir = _pses_install_dir(cls.ls_resources_dir(solidlsp_settings), pses_version) install_dir.mkdir(parents=True, exist_ok=True) log.info(f"Downloading PowerShell Editor Services from {download_url}...") @@ -120,7 +149,7 @@ class PowerShellLanguageServer(SolidLanguageServer): download_url, str(install_dir), "zip", - expected_sha256=PSES_SHA256 if pses_version == PSES_VERSION else None, + expected_sha256=_pses_sha(pses_version), allowed_hosts=PSES_ALLOWED_HOSTS, ) @@ -157,6 +186,29 @@ class PowerShellLanguageServer(SolidLanguageServer): # The bundled modules path is the directory containing PowerShellEditorServices bundled_modules_path = str(Path(pses_path).parent) + psscriptanalyzer_version = solidlsp_settings.get_ls_specific_settings(Language.POWERSHELL).get( + "psscriptanalyzer_version", PSSCRIPTANALYZER_VERSION + ) + psscriptanalyzer_path = Path(bundled_modules_path) / "PSScriptAnalyzer" / psscriptanalyzer_version + if not psscriptanalyzer_path.exists(): + log.info(f"PSScriptAnalyzer {psscriptanalyzer_version} not found. Installing...") + subprocess.run( + [ + pwsh_path, + "-NoLogo", + "-NoProfile", + "-Command", + ( + "Save-Module " + "-Name PSScriptAnalyzer " + f"-RequiredVersion '{psscriptanalyzer_version}' " + f"-Path '{bundled_modules_path}' " + "-Force " + "-ErrorAction Stop" + ), + ], + check=True, + ) return pwsh_path, pses_path, bundled_modules_path @@ -345,3 +397,35 @@ class PowerShellLanguageServer(SolidLanguageServer): self.server_ready.set() else: log.info("PowerShell Editor Services initialization complete") + + @override + def request_text_document_diagnostics( + self, + relative_file_path: str, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + ) -> list[ls_types.Diagnostic]: + uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) + published_uri = self._get_published_diagnostics_uri(uri) + diagnostics_before_request = self._get_published_diagnostics_generation(published_uri) + + with self.open_file(relative_file_path): + self.server.notify.did_save_text_document( + { + LSPConstants.TEXT_DOCUMENT: { # type: ignore + LSPConstants.URI: uri, + } + } + ) + diagnostics = self._wait_for_relevant_published_diagnostics( + uri=published_uri, + after_generation=diagnostics_before_request, + timeout=self._get_published_diagnostics_wait_timeout(True), + allow_cached=True, + ) + + if diagnostics is None: + return [] + + return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity) diff --git a/src/solidlsp/language_servers/pyright_server.py b/src/solidlsp/language_servers/pyright_server.py index efeefdaa..7f774836 100644 --- a/src/solidlsp/language_servers/pyright_server.py +++ b/src/solidlsp/language_servers/pyright_server.py @@ -155,6 +155,52 @@ class PyrightServer(SolidLanguageServer): self.found_source_files = True self.analysis_complete.set() + def handle_pyright_progress_notification(progress_kind: str, params: object | None) -> None: + """Tracks Pyright-specific progress notifications. + + Pyright can emit custom progress notifications instead of only using + ``$/progress``. Handling them avoids noisy unhandled-method warnings + and provides an additional signal that initial analysis has quiesced. + """ + # normalizing the notification payload + message_text = "" + percentage: object | None = None + if isinstance(params, dict): + raw_message = params.get("message") + message_text = "" if raw_message is None else str(raw_message) + percentage = params.get("percentage") + elif params is not None: + message_text = str(params) + + progress_label = f"{message_text} ({percentage}%)" if percentage is not None else message_text + + # logging the progress transition + if progress_kind == "begin": + log.info("Pyright progress started: %s", progress_label) + return + + if progress_kind == "report": + log.debug("Pyright progress update: %s", progress_label) + return + + log.info("Pyright progress finished: %s", progress_label) + self.analysis_complete.set() + + def pyright_begin_progress(params: object | None) -> None: + """Handles the ``pyright/beginProgress`` notification.""" + # delegating to the shared progress handler + handle_pyright_progress_notification("begin", params) + + def pyright_report_progress(params: object | None) -> None: + """Handles the ``pyright/reportProgress`` notification.""" + # delegating to the shared progress handler + handle_pyright_progress_notification("report", params) + + def pyright_end_progress(params: object | None) -> None: + """Handles the ``pyright/endProgress`` notification.""" + # delegating to the shared progress handler + handle_pyright_progress_notification("end", params) + def check_experimental_status(params: dict) -> None: """ Also listen for experimental/serverStatus as a backup signal @@ -170,6 +216,9 @@ class PyrightServer(SolidLanguageServer): 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("pyright/beginProgress", pyright_begin_progress) + self.server.on_notification("pyright/reportProgress", pyright_report_progress) + self.server.on_notification("pyright/endProgress", pyright_end_progress) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_notification("language/actionableNotification", do_nothing) self.server.on_notification("experimental/serverStatus", check_experimental_status) diff --git a/src/solidlsp/language_servers/rust_analyzer.py b/src/solidlsp/language_servers/rust_analyzer.py index f0ff00e6..2e222b5a 100644 --- a/src/solidlsp/language_servers/rust_analyzer.py +++ b/src/solidlsp/language_servers/rust_analyzer.py @@ -26,6 +26,10 @@ class RustAnalyzer(SolidLanguageServer): Provides Rust specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Rust. """ + @classmethod + def supports_implementation_request(cls) -> bool: + return True + @staticmethod def _determine_log_level(line: str) -> int: """Classify rust-analyzer stderr output to avoid false-positive errors.""" diff --git a/src/solidlsp/language_servers/solidity_language_server.py b/src/solidlsp/language_servers/solidity_language_server.py index 223a6683..d0c34053 100644 --- a/src/solidlsp/language_servers/solidity_language_server.py +++ b/src/solidlsp/language_servers/solidity_language_server.py @@ -7,19 +7,31 @@ import glob import logging import os import pathlib +import platform import shutil import threading 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, build_npm_install_command -from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, LSPConstants, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_SOLIDITY_LANGUAGE_SERVER_VERSION = "0.8.4" +DEFAULT_SOLIDITY_LANGUAGE_SERVER_VERSION = "0.8.4" +INITIAL_FORGE_VERSION = "1.5.1" +DEFAULT_FORGE_VERSION = "1.5.1" + class SolidityLanguageServer(SolidLanguageServer): """ @@ -69,7 +81,10 @@ class SolidityLanguageServer(SolidLanguageServer): assert is_node_installed, "node is not installed or isn't in PATH. Please install Node.js 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." - solidity_language_server_version = self._custom_settings.get("solidity_language_server_version", "0.8.4") + solidity_language_server_version = self._custom_settings.get( + "solidity_language_server_version", DEFAULT_SOLIDITY_LANGUAGE_SERVER_VERSION + ) + forge_version = self._custom_settings.get("forge_version", DEFAULT_FORGE_VERSION) npm_registry = self._custom_settings.get("npm_registry") deps = RuntimeDependencyCollection( @@ -84,17 +99,29 @@ class SolidityLanguageServer(SolidLanguageServer): ), platform_id="any", ), + RuntimeDependency( + id="forge", + description="Foundry forge CLI", + # the @foundry-rs/forge meta-package's postinstall uses POSIX env-var + # syntax that fails on Windows cmd.exe, so we install the platform-specific + # subpackage directly to bypass it + command=build_npm_install_command(self._get_forge_npm_package(), forge_version, npm_registry), + platform_id="any", + ), ] ) - solidity_ls_dir = os.path.join(self._ls_resources_dir, "solidity-lsp") - solidity_executable_path = os.path.join(solidity_ls_dir, "node_modules", ".bin", "nomicfoundation-solidity-language-server") + solidity_ls_dir = self._resolve_solidity_ls_dir(solidity_language_server_version, forge_version) + managed_bin_dir = os.path.join(solidity_ls_dir, "node_modules", ".bin") + solidity_executable_path = os.path.join(managed_bin_dir, "nomicfoundation-solidity-language-server") + forge_executable_path = os.path.join(managed_bin_dir, "forge") if os.name == "nt": solidity_executable_path += ".cmd" + forge_executable_path += ".cmd" - if not os.path.exists(solidity_executable_path): - log.info(f"Solidity Language Server executable not found at {solidity_executable_path}. Installing...") + if not os.path.exists(solidity_executable_path) or not os.path.exists(forge_executable_path): + log.info("Solidity Language Server dependencies missing. Installing...") deps.install(solidity_ls_dir) log.info("Solidity language server dependencies installed successfully.") @@ -106,6 +133,45 @@ class SolidityLanguageServer(SolidLanguageServer): return solidity_executable_path + @staticmethod + def _get_forge_npm_package() -> str: + """ + Returns the platform-specific @foundry-rs forge npm subpackage name. Installing + the meta-package fails on Windows due to a POSIX-only postinstall script. + """ + machine = platform.machine().lower() + if machine in ("x86_64", "amd64"): + arch = "amd64" + elif machine in ("arm64", "aarch64"): + arch = "arm64" + else: + raise RuntimeError(f"Unsupported architecture for foundry forge: {machine}") + + if os.name == "nt": + if arch != "amd64": + raise RuntimeError(f"foundry forge does not publish a Windows {arch} npm package") + return "@foundry-rs/forge-win32-amd64" + if platform.system() == "Darwin": + return f"@foundry-rs/forge-darwin-{arch}" + return f"@foundry-rs/forge-linux-{arch}" + + def create_launch_command_env(self) -> dict[str, str]: + solidity_language_server_version = self._custom_settings.get( + "solidity_language_server_version", DEFAULT_SOLIDITY_LANGUAGE_SERVER_VERSION + ) + forge_version = self._custom_settings.get("forge_version", DEFAULT_FORGE_VERSION) + solidity_ls_dir = self._resolve_solidity_ls_dir(solidity_language_server_version, forge_version) + managed_bin_dir = os.path.join(solidity_ls_dir, "node_modules", ".bin") + return {"PATH": managed_bin_dir + os.pathsep + os.environ.get("PATH", "")} + + def _resolve_solidity_ls_dir(self, solidity_language_server_version: str, forge_version: str) -> str: + # legacy unversioned dir reserved for INITIAL pair; any other combination goes into a versioned subdir + is_initial = ( + solidity_language_server_version == INITIAL_SOLIDITY_LANGUAGE_SERVER_VERSION and forge_version == INITIAL_FORGE_VERSION + ) + ls_dirname = "solidity-lsp" if is_initial else f"solidity-lsp-{solidity_language_server_version}-{forge_version}" + return os.path.join(self._ls_resources_dir, ls_dirname) + def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "--stdio"] @@ -148,6 +214,7 @@ class SolidityLanguageServer(SolidLanguageServer): "dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"], # type: ignore[list-item] }, + "publishDiagnostics": {"relatedInformation": True}, }, "workspace": { "workspaceFolders": True, @@ -224,3 +291,83 @@ class SolidityLanguageServer(SolidLanguageServer): log.info("No .sol files found; skipping indexing wait") log.info("Solidity language server initialization complete") + + @override + def request_text_document_diagnostics( + self, + relative_file_path: str, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + ) -> list[ls_types.Diagnostic]: + self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) + uri = self._get_validation_document_uri(relative_file_path) + diagnostics_before_request = self._get_published_diagnostics_generation(uri) + + absolute_path = pathlib.Path(self.repository_root_path, relative_file_path) + contents = absolute_path.read_text(encoding=self._encoding) + lines = contents.split("\n") + end_position = { + "line": len(lines) - 1, + "character": len(lines[-1]), + } + + self.server.notify.did_open_text_document( + { + LSPConstants.TEXT_DOCUMENT: { # type: ignore + LSPConstants.URI: uri, + LSPConstants.LANGUAGE_ID: self.language_id, + LSPConstants.VERSION: 0, + LSPConstants.TEXT: contents, + } + } + ) + try: + self.server.notify.did_change_text_document( + { + LSPConstants.TEXT_DOCUMENT: { # type: ignore + LSPConstants.URI: uri, + LSPConstants.VERSION: 1, + }, + LSPConstants.CONTENT_CHANGES: [ # type: ignore + { + LSPConstants.RANGE: { + "start": {"line": 0, "character": 0}, + "end": end_position, + }, + "text": contents, + } + ], + } + ) + diagnostics = self._wait_for_relevant_published_diagnostics( + uri=uri, + after_generation=diagnostics_before_request, + timeout=self._get_published_diagnostics_wait_timeout(True), + allow_cached=True, + ) + finally: + self.server.notify.did_close_text_document( + { + LSPConstants.TEXT_DOCUMENT: { # type: ignore + LSPConstants.URI: uri, + } + } + ) + + if diagnostics is None: + return [] + + return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity) + + def _get_validation_document_uri(self, relative_file_path: str) -> str: + absolute_path = pathlib.Path(self.repository_root_path, relative_file_path) + + if os.name != "nt": + return absolute_path.as_uri() + + path = absolute_path.as_posix() + if len(path) >= 2 and path[1] == ":": + path = f"{path[0].lower()}%3A{path[2:]}" + + return f"file:///{path.lstrip('/')}" diff --git a/src/solidlsp/language_servers/taplo_server.py b/src/solidlsp/language_servers/taplo_server.py index 410edde1..ee9be952 100644 --- a/src/solidlsp/language_servers/taplo_server.py +++ b/src/solidlsp/language_servers/taplo_server.py @@ -24,15 +24,24 @@ 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}" TAPLO_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") -# 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] = { +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +# To update: download each release file and run: sha256sum , copy values into DEFAULT_*. +INITIAL_TAPLO_VERSION = "0.10.0" +INITIAL_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", +} +DEFAULT_TAPLO_VERSION = "0.10.0" +DEFAULT_TAPLO_SHA256_CHECKSUMS: dict[str, str] = { "taplo-windows-x86_64.zip": "1615eed140039bd58e7089109883b1c434de5d6de8f64a993e6e8c80ca57bdf9", "taplo-windows-x86.zip": "b825701daab10dcfc0251e6d668cd1a9c0e351e7f6762dd20844c3f3f3553aa0", "taplo-darwin-x86_64.gz": "898122cde3a0b1cd1cbc2d52d3624f23338218c91b5ddb71518236a4c2c10ef2", @@ -43,7 +52,15 @@ TAPLO_SHA256_CHECKSUMS: dict[str, str] = { } -def _get_taplo_download_url(version: str = TAPLO_VERSION) -> tuple[str, str]: +def _taplo_sha(version: str, archive_filename: str) -> str | None: + if version == INITIAL_TAPLO_VERSION: + return INITIAL_TAPLO_SHA256_CHECKSUMS.get(archive_filename) + if version == DEFAULT_TAPLO_VERSION: + return DEFAULT_TAPLO_SHA256_CHECKSUMS.get(archive_filename) + return None + + +def _get_taplo_download_url(version: str = DEFAULT_TAPLO_VERSION) -> tuple[str, str]: """ Get the appropriate Taplo download URL for the current platform. @@ -130,11 +147,11 @@ class TaploServer(SolidLanguageServer): log.info(f"Using system-installed Taplo at: {system_taplo}") return system_taplo - # Setup local installation directory - taplo_dir = os.path.join(self._ls_resources_dir, "taplo") + # Setup local installation directory; legacy unversioned dir reserved for INITIAL only + taplo_version = self._custom_settings.get("taplo_version", DEFAULT_TAPLO_VERSION) + ls_dirname = "taplo" if taplo_version == INITIAL_TAPLO_VERSION else f"taplo-{taplo_version}" + taplo_dir = os.path.join(self._ls_resources_dir, ls_dirname) os.makedirs(taplo_dir, exist_ok=True) - - taplo_version = self._custom_settings.get("taplo_version", TAPLO_VERSION) _, executable_name = _get_taplo_download_url(taplo_version) taplo_executable = os.path.join(taplo_dir, executable_name) @@ -158,12 +175,13 @@ class TaploServer(SolidLanguageServer): return [core_path, "lsp", "stdio"] @classmethod - def _download_taplo(cls, install_dir: str, executable_path: str, version: str = TAPLO_VERSION) -> None: + def _download_taplo(cls, install_dir: str, executable_path: str, version: str = DEFAULT_TAPLO_VERSION) -> None: """Download and extract Taplo binary using the shared verified download helper.""" download_url, _ = _get_taplo_download_url(version) archive_filename = os.path.basename(download_url) - expected_hash = TAPLO_SHA256_CHECKSUMS.get(archive_filename) if version == TAPLO_VERSION else None - if expected_hash is None and version == TAPLO_VERSION: + # only verify the SHA when the resolved version is one of our pinned ones (INITIAL or current DEFAULT) + expected_hash = _taplo_sha(version, archive_filename) + if expected_hash is None and version in (INITIAL_TAPLO_VERSION, DEFAULT_TAPLO_VERSION): raise RuntimeError(f"No SHA256 checksum configured for Taplo archive: {archive_filename}") try: diff --git a/src/solidlsp/language_servers/terraform_ls.py b/src/solidlsp/language_servers/terraform_ls.py index ec26126d..c654fd95 100644 --- a/src/solidlsp/language_servers/terraform_ls.py +++ b/src/solidlsp/language_servers/terraform_ls.py @@ -18,6 +18,34 @@ log = logging.getLogger(__name__) TERRAFORM_LS_ALLOWED_HOSTS = ("releases.hashicorp.com",) +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_TERRAFORM_LS_VERSION = "0.36.5" +INITIAL_TERRAFORM_LS_SHA256_BY_PLATFORM = { + "osx-arm64": "fee8743aa71fe2d8b0b9b91283b844cfa57d58457306a62e53a8f38d143cec8c", + "osx-x64": "17c5c480f8eec7e528292565f1c05d5097a41edf7ef8ee2a9f3a18d288a1415a", + "linux-arm64": "724f45029f32d02d88b1952c7d1526c59fc8cd5dae49e31b9fed676a83f6cae7", + "linux-x64": "37e645cc54fd03e863157e2a3e773e7a5ff1d6cb3d045e4c20860cac1f550a44", + "win-x64": "a9223462cac9e1c0e6ba33043fbf9fb4483609b6970b5681a6306b04366698ec", +} +DEFAULT_TERRAFORM_LS_VERSION = "0.36.5" +DEFAULT_TERRAFORM_LS_SHA256_BY_PLATFORM = { + "osx-arm64": "fee8743aa71fe2d8b0b9b91283b844cfa57d58457306a62e53a8f38d143cec8c", + "osx-x64": "17c5c480f8eec7e528292565f1c05d5097a41edf7ef8ee2a9f3a18d288a1415a", + "linux-arm64": "724f45029f32d02d88b1952c7d1526c59fc8cd5dae49e31b9fed676a83f6cae7", + "linux-x64": "37e645cc54fd03e863157e2a3e773e7a5ff1d6cb3d045e4c20860cac1f550a44", + "win-x64": "a9223462cac9e1c0e6ba33043fbf9fb4483609b6970b5681a6306b04366698ec", +} + + +def _terraform_ls_sha(version: str, platform_key: str) -> str | None: + if version == INITIAL_TERRAFORM_LS_VERSION: + return INITIAL_TERRAFORM_LS_SHA256_BY_PLATFORM[platform_key] + if version == DEFAULT_TERRAFORM_LS_VERSION: + return DEFAULT_TERRAFORM_LS_SHA256_BY_PLATFORM[platform_key] + return None + class TerraformLS(SolidLanguageServer): """ @@ -99,7 +127,7 @@ class TerraformLS(SolidLanguageServer): """ cls._ensure_tf_command_available() terraform_settings = solidlsp_settings.get_ls_specific_settings(Language.TERRAFORM) - terraform_ls_version = terraform_settings.get("terraform_ls_version", "0.36.5") + terraform_ls_version = terraform_settings.get("terraform_ls_version", DEFAULT_TERRAFORM_LS_VERSION) platform_id = PlatformUtils.get_platform_id() deps = RuntimeDependencyCollection( [ @@ -110,7 +138,7 @@ class TerraformLS(SolidLanguageServer): platform_id="osx-arm64", archive_type="zip", binary_name="terraform-ls", - sha256="fee8743aa71fe2d8b0b9b91283b844cfa57d58457306a62e53a8f38d143cec8c" if terraform_ls_version == "0.36.5" else None, + sha256=_terraform_ls_sha(terraform_ls_version, "osx-arm64"), allowed_hosts=TERRAFORM_LS_ALLOWED_HOSTS, ), RuntimeDependency( @@ -120,7 +148,7 @@ class TerraformLS(SolidLanguageServer): platform_id="osx-x64", archive_type="zip", binary_name="terraform-ls", - sha256="17c5c480f8eec7e528292565f1c05d5097a41edf7ef8ee2a9f3a18d288a1415a" if terraform_ls_version == "0.36.5" else None, + sha256=_terraform_ls_sha(terraform_ls_version, "osx-x64"), allowed_hosts=TERRAFORM_LS_ALLOWED_HOSTS, ), RuntimeDependency( @@ -130,7 +158,7 @@ class TerraformLS(SolidLanguageServer): platform_id="linux-arm64", archive_type="zip", binary_name="terraform-ls", - sha256="724f45029f32d02d88b1952c7d1526c59fc8cd5dae49e31b9fed676a83f6cae7" if terraform_ls_version == "0.36.5" else None, + sha256=_terraform_ls_sha(terraform_ls_version, "linux-arm64"), allowed_hosts=TERRAFORM_LS_ALLOWED_HOSTS, ), RuntimeDependency( @@ -140,7 +168,7 @@ class TerraformLS(SolidLanguageServer): platform_id="linux-x64", archive_type="zip", binary_name="terraform-ls", - sha256="37e645cc54fd03e863157e2a3e773e7a5ff1d6cb3d045e4c20860cac1f550a44" if terraform_ls_version == "0.36.5" else None, + sha256=_terraform_ls_sha(terraform_ls_version, "linux-x64"), allowed_hosts=TERRAFORM_LS_ALLOWED_HOSTS, ), RuntimeDependency( @@ -150,17 +178,23 @@ class TerraformLS(SolidLanguageServer): platform_id="win-x64", archive_type="zip", binary_name="terraform-ls.exe", - sha256="a9223462cac9e1c0e6ba33043fbf9fb4483609b6970b5681a6306b04366698ec" if terraform_ls_version == "0.36.5" else None, + sha256=_terraform_ls_sha(terraform_ls_version, "win-x64"), allowed_hosts=TERRAFORM_LS_ALLOWED_HOSTS, ), ] ) dependency = deps.get_single_dep_for_current_platform() - terraform_ls_executable_path = deps.binary_path(cls.ls_resources_dir(solidlsp_settings)) + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + install_dir = ( + cls.ls_resources_dir(solidlsp_settings) + if terraform_ls_version == INITIAL_TERRAFORM_LS_VERSION + else os.path.join(cls.ls_resources_dir(solidlsp_settings), f"terraform-ls-{terraform_ls_version}") + ) + terraform_ls_executable_path = deps.binary_path(install_dir) if not os.path.exists(terraform_ls_executable_path): log.info(f"Downloading terraform-ls from {dependency.url}") - deps.install(cls.ls_resources_dir(solidlsp_settings)) + deps.install(install_dir) assert os.path.exists(terraform_ls_executable_path), f"terraform-ls executable not found at {terraform_ls_executable_path}" diff --git a/src/solidlsp/language_servers/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server.py index e2805b63..731ad835 100644 --- a/src/solidlsp/language_servers/typescript_language_server.py +++ b/src/solidlsp/language_servers/typescript_language_server.py @@ -23,6 +23,14 @@ from .common import RuntimeDependency, RuntimeDependencyCollection, build_npm_in log = logging.getLogger(__name__) +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_TYPESCRIPT_VERSION = "5.9.3" +DEFAULT_TYPESCRIPT_VERSION = "5.9.3" +INITIAL_TYPESCRIPT_LANGUAGE_SERVER_VERSION = "5.1.3" +DEFAULT_TYPESCRIPT_LANGUAGE_SERVER_VERSION = "5.1.3" + # Platform-specific imports if os.name != "nt": # Unix-like systems import pwd @@ -67,6 +75,10 @@ class TypeScriptLanguageServer(SolidLanguageServer): - typescript_language_server_version: Version of typescript-language-server to install (default: "5.1.3") """ + @classmethod + def supports_implementation_request(cls) -> bool: + return True + # Safety timeout for $/progress-based indexing wait. Normally the event fires # well within this window; the timeout is only hit if the server never sends progress. INDEXING_PROGRESS_TIMEOUT = 15.0 if os.name == "nt" else 10.0 @@ -85,7 +97,10 @@ class TypeScriptLanguageServer(SolidLanguageServer): self.server_ready = threading.Event() self.initialize_searcher_command_available = threading.Event() - # Progress tracking for $/progress notifications (project indexing, etc.) + # tracking asynchronous diagnostics publication + self._published_diagnostics_timeout = 5.0 + + # tracking project indexing progress self._progress_lock = threading.Lock() self._active_progress_tokens: set[str] = set() self._indexing_complete = threading.Event() @@ -147,8 +162,10 @@ class TypeScriptLanguageServer(SolidLanguageServer): # Get version settings from ls_specific_settings or use defaults language_specific_config = self._custom_settings - 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") + typescript_version = language_specific_config.get("typescript_version", DEFAULT_TYPESCRIPT_VERSION) + typescript_language_server_version = language_specific_config.get( + "typescript_language_server_version", DEFAULT_TYPESCRIPT_LANGUAGE_SERVER_VERSION + ) npm_registry = language_specific_config.get("npm_registry") deps = RuntimeDependencyCollection( @@ -174,38 +191,19 @@ 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." - # Install typescript and typescript-language-server if not already installed or version mismatch - tsserver_ls_dir = os.path.join(self._ls_resources_dir, "ts-lsp") + # legacy unversioned dir reserved for INITIAL pair; any other version combination goes into a versioned subdir + is_initial = ( + typescript_version == INITIAL_TYPESCRIPT_VERSION + and typescript_language_server_version == INITIAL_TYPESCRIPT_LANGUAGE_SERVER_VERSION + ) + ls_dirname = "ts-lsp" if is_initial else f"ts-lsp-{typescript_version}-{typescript_language_server_version}" + tsserver_ls_dir = os.path.join(self._ls_resources_dir, ls_dirname) tsserver_executable_path = os.path.join(tsserver_ls_dir, "node_modules", ".bin", "typescript-language-server") - # Check if installation is needed based on executable AND version - version_file = os.path.join(tsserver_ls_dir, ".installed_version") - expected_version = f"{typescript_version}_{typescript_language_server_version}" - - needs_install = False if not os.path.exists(tsserver_executable_path): - log.info(f"Typescript Language Server executable not found at {tsserver_executable_path}.") - 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...") + log.info(f"Typescript Language Server executable not found at {tsserver_executable_path}. Installing...") 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): @@ -217,6 +215,15 @@ class TypeScriptLanguageServer(SolidLanguageServer): def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "--stdio"] + def _get_language_id_for_file(self, relative_file_path: str) -> str: + # JSX is parsed as TS without this, which silently truncates symbol + # ranges at the first multi-line JSX expression. + if relative_file_path.endswith(".tsx"): + return "typescriptreact" + if relative_file_path.endswith(".jsx"): + return "javascriptreact" + return self.language_id + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the TypeScript Language Server. @@ -239,9 +246,11 @@ class TypeScriptLanguageServer(SolidLanguageServer): "signatureHelp": {"dynamicRegistration": True}, "codeAction": {"dynamicRegistration": True}, "rename": {"dynamicRegistration": True, "prepareSupport": True}, + "publishDiagnostics": {"relatedInformation": True}, }, "workspace": { "workspaceFolders": True, + "configuration": True, "didChangeConfiguration": {"dynamicRegistration": True}, "symbol": {"dynamicRegistration": True}, }, @@ -284,6 +293,10 @@ class TypeScriptLanguageServer(SolidLanguageServer): def execute_client_command_handler(params: dict) -> list: return [] + def configuration_handler(params: dict) -> list: + items = params.get("items", []) + return [{} for _ in items] + def do_nothing(params: dict) -> None: return @@ -343,6 +356,7 @@ class TypeScriptLanguageServer(SolidLanguageServer): self._indexing_complete.set() self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_request("workspace/configuration", configuration_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) self.server.on_request("window/workDoneProgress/create", work_done_progress_create) @@ -427,6 +441,20 @@ class TypeScriptLanguageServer(SolidLanguageServer): else: log.warning("Additional workspace indexing did not complete within timeout; proceeding anyway") + @override + def _get_published_diagnostics_uri(self, request_uri: str) -> str: + if os.name != "nt" or not request_uri.startswith("file:///"): + return request_uri + + path_part = request_uri[len("file:///") :] + if len(path_part) >= 2 and path_part[0].isalpha() and path_part[1] == ":": + return f"file:///{path_part[0].lower()}%3A{path_part[2:]}" + return request_uri + + @override + def _get_published_diagnostics_wait_timeout(self, pull_diagnostics_failed: bool) -> float: + return self._published_diagnostics_timeout + @override def _get_wait_time_for_cross_file_referencing(self) -> float: return 2 diff --git a/src/solidlsp/language_servers/vts_language_server.py b/src/solidlsp/language_servers/vts_language_server.py index dc6db249..2464338a 100644 --- a/src/solidlsp/language_servers/vts_language_server.py +++ b/src/solidlsp/language_servers/vts_language_server.py @@ -24,6 +24,12 @@ from .common import RuntimeDependency, RuntimeDependencyCollection, build_npm_in log = logging.getLogger(__name__) +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_VTSLS_VERSION = "0.2.9" +DEFAULT_VTSLS_VERSION = "0.2.9" + class VtsLanguageServer(SolidLanguageServer): """ @@ -73,7 +79,7 @@ class VtsLanguageServer(SolidLanguageServer): ] assert platform_id in valid_platforms, f"Platform {platform_id} is not supported for vtsls at the moment" vts_config = solidlsp_settings.get_ls_specific_settings(Language.TYPESCRIPT_VTS) - vtsls_version = vts_config.get("vtsls_version", "0.2.9") + vtsls_version = vts_config.get("vtsls_version", DEFAULT_VTSLS_VERSION) npm_registry = vts_config.get("npm_registry") deps = RuntimeDependencyCollection( @@ -86,7 +92,9 @@ class VtsLanguageServer(SolidLanguageServer): ), ] ) - vts_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "vts-lsp") + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + ls_dirname = "vts-lsp" if vtsls_version == INITIAL_VTSLS_VERSION else f"vts-lsp-{vtsls_version}" + vts_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), ls_dirname) vts_executable_path = os.path.join(vts_ls_dir, "vtsls") # Verify both node and npm are installed diff --git a/src/solidlsp/language_servers/vue_language_server.py b/src/solidlsp/language_servers/vue_language_server.py index 06123623..f99056ae 100644 --- a/src/solidlsp/language_servers/vue_language_server.py +++ b/src/solidlsp/language_servers/vue_language_server.py @@ -426,6 +426,18 @@ class VueLanguageServer(SolidLanguageServer): with self._ts_server.open_file(relative_file_path): return self._ts_server.request_rename_symbol_edit(relative_file_path, line, column, new_name) + @override + def request_text_document_diagnostics( + self, + relative_file_path: str, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + ) -> list[ls_types.Diagnostic]: + self._ensure_ls_operational() + assert self._ts_server is not None + return self._ts_server.request_text_document_diagnostics(relative_file_path, start_line, end_line, min_severity) + def _forward_edit_to_ts_server_if_needed(self, relative_file_path: str, edit_fn: Callable[[], object]) -> None: """ Calls ``edit_fn`` on the TypeScript server if the file is open there. @@ -598,6 +610,7 @@ class VueLanguageServer(SolidLanguageServer): "signatureHelp": {"dynamicRegistration": True}, "codeAction": {"dynamicRegistration": True}, "rename": {"dynamicRegistration": True, "prepareSupport": True}, + "publishDiagnostics": {"relatedInformation": True}, }, "workspace": { "workspaceFolders": True, diff --git a/src/solidlsp/language_servers/yaml_language_server.py b/src/solidlsp/language_servers/yaml_language_server.py index 8a471921..b52c760e 100644 --- a/src/solidlsp/language_servers/yaml_language_server.py +++ b/src/solidlsp/language_servers/yaml_language_server.py @@ -17,6 +17,12 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_YAML_LANGUAGE_SERVER_VERSION = "1.19.2" +DEFAULT_YAML_LANGUAGE_SERVER_VERSION = "1.19.2" + class YamlLanguageServer(SolidLanguageServer): """ @@ -66,7 +72,7 @@ class YamlLanguageServer(SolidLanguageServer): 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." - yaml_language_server_version = self._custom_settings.get("yaml_language_server_version", "1.19.2") + yaml_language_server_version = self._custom_settings.get("yaml_language_server_version", DEFAULT_YAML_LANGUAGE_SERVER_VERSION) npm_registry = self._custom_settings.get("npm_registry") deps = RuntimeDependencyCollection( @@ -80,8 +86,13 @@ class YamlLanguageServer(SolidLanguageServer): ] ) - # Install yaml-language-server if not already installed - yaml_ls_dir = os.path.join(self._ls_resources_dir, "yaml-lsp") + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + ls_dirname = ( + "yaml-lsp" + if yaml_language_server_version == INITIAL_YAML_LANGUAGE_SERVER_VERSION + else f"yaml-lsp-{yaml_language_server_version}" + ) + yaml_ls_dir = os.path.join(self._ls_resources_dir, ls_dirname) yaml_executable_path = os.path.join(yaml_ls_dir, "node_modules", ".bin", "yaml-language-server") # Handle Windows executable extension diff --git a/src/solidlsp/language_servers/zls.py b/src/solidlsp/language_servers/zls.py index 368f57eb..cb5652fc 100644 --- a/src/solidlsp/language_servers/zls.py +++ b/src/solidlsp/language_servers/zls.py @@ -132,6 +132,7 @@ class ZigLanguageServer(SolidLanguageServer): "dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"], }, + "publishDiagnostics": {"relatedInformation": True}, }, "workspace": { "workspaceFolders": True, diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index caa65829..6bab82aa 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -13,8 +13,8 @@ from collections.abc import Hashable, Iterator from contextlib import contextmanager from copy import copy from pathlib import Path, PurePath -from time import perf_counter, sleep -from typing import Self, Union, cast +from time import monotonic, perf_counter, sleep +from typing import Any, Self, Union, cast import pathspec from sensai.util.pickle import getstate, load_pickle @@ -487,10 +487,14 @@ class SolidLanguageServer(ABC): :param config: the global SolidLSP configuration. :param repository_root_path: the root path of the repository. - :param process_launch_info: (DEPRECATED - implement _create_dependency_provider instead) + :param process_launch_info: (DEPRECATED: pass None and implement _create_dependency_provider instead) 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 language_id: The language identifier which will be passed to the language server in the `textDocument/didOpen` + notification by default. + If the language server uses multiple language identifiers, it must override the method `get_language_id_for_file` + to provide the appropriate identifier for each type of file. :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 @@ -511,6 +515,10 @@ class SolidLanguageServer(ABC): self.language_id = language_id self.open_file_buffers: dict[str, LSPFileBuffer] = {} self.language = Language(language_id) + self._published_diagnostics: dict[str, list[ls_types.Diagnostic]] = {} + self._published_diagnostics_generation_by_uri: dict[str, int] = {} + self._published_diagnostics_generation = 0 + self._published_diagnostics_condition = threading.Condition() # initialise symbol caches self.cache_dir = Path(self._solidlsp_settings.project_data_path) / self.CACHE_FOLDER_NAME / self.language_id @@ -550,6 +558,7 @@ class SolidLanguageServer(ABC): logger=logging_fn, start_independent_lsp_process=config.start_independent_lsp_process, ) + self.server.on_any_notification(self._observe_server_notification) # Set up the pathspec matcher for the ignored paths # for all absolute paths in ignored_paths, convert them to relative paths @@ -590,6 +599,388 @@ class SolidLanguageServer(ABC): _seen.add(additional_workspace_abs_path) self._additional_workspace_abs_paths.append(additional_workspace_abs_path) + def _observe_server_notification(self, method: str, params: Any) -> None: + """ + Observe notifications sent by the language server. + + This is used for generic cross-language bookkeeping that must work independently of + language-specific notification handlers. + """ + if method == "textDocument/publishDiagnostics": + self._store_published_diagnostics(params) + + def _store_published_diagnostics(self, params: Any) -> None: + """ + Store diagnostics received through ``textDocument/publishDiagnostics``. + """ + if not isinstance(params, dict): + return + + uri = params.get("uri") + diagnostics = params.get("diagnostics") + if not isinstance(uri, str) or not isinstance(diagnostics, list): + return + + normalized_diagnostics: list[ls_types.Diagnostic] = [] + for diagnostic in diagnostics: + if not isinstance(diagnostic, dict): + continue + if "message" not in diagnostic or "range" not in diagnostic: + continue + + normalized_diagnostic: ls_types.Diagnostic = { + "uri": uri, + "message": diagnostic["message"], + "range": diagnostic["range"], + } + severity = diagnostic.get("severity") + if isinstance(severity, int): + normalized_diagnostic["severity"] = ls_types.DiagnosticSeverity(severity) + + code = diagnostic.get("code") + if isinstance(code, int | str): + normalized_diagnostic["code"] = code + + if "source" in diagnostic: + normalized_diagnostic["source"] = diagnostic["source"] + normalized_diagnostics.append(ls_types.Diagnostic(**normalized_diagnostic)) + + # canonicalize the key so lookups using URIs produced by pathlib.Path.as_uri() match + # what servers publish (e.g. file:///c%3A/... or file:///c:/... vs. file:///C:/...) + key = self._canonicalize_published_diagnostics_uri(uri) + + with self._published_diagnostics_condition: + self._published_diagnostics_generation += 1 + self._published_diagnostics[key] = normalized_diagnostics + self._published_diagnostics_generation_by_uri[key] = self._published_diagnostics_generation + self._published_diagnostics_condition.notify_all() + + @staticmethod + def _canonicalize_published_diagnostics_uri(uri: str) -> str: + """ + Canonicalizes a ``file://`` URI so that diagnostics published by language servers + and lookups based on ``pathlib.Path.as_uri()`` agree on the same key. + + On Windows, servers may publish under ``file:///c%3A/...`` or ``file:///c:/...`` while + ``pathlib.Path.as_uri()`` produces ``file:///C:/...``. The canonical form uses an + upper-case drive letter and a plain colon. + """ + if os.name != "nt" or not uri.startswith("file:///"): + return uri + + # extract the segment after "file:///" up to the next slash and look for a drive letter + prefix = "file:///" + rest = uri[len(prefix) :] + slash = rest.find("/") + head = rest if slash < 0 else rest[:slash] + tail = "" if slash < 0 else rest[slash:] + + if (len(head) >= 2 and head[0].isalpha() and head[1] == ":") or ( + len(head) >= 4 and head[0].isalpha() and head[1:4].lower() == "%3a" + ): + head = head[0].upper() + ":" + else: + return uri + + return prefix + head + tail + + def _get_published_diagnostics_generation(self, uri: str) -> int: + key = self._canonicalize_published_diagnostics_uri(uri) + with self._published_diagnostics_condition: + return self._published_diagnostics_generation_by_uri.get(key, -1) + + def _wait_for_published_diagnostics( + self, + uri: str, + after_generation: int, + timeout: float, + ) -> list[ls_types.Diagnostic] | None: + key = self._canonicalize_published_diagnostics_uri(uri) + deadline = perf_counter() + timeout + with self._published_diagnostics_condition: + while True: + current_generation = self._published_diagnostics_generation_by_uri.get(key, -1) + if current_generation > after_generation: + return list(self._published_diagnostics.get(key, [])) + + remaining_timeout = deadline - perf_counter() + if remaining_timeout <= 0: + return None + self._published_diagnostics_condition.wait(timeout=remaining_timeout) + + def _get_cached_published_diagnostics(self, uri: str) -> list[ls_types.Diagnostic] | None: + key = self._canonicalize_published_diagnostics_uri(uri) + with self._published_diagnostics_condition: + diagnostics = self._published_diagnostics.get(key) + if diagnostics is None: + return None + return list(diagnostics) + + @staticmethod + def _diagnostic_matches_range(diagnostic: ls_types.Diagnostic, start_line: int, end_line: int) -> bool: + diagnostic_start_line = diagnostic["range"]["start"]["line"] + diagnostic_end_line = diagnostic["range"]["end"]["line"] + + # normalize inverted ranges: some servers (e.g. Julia's JuliaSyntax.jl for unterminated + # expressions) emit diagnostics whose end position lies before the start position. + # treat such ranges as a single span covering both lines. + lo = min(diagnostic_start_line, diagnostic_end_line) + hi = max(diagnostic_start_line, diagnostic_end_line) + + # when end_line < 0 the caller imposes no upper bound; only enforce the lower bound. + if end_line < 0: + return hi >= start_line + return lo <= end_line and hi >= start_line + + @staticmethod + def _diagnostic_matches_min_severity(diagnostic: ls_types.Diagnostic, min_severity: int) -> bool: + severity = diagnostic.get("severity") + if severity is None: + return True + return int(severity) <= min_severity + + @classmethod + def _filter_diagnostics( + cls, + diagnostics: list[ls_types.Diagnostic], + start_line: int, + end_line: int, + min_severity: int, + ) -> list[ls_types.Diagnostic]: + diagnostics = [d for d in diagnostics if cls._diagnostic_matches_range(d, start_line, end_line)] + diagnostics = [d for d in diagnostics if cls._diagnostic_matches_min_severity(d, min_severity)] + return diagnostics + + def _validate_text_document_diagnostics_request( + self, + relative_file_path: str, + start_line: int, + end_line: int, + min_severity: int, + ) -> str: + if not self.server_started: + log.error("request_text_document_diagnostics called before Language Server started") + raise SolidLSPException("Language Server not started") + if start_line < 0: + raise ValueError(f"start_line must be non-negative, got {start_line}") + if end_line != -1 and end_line < start_line: + raise ValueError(f"end_line must be -1 or >= start_line, got {end_line} < {start_line}") + if min_severity not in {1, 2, 3, 4}: + raise ValueError(f"min_severity must be one of 1, 2, 3, 4, got {min_severity}") + return pathlib.Path(str(PurePath(self.repository_root_path, relative_file_path))).as_uri() + + def get_published_diagnostics_generation(self, relative_file_path: str) -> int: + """ + Get the generation number for the latest published diagnostics of a file. + + :param relative_file_path: The relative path of the file. + :return: the generation number, or ``-1`` if none were published yet. + """ + uri = pathlib.Path(str(PurePath(self.repository_root_path, relative_file_path))).as_uri() + return self._get_published_diagnostics_generation(uri) + + def get_cached_published_text_document_diagnostics( + self, + relative_file_path: str, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + ) -> list[ls_types.Diagnostic] | None: + """ + Get cached diagnostics received through ``textDocument/publishDiagnostics``. + + :param relative_file_path: The relative path of the file to retrieve diagnostics for. + :param start_line: the first 0-based line to include in the result. + :param end_line: the last 0-based line to include in the result. ``-1`` means no upper bound. + :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. + Diagnostics with lower-or-equal numeric severity are returned. + :return: the cached diagnostics, or ``None`` if no diagnostics were published yet. + """ + uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) + diagnostics = self._get_cached_published_diagnostics(uri) + if diagnostics is None: + return None + return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity) + + def request_published_text_document_diagnostics( + self, + relative_file_path: str, + after_generation: int = -1, + timeout: float = 2.5, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + allow_cached: bool = True, + ) -> list[ls_types.Diagnostic] | None: + """ + Wait for diagnostics received through ``textDocument/publishDiagnostics`` and return them. + + :param relative_file_path: The relative path of the file to retrieve diagnostics for. + :param after_generation: only return diagnostics published after this generation. ``-1`` accepts the next publication. + :param timeout: the maximum time to wait for a newer publication. + :param start_line: the first 0-based line to include in the result. + :param end_line: the last 0-based line to include in the result. ``-1`` means no upper bound. + :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. + Diagnostics with lower-or-equal numeric severity are returned. + :param allow_cached: whether to fall back to the current cached diagnostics if no newer publication arrives in time. + :return: the published diagnostics, or ``None`` if no diagnostics are available. + """ + uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) + published_uri = self._get_published_diagnostics_uri(uri) + diagnostics: list[ls_types.Diagnostic] | None = None + + # keeping the document open + with self.open_file(relative_file_path): + diagnostics = self._wait_for_relevant_published_diagnostics( + uri=published_uri, + after_generation=after_generation, + timeout=timeout, + allow_cached=allow_cached, + ) + + if diagnostics is None: + return None + + return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity) + + def _get_published_diagnostics_uri(self, request_uri: str) -> str: + """ + Gets the URI under which published diagnostics should be looked up. + """ + return request_uri + + def _supports_pull_diagnostics(self) -> bool: + """ + Whether the language server handles ``textDocument/diagnostic`` (LSP 3.17 pull + diagnostics) gracefully. Subclasses should override and return ``False`` when the + underlying server does not implement pull diagnostics, especially when sending + the request would terminate the server (e.g. LanguageServer.jl raises an error + for unknown methods that crashes the process). + """ + return True + + def _get_published_diagnostics_wait_timeout(self, pull_diagnostics_failed: bool) -> float: + """ + Gets the timeout for waiting on published diagnostics after a diagnostics request. + """ + return 2.5 + + def _accept_published_diagnostics(self, diagnostics: list[ls_types.Diagnostic]) -> bool: + """ + Determines whether a published diagnostics payload should satisfy the current wait. + """ + return bool(diagnostics) + + def _wait_for_relevant_published_diagnostics( + self, + uri: str, + after_generation: int, + timeout: float, + allow_cached: bool = True, + ) -> list[ls_types.Diagnostic] | None: + """ + Waits for a published diagnostics payload that is relevant for the current request. + """ + deadline = monotonic() + timeout + current_after_generation = after_generation + + while True: + remaining_timeout = deadline - monotonic() + if remaining_timeout <= 0: + break + + diagnostics = self._wait_for_published_diagnostics( + uri=uri, + after_generation=current_after_generation, + timeout=remaining_timeout, + ) + if diagnostics is None: + break + if self._accept_published_diagnostics(diagnostics): + return diagnostics + current_after_generation = self._get_published_diagnostics_generation(uri) + + if allow_cached: + diagnostics = self._get_cached_published_diagnostics(uri) + if diagnostics is not None and self._accept_published_diagnostics(diagnostics): + return diagnostics + + return None + + def request_text_document_diagnostics( + self, + relative_file_path: str, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + ) -> list[ls_types.Diagnostic]: + """ + Raise a [textDocument/diagnostic](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_diagnostic) request to the Language Server + to find diagnostics for the given file. Wait for the response and return the result. + + :param relative_file_path: The relative path of the file to retrieve diagnostics for + :param start_line: the first 0-based line to include in the result. + :param end_line: the last 0-based line to include in the result. `-1` means no upper bound. + :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. + Diagnostics with lower-or-equal numeric severity are returned. + + :return: A list of diagnostics for the file + """ + uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) + published_uri = self._get_published_diagnostics_uri(uri) + diagnostics_before_request = self._get_published_diagnostics_generation(published_uri) + ret: list[ls_types.Diagnostic] | None = None + pull_diagnostics_failed = False + + with self.open_file(relative_file_path): + response: Any = None + # only send pull diagnostics when the server actually supports it; some servers + # (e.g. Julia's LanguageServer.jl) hard-error and crash the process on unknown methods + if self._supports_pull_diagnostics(): + try: + response = self.server.send.text_document_diagnostic( + { + LSPConstants.TEXT_DOCUMENT: { # type: ignore + LSPConstants.URI: uri, + } + } + ) + except SolidLSPException as ex: + log.debug("Falling back to published diagnostics for %s due to pull-diagnostics error: %s", relative_file_path, ex) + response = None + pull_diagnostics_failed = True + + if response is not None: + assert isinstance(response, dict), ( + f"Unexpected response from Language Server (expected list, got {type(response)}): {response}" + ) + ret = [] + for item in response["items"]: # type: ignore + new_item: ls_types.Diagnostic = { + "uri": uri, + "severity": item["severity"], + "message": item["message"], + "range": item["range"], + "code": item.get("code"), # type: ignore + } + if "source" in item: + new_item["source"] = item["source"] + ret.append(ls_types.Diagnostic(**new_item)) + + if not ret: + published_diagnostics = self._wait_for_relevant_published_diagnostics( + uri=published_uri, + after_generation=diagnostics_before_request, + timeout=self._get_published_diagnostics_wait_timeout(pull_diagnostics_failed), + ) + if published_diagnostics is not None: + ret = published_diagnostics + + if ret is None: + return [] + + return self._filter_diagnostics(ret, start_line, end_line, min_severity) + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: """ Creates the dependency provider for this language server. @@ -870,10 +1261,12 @@ class SolidLanguageServer(ABC): pass def _get_language_id_for_file(self, relative_file_path: str) -> str: - """Return the language ID for a file. + """ + Determines the language identifier to pass to the language server for the given file, + particularly `textDocument/didOpen` requests. - Override in subclasses to return file-specific language IDs. - Default implementation returns self.language_id. + Override this method in subclasses to return file-specific language identifiers. + The default implementation returns the main identifier passed at construction (self.language_id). """ return self.language_id diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index eb527dc3..1eb1295a 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -207,6 +207,12 @@ class Language(str, Enum): case _: return 2 + def supports_implementation_request(self) -> bool: + """ + Return whether the default language server for this language supports ``textDocument/implementation``. + """ + return self.get_ls_class().supports_implementation_request() + def get_source_fn_matcher(self) -> FilenameMatcher: match self: case self.PYTHON | self.PYTHON_JEDI | self.PYTHON_TY: diff --git a/src/solidlsp/ls_process.py b/src/solidlsp/ls_process.py index 6b979a92..6319a9d7 100644 --- a/src/solidlsp/ls_process.py +++ b/src/solidlsp/ls_process.py @@ -150,6 +150,7 @@ class LanguageServerProcess: self._pending_requests: dict[Any, Request] = {} self.on_request_handlers: dict[str, Callable[[Any], Any]] = {} self.on_notification_handlers: dict[str, Callable[[Any], None]] = {} + self._notification_observers: list[Callable[[str, Any], None]] = [] self._trace_log_fn = logger self.tasks: dict[int, Any] = {} self.task_counter = 0 @@ -510,6 +511,12 @@ class LanguageServerProcess: """ self.on_notification_handlers[method] = cb + def on_any_notification(self, cb: Callable[[str, Any], None]) -> None: + """ + Register an observer that is invoked for every notification received from the server. + """ + self._notification_observers.append(cb) + def _response_handler(self, response: StringDict) -> None: """ Handle the response received from the server for a request, using the id to determine the request @@ -561,6 +568,16 @@ class LanguageServerProcess: """ method = response.get("method", "") params = response.get("params") + + for observer in self._notification_observers: + try: + observer(method, params) + except asyncio.CancelledError: + return + except Exception as ex: + if not self._is_shutting_down: + log.error("Error handling notification observer for method '%s': %s", method, ex, exc_info=ex) + handler = self.on_notification_handlers.get(method) if not handler: log.warning("Unhandled method '%s'", method) diff --git a/src/solidlsp/ls_types.py b/src/solidlsp/ls_types.py index 78446bc5..24d51266 100644 --- a/src/solidlsp/ls_types.py +++ b/src/solidlsp/ls_types.py @@ -347,7 +347,7 @@ class Diagnostic(TypedDict): """ The severity of the diagnostic. """ message: str """ The diagnostic message. """ - code: str + code: NotRequired[str | int] """ The code of the diagnostic. """ source: NotRequired[str] """ The source of the diagnostic, e.g. the name of the tool that produced it. """ diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py index b54654b8..73066e12 100644 --- a/src/solidlsp/ls_utils.py +++ b/src/solidlsp/ls_utils.py @@ -124,6 +124,14 @@ class TextUtils: end_idx = TextUtils.get_index_from_line_col(text, end_line, end_col) return text[start_idx:end_idx] + @staticmethod + def get_text_in_lines_range(text: str, start_line: int, end_line: int) -> str: + """ + Returns the text encompassed by the given start and end lines (inclusive). + """ + lines = text.splitlines(keepends=True) + return "".join(lines[start_line : end_line + 1]) + class PathUtils: """ diff --git a/src/solidlsp/lsp_protocol_handler/server.py b/src/solidlsp/lsp_protocol_handler/server.py index 36e24b15..da94e1eb 100644 --- a/src/solidlsp/lsp_protocol_handler/server.py +++ b/src/solidlsp/lsp_protocol_handler/server.py @@ -131,11 +131,14 @@ class StopLoopException(Exception): pass -def create_message(payload: PayloadLike) -> tuple[bytes, bytes, bytes]: +def create_message(payload: PayloadLike) -> tuple[bytes, bytes]: body = json.dumps(payload, check_circular=False, ensure_ascii=False, separators=(",", ":")).encode(ENCODING) + # NOTE: only Content-Length is sent. Content-Type is optional per the LSP + # spec (default: application/vscode-jsonrpc; charset=utf-8), and Godot's + # GDScript LSP parser only handles Content-Length — any additional header + # makes it silently drop the message. return ( - f"Content-Length: {len(body)}\r\n".encode(ENCODING), - "Content-Type: application/vscode-jsonrpc; charset=utf-8\r\n\r\n".encode(ENCODING), + f"Content-Length: {len(body)}\r\n\r\n".encode(ENCODING), body, ) diff --git a/test/conftest.py b/test/conftest.py index 99ab2845..5d66bf78 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,6 +1,7 @@ import logging import os import platform +import re import shutil as _sh from collections.abc import Iterator from contextlib import contextmanager @@ -41,10 +42,13 @@ _LANGUAGE_REPO_ALIASES: dict[Language, Language] = { Language.CPP_CCLS: Language.CPP, Language.PHP_PHPACTOR: Language.PHP, Language.PYTHON_JEDI: Language.PYTHON, + Language.PYTHON_TY: Language.PYTHON, Language.RUBY_SOLARGRAPH: Language.RUBY, Language.PYTHON_TY: Language.PYTHON, } +PYTHON_LANGUAGE_BACKENDS = [Language.PYTHON, Language.PYTHON_TY] + def get_repo_path(language: Language) -> Path: repo_language = _LANGUAGE_REPO_ALIASES.get(language, language) @@ -334,3 +338,60 @@ def language_tests_enabled(language: Language) -> bool: :return: True if tests for the language are enabled, False otherwise """ return language not in _disabled_languages + + +def language_supports_implementation(language: Language) -> bool: + return language.supports_implementation_request() + + +def languages_supporting_implementation(*languages: Language) -> list[Language]: + return [language for language in languages if language_supports_implementation(language)] + + +_VERIFIED_IMPLEMENTATION_LANGUAGES = { + Language.CSHARP, + Language.GO, + Language.JAVA, + Language.RUST, + Language.TYPESCRIPT, +} + + +def language_has_verified_implementation_support(language: Language) -> bool: + """ + True only for languages where the server advertises implementation support and + the repo fixtures contain a verified working go-to-implementation scenario. + """ + return language in _VERIFIED_IMPLEMENTATION_LANGUAGES and language_supports_implementation(language) + + +def find_identifier_position(file_path: Path, identifier: str) -> tuple[int, int] | None: + pattern = re.compile(r"\b" + re.escape(identifier) + r"\b") + with file_path.open(encoding="utf-8") as f: + for line_idx, line in enumerate(f): + match = pattern.search(line) + if match: + return line_idx, match.start() + return None + + +def find_identifier_pos( + file_path: Path, + identifier: str, + occurrence_index: int = 0, + column_offset: int = 0, +) -> tuple[int, int] | None: + if occurrence_index < 0: + raise ValueError("occurrence_index must be non-negative") + if column_offset < 0: + raise ValueError("column_offset must be non-negative") + + pattern = re.compile(r"\b" + re.escape(identifier) + r"\b") + current_index = 0 + with file_path.open(encoding="utf-8") as f: + for line_idx, line in enumerate(f): + for match in pattern.finditer(line): + if current_index == occurrence_index: + return line_idx, match.start() + column_offset + current_index += 1 + return None diff --git a/test/resources/repos/bash/test_repo/diagnostics_sample.sh b/test/resources/repos/bash/test_repo/diagnostics_sample.sh new file mode 100644 index 00000000..050a8a99 --- /dev/null +++ b/test/resources/repos/bash/test_repo/diagnostics_sample.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +if then + echo "$missingGreeting" +fi diff --git a/test/resources/repos/clojure/test_repo/src/test_app/diagnostics_sample.clj b/test/resources/repos/clojure/test_repo/src/test_app/diagnostics_sample.clj new file mode 100644 index 00000000..ecc0c79c --- /dev/null +++ b/test/resources/repos/clojure/test_repo/src/test_app/diagnostics_sample.clj @@ -0,0 +1,9 @@ +(ns test-app.diagnostics-sample) + +(defn broken-factory [] + missing-greeting) + +(defn broken-consumer [] + (let [value (broken-factory)] + (println value) + missing-consumer-value)) diff --git a/test/resources/repos/cpp/test_repo/compile_commands.json b/test/resources/repos/cpp/test_repo/compile_commands.json index 21c02f62..cb096a60 100644 --- a/test/resources/repos/cpp/test_repo/compile_commands.json +++ b/test/resources/repos/cpp/test_repo/compile_commands.json @@ -8,5 +8,10 @@ "directory": ".", "command": "g++ -std=c++17 -I . -c b.cpp", "file": "b.cpp" + }, + { + "directory": ".", + "command": "g++ -std=c++17 -I . -c diagnostics_sample.cpp", + "file": "diagnostics_sample.cpp" } -] \ No newline at end of file +] diff --git a/test/resources/repos/cpp/test_repo/diagnostics_sample.cpp b/test/resources/repos/cpp/test_repo/diagnostics_sample.cpp new file mode 100644 index 00000000..78075fc8 --- /dev/null +++ b/test/resources/repos/cpp/test_repo/diagnostics_sample.cpp @@ -0,0 +1,8 @@ +int brokenFactory() { + return missingGreeting; +} + +int brokenConsumer() { + int value = brokenFactory(); + return value + missingConsumerValue; +} diff --git a/test/resources/repos/crystal/test_repo/src/diagnostics_sample.cr b/test/resources/repos/crystal/test_repo/src/diagnostics_sample.cr new file mode 100644 index 00000000..b408a202 --- /dev/null +++ b/test/resources/repos/crystal/test_repo/src/diagnostics_sample.cr @@ -0,0 +1,5 @@ +def broken_factory : String + missingGreeting +end + +broken_consumer = missingConsumerValue diff --git a/test/resources/repos/csharp/test_repo/DiagnosticsSample.cs b/test/resources/repos/csharp/test_repo/DiagnosticsSample.cs new file mode 100644 index 00000000..1356e26d --- /dev/null +++ b/test/resources/repos/csharp/test_repo/DiagnosticsSample.cs @@ -0,0 +1,19 @@ +using System; + +namespace TestProject +{ + public static class DiagnosticsSample + { + public static string BrokenFactory() + { + return missingGreeting; + } + + public static void BrokenConsumer() + { + string value = BrokenFactory(); + Console.WriteLine(value); + Console.WriteLine(missingConsumerValue); + } + } +} diff --git a/test/resources/repos/csharp/test_repo/Program.cs b/test/resources/repos/csharp/test_repo/Program.cs index 55c8d228..4bcf0f6a 100644 --- a/test/resources/repos/csharp/test_repo/Program.cs +++ b/test/resources/repos/csharp/test_repo/Program.cs @@ -1,4 +1,5 @@ using System; +using TestProject.Services; namespace TestProject { @@ -7,10 +8,13 @@ namespace TestProject static void Main(string[] args) { Console.WriteLine("Hello, World!"); - + var calculator = new Calculator(); int result = calculator.Add(5, 3); Console.WriteLine($"5 + 3 = {result}"); + + IGreeter greeter = new ConsoleGreeter(); + Console.WriteLine(greeter.FormatGreeting("World")); } } @@ -40,4 +44,4 @@ namespace TestProject return (double)a / b; } } -} \ No newline at end of file +} diff --git a/test/resources/repos/csharp/test_repo/Services/ConsoleGreeter.cs b/test/resources/repos/csharp/test_repo/Services/ConsoleGreeter.cs new file mode 100644 index 00000000..d80d8ba8 --- /dev/null +++ b/test/resources/repos/csharp/test_repo/Services/ConsoleGreeter.cs @@ -0,0 +1,10 @@ +namespace TestProject.Services +{ + public class ConsoleGreeter : IGreeter + { + public string FormatGreeting(string name) + { + return $"Hello, {name}!"; + } + } +} diff --git a/test/resources/repos/csharp/test_repo/Services/IGreeter.cs b/test/resources/repos/csharp/test_repo/Services/IGreeter.cs new file mode 100644 index 00000000..2f64ca0e --- /dev/null +++ b/test/resources/repos/csharp/test_repo/Services/IGreeter.cs @@ -0,0 +1,7 @@ +namespace TestProject.Services +{ + public interface IGreeter + { + string FormatGreeting(string name); + } +} diff --git a/test/resources/repos/dart/test_repo/lib/diagnostics_sample.dart b/test/resources/repos/dart/test_repo/lib/diagnostics_sample.dart new file mode 100644 index 00000000..d309d9fc --- /dev/null +++ b/test/resources/repos/dart/test_repo/lib/diagnostics_sample.dart @@ -0,0 +1,3 @@ +String brokenFactory() { + return 'broken' +} diff --git a/test/resources/repos/elixir/test_repo/lib/diagnostics_sample.ex b/test/resources/repos/elixir/test_repo/lib/diagnostics_sample.ex new file mode 100644 index 00000000..af510437 --- /dev/null +++ b/test/resources/repos/elixir/test_repo/lib/diagnostics_sample.ex @@ -0,0 +1,10 @@ +defmodule DiagnosticsSample do + def broken_factory do + missing_greeting + end + + def broken_consumer do + value = broken_factory() + {value, missing_consumer_value} + end +end diff --git a/test/resources/repos/elm/test_repo/DiagnosticsSample.elm b/test/resources/repos/elm/test_repo/DiagnosticsSample.elm new file mode 100644 index 00000000..2880fa7b --- /dev/null +++ b/test/resources/repos/elm/test_repo/DiagnosticsSample.elm @@ -0,0 +1,5 @@ +module DiagnosticsSample exposing (brokenFactory) + +brokenFactory : String +brokenFactory = + "unterminated diff --git a/test/resources/repos/erlang/test_repo/src/diagnostics_sample.erl b/test/resources/repos/erlang/test_repo/src/diagnostics_sample.erl new file mode 100644 index 00000000..edb18776 --- /dev/null +++ b/test/resources/repos/erlang/test_repo/src/diagnostics_sample.erl @@ -0,0 +1,5 @@ +-module(diagnostics_sample). +-export([broken_factory/0, broken_consumer/0]). + +broken_factory() -> MissingGreeting. +broken_consumer() -> MissingConsumerValue. diff --git a/test/resources/repos/fortran/test_repo/diagnostics_sample.f90 b/test/resources/repos/fortran/test_repo/diagnostics_sample.f90 new file mode 100644 index 00000000..2c23e5df --- /dev/null +++ b/test/resources/repos/fortran/test_repo/diagnostics_sample.f90 @@ -0,0 +1,5 @@ +program diagnostics_sample + implicit none + integer :: duplicated_value + real :: duplicated_value +end program diagnostics_sample diff --git a/test/resources/repos/fsharp/test_repo/DiagnosticsSample.fs b/test/resources/repos/fsharp/test_repo/DiagnosticsSample.fs new file mode 100644 index 00000000..815b844b --- /dev/null +++ b/test/resources/repos/fsharp/test_repo/DiagnosticsSample.fs @@ -0,0 +1,11 @@ +module DiagnosticsSample + +let brokenConsumerValue = 1 + +let brokenFactory () = + missingGreeting + +let brokenConsumer () = + let value = brokenFactory () + printfn "%A" value + missingConsumerValue diff --git a/test/resources/repos/fsharp/test_repo/Formatter.fs b/test/resources/repos/fsharp/test_repo/Formatter.fs new file mode 100644 index 00000000..52085d3f --- /dev/null +++ b/test/resources/repos/fsharp/test_repo/Formatter.fs @@ -0,0 +1,8 @@ +module Formatter + +type IGreeter = + abstract member FormatGreeting: string -> string + +type ConsoleGreeter() = + interface IGreeter with + member _.FormatGreeting(name: string) = sprintf "Hello, %s!" name diff --git a/test/resources/repos/fsharp/test_repo/Program.fs b/test/resources/repos/fsharp/test_repo/Program.fs index e5328ceb..3305598c 100644 --- a/test/resources/repos/fsharp/test_repo/Program.fs +++ b/test/resources/repos/fsharp/test_repo/Program.fs @@ -1,7 +1,9 @@ module Program open Calculator +open Formatter open Models +open Shapes [] let main argv = @@ -24,6 +26,12 @@ let main argv = let calc = CalculatorClass() let classResult = calc.Add(20, 5) printfn "Calculator class: 20 + 5 = %d" classResult + + let greeter: IGreeter = ConsoleGreeter() :> IGreeter + printfn "%s" (greeter.FormatGreeting("World")) + + let shape: Shape = Circle(2.0) :> Shape + printfn "Area: %.2f" (shape.Area()) // Test person module let person = PersonModule.createPerson "Alice Smith" 25 (Some "alice@example.com") @@ -34,4 +42,4 @@ let main argv = let fact5 = factorial 5 printfn "5! = %d" fact5 - 0 // return success \ No newline at end of file + 0 // return success diff --git a/test/resources/repos/fsharp/test_repo/Shapes.fs b/test/resources/repos/fsharp/test_repo/Shapes.fs new file mode 100644 index 00000000..d67a98aa --- /dev/null +++ b/test/resources/repos/fsharp/test_repo/Shapes.fs @@ -0,0 +1,10 @@ +module Shapes + +[] +type Shape() = + abstract member Area: unit -> float + +type Circle(radius: float) = + inherit Shape() + + override _.Area() = System.Math.PI * radius * radius diff --git a/test/resources/repos/fsharp/test_repo/TestProject.fsproj b/test/resources/repos/fsharp/test_repo/TestProject.fsproj index 39b621ff..e44baee0 100644 --- a/test/resources/repos/fsharp/test_repo/TestProject.fsproj +++ b/test/resources/repos/fsharp/test_repo/TestProject.fsproj @@ -7,8 +7,11 @@ + + + - \ No newline at end of file + diff --git a/test/resources/repos/go/test_repo/diagnostics_sample.go b/test/resources/repos/go/test_repo/diagnostics_sample.go new file mode 100644 index 00000000..15f7d6a2 --- /dev/null +++ b/test/resources/repos/go/test_repo/diagnostics_sample.go @@ -0,0 +1,11 @@ +package main + +func brokenFactory() string { + return missingGreeting +} + +func brokenConsumer() { + value := brokenFactory() + _ = value + _ = missingConsumerValue +} diff --git a/test/resources/repos/go/test_repo/main.go b/test/resources/repos/go/test_repo/main.go index a5716358..44412892 100644 --- a/test/resources/repos/go/test_repo/main.go +++ b/test/resources/repos/go/test_repo/main.go @@ -5,6 +5,8 @@ import "fmt" func main() { fmt.Println("Hello, Go!") Helper() + var greeter Greeter = ConsoleGreeter{} + fmt.Println(greeter.FormatGreeting("Go")) } func Helper() { @@ -22,3 +24,13 @@ func (d *DemoStruct) Value() int { func UsingHelper() { Helper() } + +type Greeter interface { + FormatGreeting(name string) string +} + +type ConsoleGreeter struct{} + +func (ConsoleGreeter) FormatGreeting(name string) string { + return "Hello, " + name + "!" +} diff --git a/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/DiagnosticsSample.groovy b/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/DiagnosticsSample.groovy new file mode 100644 index 00000000..a176203e --- /dev/null +++ b/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/DiagnosticsSample.groovy @@ -0,0 +1,13 @@ +package com.example + +class DiagnosticsSample { + static String brokenFactory() { + missingGreeting + } + + static void brokenConsumer() { + def value = brokenFactory() + println(value) + println(missingConsumerValue) + } +} diff --git a/test/resources/repos/haskell/test_repo/src/DiagnosticsSample.hs b/test/resources/repos/haskell/test_repo/src/DiagnosticsSample.hs new file mode 100644 index 00000000..e5777b1f --- /dev/null +++ b/test/resources/repos/haskell/test_repo/src/DiagnosticsSample.hs @@ -0,0 +1,7 @@ +module DiagnosticsSample where + +brokenFactory :: String +brokenFactory = missingGreeting + +brokenConsumer :: String +brokenConsumer = missingConsumerValue diff --git a/test/resources/repos/haxe/test_repo/src/DiagnosticsSample.hx b/test/resources/repos/haxe/test_repo/src/DiagnosticsSample.hx new file mode 100644 index 00000000..3b2e2c29 --- /dev/null +++ b/test/resources/repos/haxe/test_repo/src/DiagnosticsSample.hx @@ -0,0 +1,10 @@ +class DiagnosticsSample { + static function brokenFactory():String { + return missingGreeting; + } + + static function brokenConsumer():Void { + trace(brokenFactory()); + trace(missingConsumerValue); + } +} diff --git a/test/resources/repos/hlsl/test_repo/diagnostics_sample.hlsl b/test/resources/repos/hlsl/test_repo/diagnostics_sample.hlsl new file mode 100644 index 00000000..ffc8075c --- /dev/null +++ b/test/resources/repos/hlsl/test_repo/diagnostics_sample.hlsl @@ -0,0 +1,3 @@ +float4 main() : SV_Target { + return missingGreeting; +} diff --git a/test/resources/repos/java/test_repo/src/main/java/test_repo/ConsoleGreeter.java b/test/resources/repos/java/test_repo/src/main/java/test_repo/ConsoleGreeter.java new file mode 100644 index 00000000..e8b85c99 --- /dev/null +++ b/test/resources/repos/java/test_repo/src/main/java/test_repo/ConsoleGreeter.java @@ -0,0 +1,8 @@ +package test_repo; + +public class ConsoleGreeter implements Greeter { + @Override + public String formatGreeting(String name) { + return "Hello, " + name + "!"; + } +} diff --git a/test/resources/repos/java/test_repo/src/main/java/test_repo/DiagnosticsSample.java b/test/resources/repos/java/test_repo/src/main/java/test_repo/DiagnosticsSample.java new file mode 100644 index 00000000..53f8406b --- /dev/null +++ b/test/resources/repos/java/test_repo/src/main/java/test_repo/DiagnosticsSample.java @@ -0,0 +1,13 @@ +package test_repo; + +public class DiagnosticsSample { + public static String brokenFactory() { + return missingGreeting; + } + + public static void brokenConsumer() { + String value = brokenFactory(); + System.out.println(value); + System.out.println(missingConsumerValue); + } +} diff --git a/test/resources/repos/java/test_repo/src/main/java/test_repo/Greeter.java b/test/resources/repos/java/test_repo/src/main/java/test_repo/Greeter.java new file mode 100644 index 00000000..569d5006 --- /dev/null +++ b/test/resources/repos/java/test_repo/src/main/java/test_repo/Greeter.java @@ -0,0 +1,5 @@ +package test_repo; + +public interface Greeter { + String formatGreeting(String name); +} diff --git a/test/resources/repos/java/test_repo/src/main/java/test_repo/Main.java b/test/resources/repos/java/test_repo/src/main/java/test_repo/Main.java index ed79b9fe..29c0688e 100644 --- a/test/resources/repos/java/test_repo/src/main/java/test_repo/Main.java +++ b/test/resources/repos/java/test_repo/src/main/java/test_repo/Main.java @@ -6,6 +6,8 @@ public class Main { Model model = new Model("Cascade"); System.out.println(model.getName()); acceptModel(model); + Greeter greeter = new ConsoleGreeter(); + System.out.println(greeter.formatGreeting("Cascade")); } public static void acceptModel(Model m) { // Do nothing, just for LSP reference diff --git a/test/resources/repos/json/test_repo/diagnostics_sample.json b/test/resources/repos/json/test_repo/diagnostics_sample.json new file mode 100644 index 00000000..0cc75efd --- /dev/null +++ b/test/resources/repos/json/test_repo/diagnostics_sample.json @@ -0,0 +1,4 @@ +{ + "name": "diagnostics", + "value": +} diff --git a/test/resources/repos/julia/test_repo/diagnostics_sample.jl b/test/resources/repos/julia/test_repo/diagnostics_sample.jl new file mode 100644 index 00000000..ef7e4a51 --- /dev/null +++ b/test/resources/repos/julia/test_repo/diagnostics_sample.jl @@ -0,0 +1,3 @@ +function broken_factory( + value = 1 +end diff --git a/test/resources/repos/kotlin/test_repo/gradle/wrapper/gradle-wrapper.properties b/test/resources/repos/kotlin/test_repo/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..2a84e188 --- /dev/null +++ b/test/resources/repos/kotlin/test_repo/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/test/resources/repos/kotlin/test_repo/gradlew b/test/resources/repos/kotlin/test_repo/gradlew new file mode 100644 index 00000000..ef07e016 --- /dev/null +++ b/test/resources/repos/kotlin/test_repo/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/test/resources/repos/kotlin/test_repo/gradlew.bat b/test/resources/repos/kotlin/test_repo/gradlew.bat new file mode 100644 index 00000000..db3a6ac2 --- /dev/null +++ b/test/resources/repos/kotlin/test_repo/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/DiagnosticsSample.kt b/test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/DiagnosticsSample.kt new file mode 100644 index 00000000..93f0b939 --- /dev/null +++ b/test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/DiagnosticsSample.kt @@ -0,0 +1,11 @@ +package test_repo + +fun brokenFactory(): String { + return missingGreeting +} + +fun brokenConsumer() { + val value = brokenFactory() + println(value) + println(missingConsumerValue) +} diff --git a/test/resources/repos/lean4/test_repo/DiagnosticsSample.lean b/test/resources/repos/lean4/test_repo/DiagnosticsSample.lean new file mode 100644 index 00000000..169754f7 --- /dev/null +++ b/test/resources/repos/lean4/test_repo/DiagnosticsSample.lean @@ -0,0 +1 @@ +def brokenFactory : Nat := diff --git a/test/resources/repos/lua/test_repo/main.lua b/test/resources/repos/lua/test_repo/main.lua index d19d5212..cd6ecc97 100644 --- a/test/resources/repos/lua/test_repo/main.lua +++ b/test/resources/repos/lua/test_repo/main.lua @@ -3,6 +3,7 @@ -- main.lua: Entry point for the test application local calculator = require("src.calculator") +local animals = require("src.animals") local utils = require("src.utils") local function print_banner() @@ -50,6 +51,12 @@ local function test_utils() logger:warn("This is a warning") end +local function test_animals() + print("\nTesting Animal Module:") + local dog = animals.Dog:new("Rex") + print(dog:speak()) +end + local function interactive_calculator() print("\nInteractive Calculator (type 'quit' to exit):") while true do @@ -99,11 +106,13 @@ local function main(args) if #args == 0 then test_calculator() test_utils() + test_animals() elseif args[1] == "interactive" then interactive_calculator() elseif args[1] == "test" then test_calculator() test_utils() + test_animals() print("\nAll tests completed!") else print("Usage: lua main.lua [interactive|test]") @@ -111,4 +120,4 @@ local function main(args) end -- Run main function -main(arg or {}) \ No newline at end of file +main(arg or {}) diff --git a/test/resources/repos/lua/test_repo/src/animals.lua b/test/resources/repos/lua/test_repo/src/animals.lua new file mode 100644 index 00000000..92f08f26 --- /dev/null +++ b/test/resources/repos/lua/test_repo/src/animals.lua @@ -0,0 +1,37 @@ +---@class Animal +local Animal = {} +Animal.__index = Animal + +---@param name string +---@return Animal +function Animal:new(name) + local self = setmetatable({}, Animal) + self.name = name + return self +end + +---@return string +function Animal:speak() + return self.name .. " makes a sound" +end + +---@class Dog: Animal +local Dog = setmetatable({}, { __index = Animal }) +Dog.__index = Dog + +---@param name string +---@return Dog +function Dog:new(name) + local self = Animal.new(self, name) + return setmetatable(self, Dog) +end + +---@return string +function Dog:speak() + return self.name .. " says woof" +end + +return { + Animal = Animal, + Dog = Dog, +} diff --git a/test/resources/repos/matlab/test_repo/diagnostics_sample.m b/test/resources/repos/matlab/test_repo/diagnostics_sample.m new file mode 100644 index 00000000..b0f87a62 --- /dev/null +++ b/test/resources/repos/matlab/test_repo/diagnostics_sample.m @@ -0,0 +1,3 @@ +function value = diagnostics_sample() +value = missingGreeting; +end diff --git a/test/resources/repos/nix/test_repo/diagnostics_sample.nix b/test/resources/repos/nix/test_repo/diagnostics_sample.nix new file mode 100644 index 00000000..320a6ab8 --- /dev/null +++ b/test/resources/repos/nix/test_repo/diagnostics_sample.nix @@ -0,0 +1,4 @@ +{ + greeting = missingGreeting; + consumer = missingConsumerValue; +} diff --git a/test/resources/repos/ocaml/test_repo/lib/diagnostics_sample.ml b/test/resources/repos/ocaml/test_repo/lib/diagnostics_sample.ml new file mode 100644 index 00000000..c23ccefe --- /dev/null +++ b/test/resources/repos/ocaml/test_repo/lib/diagnostics_sample.ml @@ -0,0 +1 @@ +let broken_factory ( diff --git a/test/resources/repos/ocaml/test_repo/lib/dune b/test/resources/repos/ocaml/test_repo/lib/dune index fe41978f..aba011b1 100644 --- a/test/resources/repos/ocaml/test_repo/lib/dune +++ b/test/resources/repos/ocaml/test_repo/lib/dune @@ -1,3 +1,4 @@ (library (public_name test_repo) - (name test_repo)) \ No newline at end of file + (name test_repo) + (modules test_repo)) diff --git a/test/resources/repos/pascal/test_repo/diagnostics_sample.pas b/test/resources/repos/pascal/test_repo/diagnostics_sample.pas new file mode 100644 index 00000000..7a3bf84b --- /dev/null +++ b/test/resources/repos/pascal/test_repo/diagnostics_sample.pas @@ -0,0 +1,4 @@ +program DiagnosticsSample; +begin + writeln( +end. diff --git a/test/resources/repos/perl/test_repo/diagnostics_sample.pl b/test/resources/repos/perl/test_repo/diagnostics_sample.pl new file mode 100644 index 00000000..4d78b686 --- /dev/null +++ b/test/resources/repos/perl/test_repo/diagnostics_sample.pl @@ -0,0 +1,5 @@ +use strict; +use warnings; + +print $missingGreeting; +print $missingConsumerValue; diff --git a/test/resources/repos/php/test_repo/diagnostics_sample.php b/test/resources/repos/php/test_repo/diagnostics_sample.php new file mode 100644 index 00000000..b1a6be19 --- /dev/null +++ b/test/resources/repos/php/test_repo/diagnostics_sample.php @@ -0,0 +1,11 @@ + User: + return missing_user + + +def broken_consumer() -> None: + created_user = broken_factory() + print(created_user) + print(undefined_name) diff --git a/test/resources/repos/ruby/test_repo/lib.rb b/test/resources/repos/ruby/test_repo/lib.rb index 5dd8504b..65eba9de 100644 --- a/test/resources/repos/ruby/test_repo/lib.rb +++ b/test/resources/repos/ruby/test_repo/lib.rb @@ -7,3 +7,15 @@ class Calculator a - b end end + +class Greeter + def format_greeting(name) + name + end +end + +class ConsoleGreeter < Greeter + def format_greeting(name) + "Hello, #{name}!" + end +end diff --git a/test/resources/repos/ruby/test_repo/main.rb b/test/resources/repos/ruby/test_repo/main.rb index dcf1b0f2..b9a98c92 100644 --- a/test/resources/repos/ruby/test_repo/main.rb +++ b/test/resources/repos/ruby/test_repo/main.rb @@ -15,6 +15,8 @@ end def helper_function(number = 42) demo = DemoClass.new(number) Calculator.new.add(demo.value, 10) + greeter = ConsoleGreeter.new + puts greeter.format_greeting("Ruby") demo.print_value end diff --git a/test/resources/repos/rust/test_repo/src/diagnostics_sample.rs b/test/resources/repos/rust/test_repo/src/diagnostics_sample.rs new file mode 100644 index 00000000..120e9e36 --- /dev/null +++ b/test/resources/repos/rust/test_repo/src/diagnostics_sample.rs @@ -0,0 +1,9 @@ +pub fn broken_factory() -> String { + missing_greeting +} + +pub fn broken_consumer() { + let value = broken_factory(); + println!("{value}"); + println!("{}", missing_consumer_value); +} diff --git a/test/resources/repos/rust/test_repo/src/lib.rs b/test/resources/repos/rust/test_repo/src/lib.rs index 8ad43ead..178d0165 100644 --- a/test/resources/repos/rust/test_repo/src/lib.rs +++ b/test/resources/repos/rust/test_repo/src/lib.rs @@ -1,3 +1,5 @@ +pub mod diagnostics_sample; + // This function returns the sum of 2 + 2 pub fn add() -> i32 { let res = 2 + 2; @@ -7,3 +9,15 @@ pub fn multiply() -> i32 { 2 * 3 } +pub trait Greeter { + fn format_greeting(&self, name: &str) -> String; +} + +pub struct ConsoleGreeter; + +impl Greeter for ConsoleGreeter { + fn format_greeting(&self, name: &str) -> String { + format!("Hello, {name}!") + } +} + diff --git a/test/resources/repos/rust/test_repo/src/main.rs b/test/resources/repos/rust/test_repo/src/main.rs index 7385c9d9..a5bb8063 100644 --- a/test/resources/repos/rust/test_repo/src/main.rs +++ b/test/resources/repos/rust/test_repo/src/main.rs @@ -1,8 +1,10 @@ -use rsandbox::add; +use rsandbox::{add, ConsoleGreeter, Greeter}; fn main() { println!("Hello, World!"); println!("Good morning!"); println!("add result: {}", add()); + let greeter = ConsoleGreeter; + println!("{}", greeter.format_greeting("Rust")); println!("inserted line"); } diff --git a/test/resources/repos/scala/test_repo/src/main/scala/com/example/DiagnosticsSample.scala b/test/resources/repos/scala/test_repo/src/main/scala/com/example/DiagnosticsSample.scala new file mode 100644 index 00000000..ca9d929b --- /dev/null +++ b/test/resources/repos/scala/test_repo/src/main/scala/com/example/DiagnosticsSample.scala @@ -0,0 +1,9 @@ +package com.example + +object DiagnosticsSample { + def brokenFactory(): String = missingGreeting + def brokenConsumer(): Unit = { + println(brokenFactory()) + println(missingConsumerValue) + } +} diff --git a/test/resources/repos/solidity/test_repo/contracts/DiagnosticsSample.sol b/test/resources/repos/solidity/test_repo/contracts/DiagnosticsSample.sol new file mode 100644 index 00000000..7b2a0892 --- /dev/null +++ b/test/resources/repos/solidity/test_repo/contracts/DiagnosticsSample.sol @@ -0,0 +1,7 @@ +pragma solidity ^0.8.0; + +contract DiagnosticsSample { + function brokenFactory() pure returns (string memory) { + return "broken"; + } +} diff --git a/test/resources/repos/swift/test_repo/Package.swift b/test/resources/repos/swift/test_repo/Package.swift index 13a02704..5e9b7cff 100644 --- a/test/resources/repos/swift/test_repo/Package.swift +++ b/test/resources/repos/swift/test_repo/Package.swift @@ -11,6 +11,7 @@ let package = Package( targets: [ .target( name: "test_repo", - dependencies: []), + dependencies: [], + path: "src"), ] -) \ No newline at end of file +) diff --git a/test/resources/repos/swift/test_repo/src/diagnostics_sample.swift b/test/resources/repos/swift/test_repo/src/diagnostics_sample.swift new file mode 100644 index 00000000..b70b2c68 --- /dev/null +++ b/test/resources/repos/swift/test_repo/src/diagnostics_sample.swift @@ -0,0 +1,3 @@ +func brokenFactory() -> String { + return +} diff --git a/test/resources/repos/systemverilog/test_repo/diagnostics_sample.sv b/test/resources/repos/systemverilog/test_repo/diagnostics_sample.sv new file mode 100644 index 00000000..b5364262 --- /dev/null +++ b/test/resources/repos/systemverilog/test_repo/diagnostics_sample.sv @@ -0,0 +1,5 @@ +module diagnostics_sample; + initial begin + if ( + end +endmodule diff --git a/test/resources/repos/terraform/test_repo/diagnostics_sample.tf b/test/resources/repos/terraform/test_repo/diagnostics_sample.tf new file mode 100644 index 00000000..15de22c0 --- /dev/null +++ b/test/resources/repos/terraform/test_repo/diagnostics_sample.tf @@ -0,0 +1,4 @@ +resource "aws_instance" "broken" { + ami = + instance_type = "t2.micro" +} diff --git a/test/resources/repos/toml/test_repo/diagnostics_sample.toml b/test/resources/repos/toml/test_repo/diagnostics_sample.toml new file mode 100644 index 00000000..ed4ae4c1 --- /dev/null +++ b/test/resources/repos/toml/test_repo/diagnostics_sample.toml @@ -0,0 +1,2 @@ +[diagnostics] +value = diff --git a/test/resources/repos/typescript/test_repo/.serena/project.yml b/test/resources/repos/typescript/test_repo/.serena/project.yml index b9f472b6..942b95a1 100644 --- a/test/resources/repos/typescript/test_repo/.serena/project.yml +++ b/test/resources/repos/typescript/test_repo/.serena/project.yml @@ -1,7 +1,153 @@ + +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# +# Below is the complete list of tools for convenience. +# To make sure you have the latest list of tools, and to view their descriptions, +# execute `uv run scripts/print_tool_overview.py`. +# +# * `activate_project`: Activates a project by name. +# * `check_onboarding_performed`: Checks whether project onboarding was already performed. +# * `create_text_file`: Creates/overwrites a file in the project directory. +# * `delete_lines`: Deletes a range of lines within a file. +# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. +# * `execute_shell_command`: Executes a shell command. +# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. +# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). +# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). +# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. +# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. +# * `initial_instructions`: Gets the initial instructions for the current project. +# Should only be used in settings where the system prompt cannot be set, +# e.g. in clients you have no control over, like Claude Desktop. +# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. +# * `insert_at_line`: Inserts content at a given line in a file. +# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. +# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). +# * `list_memories`: Lists memories in Serena's project-specific memory store. +# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). +# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). +# * `read_file`: Reads a file within the project directory. +# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. +# * `remove_project`: Removes a project from the Serena configuration. +# * `replace_lines`: Replaces a range of lines within a file with new content. +# * `replace_symbol_body`: Replaces the full definition of a symbol. +# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. +# * `search_for_pattern`: Performs a search for a pattern in the project. +# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. +# * `switch_modes`: Activates modes by providing a list of their names +# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. +# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. +# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. +# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. excluded_tools: [] + +# whether to use project's .gitignore files to ignore files ignore_all_files_in_gitignore: true + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. ignored_paths: [] + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). initial_prompt: '' -language: typescript +# the name by which the project can be referenced within Serena project_name: test_repo + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 read_only: false + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +fixed_tools: [] + +# list of mode names to that are always to be included in the set of active modes +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this setting overrides the global configuration. +# Set this to [] to disable base modes for this project. +# Set this to a list of mode names to always include the respective modes for this project. +base_modes: + +# list of mode names that are to be activated by default. +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# This setting can, in turn, be overridden by CLI parameters (--mode). +default_modes: + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key 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. +ls_specific_settings: {} + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: utf-8 + + +# list of languages for which language servers are started; choose from: +# al bash clojure cpp csharp +# csharp_omnisharp dart elixir elm erlang +# fortran fsharp go groovy haskell +# java julia kotlin lua markdown +# matlab nix pascal perl php +# php_phpactor powershell python python_jedi python_ty +# r +# rego ruby ruby_solargraph rust scala +# swift terraform toml typescript typescript_vts +# vue yaml zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- typescript diff --git a/test/resources/repos/typescript/test_repo/diagnostics_sample.ts b/test/resources/repos/typescript/test_repo/diagnostics_sample.ts new file mode 100644 index 00000000..566643e1 --- /dev/null +++ b/test/resources/repos/typescript/test_repo/diagnostics_sample.ts @@ -0,0 +1,9 @@ +export function brokenFactory(): string { + return missingGreeting; +} + +export function brokenConsumer(): void { + const value = brokenFactory(); + console.log(value); + console.log(missingConsumerValue); +} diff --git a/test/resources/repos/typescript/test_repo/formatters.ts b/test/resources/repos/typescript/test_repo/formatters.ts new file mode 100644 index 00000000..fe233f93 --- /dev/null +++ b/test/resources/repos/typescript/test_repo/formatters.ts @@ -0,0 +1,9 @@ +export interface Greeter { + formatGreeting(name: string): string; +} + +export class ConsoleGreeter implements Greeter { + formatGreeting(name: string): string { + return `Hello, ${name}!`; + } +} diff --git a/test/resources/repos/typescript/test_repo/index.ts b/test/resources/repos/typescript/test_repo/index.ts index 724a67c6..417e5863 100644 --- a/test/resources/repos/typescript/test_repo/index.ts +++ b/test/resources/repos/typescript/test_repo/index.ts @@ -1,3 +1,5 @@ +import { ConsoleGreeter, Greeter } from "./formatters"; + export class DemoClass { value: number; constructor(value: number) { @@ -11,6 +13,9 @@ export class DemoClass { export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); + + const greeter: Greeter = new ConsoleGreeter(); + console.log(greeter.formatGreeting("World")); } helperFunction(); diff --git a/test/resources/repos/typescript/test_repo/jsx_component.tsx b/test/resources/repos/typescript/test_repo/jsx_component.tsx new file mode 100644 index 00000000..c8dea77d --- /dev/null +++ b/test/resources/repos/typescript/test_repo/jsx_component.tsx @@ -0,0 +1,44 @@ +// Regression fixture: mirrors the real-world pattern that caused +// languageId=typescript (instead of typescriptreact) to truncate symbol +// ranges at the first multi-line JSX expression. + +import * as React from "react" + +type SectionProps = { title: string; emphasised?: boolean } + +function Section({ title, emphasised }: SectionProps) { + return emphasised ? ( +

{title}

+ ) : ( +

{title}

+ ) +} + +export function JsxComponent({ heading, items }: { heading: string; items: string[] }) { + const renderHeader = (title: string) => + title.length > 10 ? ( +
+ ) : ( +
+ ) + + // The truncation bug used to cut JsxComponent's range right around here, + // hiding everything below from find_symbol. Keep this comment so the + // regression test can assert the function body extends past it. + const renderItem = (item: string, idx: number) => ( +
  • + {item} +
  • + ) + + return ( +
    + {renderHeader(heading)} +
      {items.map(renderItem)}
    +
    + ) +} + +export function trailingHelper(): string { + return "this symbol is invisible to find_symbol when JsxComponent's range is truncated" +} diff --git a/test/resources/repos/typescript/test_repo/tsconfig.json b/test/resources/repos/typescript/test_repo/tsconfig.json index ba0514f5..7267a686 100644 --- a/test/resources/repos/typescript/test_repo/tsconfig.json +++ b/test/resources/repos/typescript/test_repo/tsconfig.json @@ -5,7 +5,8 @@ "strict": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, - "skipLibCheck": true + "skipLibCheck": true, + "jsx": "react" }, - "include": ["**/*.ts"] + "include": ["**/*.ts", "**/*.tsx"] } diff --git a/test/resources/repos/vue/test_repo/src/DiagnosticsSample.vue b/test/resources/repos/vue/test_repo/src/DiagnosticsSample.vue new file mode 100644 index 00000000..17bd62e3 --- /dev/null +++ b/test/resources/repos/vue/test_repo/src/DiagnosticsSample.vue @@ -0,0 +1,3 @@ + diff --git a/test/resources/repos/yaml/test_repo/diagnostics_sample.yaml b/test/resources/repos/yaml/test_repo/diagnostics_sample.yaml new file mode 100644 index 00000000..c07e469d --- /dev/null +++ b/test/resources/repos/yaml/test_repo/diagnostics_sample.yaml @@ -0,0 +1,5 @@ +name: diagnostics +value: + - good + - + broken: [ diff --git a/test/resources/repos/zig/test_repo/build.zig b/test/resources/repos/zig/test_repo/build.zig index da7317d0..c9c1ff5a 100644 --- a/test/resources/repos/zig/test_repo/build.zig +++ b/test/resources/repos/zig/test_repo/build.zig @@ -28,4 +28,4 @@ pub fn build(b: *std.Build) void { const run_lib_tests = b.addRunArtifact(lib_tests); const test_step = b.step("test", "Run unit tests"); test_step.dependOn(&run_lib_tests.step); -} \ No newline at end of file +} diff --git a/test/resources/repos/zig/test_repo/src/diagnostics_sample.zig b/test/resources/repos/zig/test_repo/src/diagnostics_sample.zig new file mode 100644 index 00000000..844d6f14 --- /dev/null +++ b/test/resources/repos/zig/test_repo/src/diagnostics_sample.zig @@ -0,0 +1,3 @@ +pub fn brokenFactory() void { + const value = +} diff --git a/test/resources/repos/zig/test_repo/src/main.zig b/test/resources/repos/zig/test_repo/src/main.zig index 326b2cd0..156adabd 100644 --- a/test/resources/repos/zig/test_repo/src/main.zig +++ b/test/resources/repos/zig/test_repo/src/main.zig @@ -29,4 +29,4 @@ pub fn main() !void { pub fn greeting(name: []const u8) []const u8 { return std.fmt.allocPrint(std.heap.page_allocator, "Hello, {s}!", .{name}) catch "Hello!"; -} \ No newline at end of file +} diff --git a/test/serena/__snapshots__/test_symbol_editing.ambr b/test/serena/__snapshots__/test_symbol_editing.ambr index 6c688345..ec23cf33 100644 --- a/test/serena/__snapshots__/test_symbol_editing.ambr +++ b/test/serena/__snapshots__/test_symbol_editing.ambr @@ -67,11 +67,16 @@ # --- # name: test_delete_symbol[test_case1] ''' + import { ConsoleGreeter, Greeter } from "./formatters"; + export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); + + const greeter: Greeter = new ConsoleGreeter(); + console.log(greeter.formatGreeting("World")); } helperFunction(); @@ -515,6 +520,8 @@ # --- # name: test_insert_in_rel_to_symbol[test_case1-after] ''' + import { ConsoleGreeter, Greeter } from "./formatters"; + export class DemoClass { value: number; constructor(value: number) { @@ -532,6 +539,9 @@ export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); + + const greeter: Greeter = new ConsoleGreeter(); + console.log(greeter.formatGreeting("World")); } helperFunction(); @@ -544,6 +554,8 @@ # --- # name: test_insert_in_rel_to_symbol[test_case1-before] ''' + import { ConsoleGreeter, Greeter } from "./formatters"; + function newFunctionAfterClass(): void { console.log("This function is after DemoClass."); } @@ -561,6 +573,9 @@ export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); + + const greeter: Greeter = new ConsoleGreeter(); + console.log(greeter.formatGreeting("World")); } helperFunction(); @@ -573,6 +588,8 @@ # --- # name: test_insert_in_rel_to_symbol[test_case2-after] ''' + import { ConsoleGreeter, Greeter } from "./formatters"; + export class DemoClass { value: number; constructor(value: number) { @@ -586,6 +603,9 @@ export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); + + const greeter: Greeter = new ConsoleGreeter(); + console.log(greeter.formatGreeting("World")); } function newInsertedFunction(): void { @@ -602,6 +622,8 @@ # --- # name: test_insert_in_rel_to_symbol[test_case2-before] ''' + import { ConsoleGreeter, Greeter } from "./formatters"; + export class DemoClass { value: number; constructor(value: number) { @@ -619,6 +641,9 @@ export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); + + const greeter: Greeter = new ConsoleGreeter(); + console.log(greeter.formatGreeting("World")); } helperFunction(); @@ -1686,6 +1711,8 @@ # --- # name: test_replace_body[test_case1] ''' + import { ConsoleGreeter, Greeter } from "./formatters"; + export class DemoClass { value: number; constructor(value: number) { @@ -1700,6 +1727,9 @@ export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); + + const greeter: Greeter = new ConsoleGreeter(); + console.log(greeter.formatGreeting("World")); } helperFunction(); diff --git a/test/serena/config/test_serena_config.py b/test/serena/config/test_serena_config.py index 3f2e7ac3..75c1e143 100644 --- a/test/serena/config/test_serena_config.py +++ b/test/serena/config/test_serena_config.py @@ -499,6 +499,62 @@ class TestProjectSerenaDataFolder: assert project.path_to_serena_data_folder() == str(custom_serena) +class TestSerenaConfigFromConfigFileRobustness: + """Tests that ``SerenaConfig.from_config_file`` does not abort the whole + loader when a single registered project has a broken ``project.yml``. + """ + + def setup_method(self): + self.test_dir = Path(tempfile.mkdtemp()) + self.master_config_path = self.test_dir / "serena_config.yml" + + def teardown_method(self): + shutil.rmtree(self.test_dir) + + def _make_project_dir(self, name: str, project_yml_body: str) -> Path: + project_dir = self.test_dir / name + (project_dir / SERENA_MANAGED_DIR_NAME).mkdir(parents=True) + (project_dir / SERENA_MANAGED_DIR_NAME / "project.yml").write_text(project_yml_body) + return project_dir + + def _write_master_config(self, project_paths: list[Path]) -> None: + body_lines = ["projects:"] + for p in project_paths: + body_lines.append(f" - {p}") + self.master_config_path.write_text("\n".join(body_lines) + "\n") + + def test_malformed_project_is_skipped_with_warning(self, caplog, monkeypatch): + """A malformed project.yml must not abort loading of the others.""" + good_project = self._make_project_dir( + "good_project", + 'project_name: "good_project"\nlanguages: ["python"]\n', + ) + # Invalid YAML: a stray colon at the start of a mapping value. + bad_project = self._make_project_dir( + "bad_project", + ": this is not : valid : yaml :\n", + ) + self._write_master_config([good_project, bad_project]) + + # SerenaPaths is a process-wide singleton, so we cannot reliably + # redirect it via SERENA_HOME after the fact. Instead, redirect the + # config-file-path resolver directly. + monkeypatch.setattr( + SerenaConfig, + "_determine_config_file_path", + classmethod(lambda cls: str(self.master_config_path)), + ) + + with caplog.at_level(logging.ERROR): + config = SerenaConfig.from_config_file(generate_if_missing=False) + + registered_roots = {Path(p.project_root).resolve() for p in config.projects} + assert registered_roots == {good_project.resolve()}, f"Expected only the good project to be registered, got {registered_roots}" + assert any("Failed to load project configuration" in msg and str(bad_project.resolve()) in msg for msg in caplog.messages), ( + f"Expected a warning naming {bad_project.resolve()}, got: {caplog.messages}" + ) + + class TestMemoriesManagerCustomPath: """Tests for MemoriesManager with a custom serena data folder.""" diff --git a/test/serena/test_hooks.py b/test/serena/test_hooks.py index 25408f0d..bf7ca82c 100644 --- a/test/serena/test_hooks.py +++ b/test/serena/test_hooks.py @@ -13,7 +13,6 @@ from serena.hooks import ( PreToolUseAutoApproveSerenaHook, PreToolUseRemindAboutSerenaHook, SessionEndCleanupHook, - SessionStartActivateProjectHook, hook_commands, ) @@ -410,7 +409,7 @@ class TestToolUseCounter: class TestPreToolUseAutoApproveSerenaHook: - """Tests for the auto-approve hook that allows Serena tools while the client is in ``acceptEdits`` mode.""" + """Tests for the auto-approve hook that allows Serena tools while the client is in a permissive permission mode (``acceptEdits`` or ``auto``).""" @staticmethod def _approve_input( @@ -441,6 +440,27 @@ class TestPreToolUseAutoApproveSerenaHook: assert hook_output["permissionDecision"] == "allow" assert "acceptedits" in hook_output["permissionDecisionReason"].lower() + def test_approves_serena_tool_in_auto_mode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """When the tool is a Serena tool and the mode is ``auto``, an allow decision is emitted. + + Claude Code's ``auto`` mode is the hands-off-execution mode users adopt for autonomous + runs; Serena tool calls should be auto-approved there for the same reason as in + ``acceptEdits`` (Serena's destructive tools would otherwise still prompt per call). + """ + stdin_data = self._approve_input(permission_mode="auto") + with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute() + + output = capsys.readouterr().out.strip() + result = json.loads(output) + hook_output = result["hookSpecificOutput"] + assert hook_output["hookEventName"] == "PreToolUse" + assert hook_output["permissionDecision"] == "allow" + # the reason must identify the actual mode that triggered the approval, not just say + # "auto-approved" — the substring " auto " (with surrounding spaces) discriminates the + # mode name from the unrelated "Auto-approved" prefix. + assert " auto " in hook_output["permissionDecisionReason"] + def test_accepts_camel_case_permission_mode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): """The hook also reads the ``permissionMode`` (camelCase) variant of the field.""" stdin_data = self._approve_input(permission_mode_key="permissionMode") @@ -471,6 +491,27 @@ class TestPreToolUseAutoApproveSerenaHook: PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute() assert capsys.readouterr().out == "" + def test_stays_silent_in_bypass_permissions_mode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """``bypassPermissions`` already approves everything upstream in Claude Code, so the + hook deliberately does not emit an explicit allow there — it stays silent. + + This test pins that boundary: only ``acceptEdits`` and ``auto`` are active modes for the + hook; expanding it further requires a deliberate change here. + """ + stdin_data = self._approve_input(permission_mode="bypassPermissions") + with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute() + assert capsys.readouterr().out == "" + + def test_stays_silent_in_dont_ask_mode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """``dontAsk`` is the user's deny-by-default posture (auto-deny unless allow-listed); + the hook honors that choice and stays silent rather than overriding it. + """ + stdin_data = self._approve_input(permission_mode="dontAsk") + with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute() + assert capsys.readouterr().out == "" + def test_stays_silent_when_permission_mode_missing(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): """If ``permission_mode`` is missing from the input, the hook stays silent rather than erroring.""" stdin_data = self._approve_input(permission_mode=None) @@ -479,19 +520,6 @@ class TestPreToolUseAutoApproveSerenaHook: assert capsys.readouterr().out == "" -class TestSessionStartActivateProjectHook: - def test_outputs_activation_message(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): - stdin_data = {"session_id": "s1"} - with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)): - SessionStartActivateProjectHook(HookClient.VSCODE).execute() - - output = capsys.readouterr().out.strip() - result = json.loads(output) - context = result["hookSpecificOutput"]["additionalContext"] - assert "Activate" in context - assert "Serena Instructions Manual" in context - - class TestSessionEndCleanupHook: def test_removes_session_dir(self, tmp_path: Path): session_dir = tmp_path / "hook_data" / "cleanup-session" @@ -515,15 +543,6 @@ class TestSessionEndCleanupHook: class TestHookCli: """Tests for the Click CLI entry point (serena-hooks).""" - def test_activate_command(self, tmp_path: Path): - stdin_json = json.dumps({"session_id": "cli-test"}) - runner = CliRunner() - with patch("serena.hooks.serena_home_dir", str(tmp_path)): - result = runner.invoke(hook_commands, ["activate", "--client", "vscode"], input=stdin_json) - assert result.exit_code == 0 - output = json.loads(result.output) - assert "Activate" in output["hookSpecificOutput"]["additionalContext"] - def test_cleanup_command(self, tmp_path: Path): session_dir = tmp_path / "hook_data" / "cli-cleanup" session_dir.mkdir(parents=True) @@ -565,6 +584,23 @@ class TestHookCli: output = json.loads(result.output) assert output["hookSpecificOutput"]["permissionDecision"] == "allow" + def test_auto_approve_command_in_auto_mode(self, tmp_path: Path): + """The ``auto-approve`` CLI command emits an allow for a Serena tool in ``auto`` mode.""" + stdin_json = json.dumps( + { + "session_id": "cli-auto-approve-auto-mode", + "tool_name": "mcp__serena__find_symbol", + "tool_input": {}, + "permission_mode": "auto", + } + ) + runner = CliRunner() + with patch("serena.hooks.serena_home_dir", str(tmp_path)): + result = runner.invoke(hook_commands, ["auto-approve", "--client", "claude-code"], input=stdin_json) + assert result.exit_code == 0 + output = json.loads(result.output) + assert output["hookSpecificOutput"]["permissionDecision"] == "allow" + def test_auto_approve_command_stays_silent_in_default_mode(self, tmp_path: Path): """The ``auto-approve`` CLI command emits nothing when the mode is not ``acceptEdits``.""" stdin_json = json.dumps( diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 896b030f..3e1829f3 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -5,9 +5,12 @@ import re import time from collections.abc import Iterator from contextlib import contextmanager -from typing import Literal +from copy import copy +from dataclasses import dataclass +from typing import Literal, cast import pytest +from _pytest.mark import Mark, MarkDecorator, ParameterSet from serena.agent import SerenaAgent from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig @@ -15,8 +18,12 @@ from serena.project import Project from serena.tools import ( SUCCESS_RESULT, ActivateProjectTool, + EditingToolWithDiagnostics, + FindDeclarationTool, + FindImplementationsTool, FindReferencingSymbolsTool, FindSymbolTool, + GetDiagnosticsForFileTool, InitialInstructionsTool, ReplaceContentTool, ReplaceSymbolBodyTool, @@ -25,8 +32,728 @@ from serena.tools import ( ) from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind -from test.conftest import get_repo_path, is_ci, language_tests_enabled -from test.solidlsp import clojure as clj +from test.conftest import ( + find_identifier_pos, + get_pytest_markers, + get_repo_path, + is_ci, + language_tests_enabled, +) + + +@dataclass +class BaseCase: + language: Language + id: str + + def to_pytest_param(self, *marks: MarkDecorator | Mark) -> ParameterSet: + return pytest.param(self.language, self, marks=[*get_pytest_markers(self.language), *marks], id=self.id) # type: ignore + + +@dataclass +class FindSymbolCase(BaseCase): + symbol_name: str + expected_kind: str + expected_file: str + + +@dataclass +class FindReferenceCase(BaseCase): + symbol_name: str + definition_file: str + reference_file: str + + +@dataclass +class FindDefiningSymbolCase(BaseCase): + relative_path: str + identifier: str + occurrence_index: int + column_offset: int + expected_name: str + expected_definition_file: str + + +@dataclass +class RegexDefiningSymbolCase(BaseCase): + relative_path: str + regex: str + containing_symbol_name_path: str + expected_name: str + expected_definition_file: str + + +@dataclass +class RegexDefiningSymbolErrorCase(BaseCase): + relative_path: str + regex: str + containing_symbol_name_path: str + error_fragment: str + + +@dataclass +class FindImplementationCase(BaseCase): + symbol_name: str + definition_file: str + implementation_file: str + expected_symbol_name: str + + +@dataclass +class FindSymbolNamePathCase(BaseCase): + name_path: str + substring_matching: bool + expected_symbol_name: str + expected_kind: str + expected_file: str + + +@dataclass +class FindSymbolNoMatchCase(BaseCase): + name_path: str + + +@dataclass +class FindSymbolOverloadedCase(BaseCase): + name_path: str + num_expected: int + + +@dataclass +class NonUniqueSymbolReferenceCase(BaseCase): + name_path: str + relative_path: str + expected_error_fragment: str = "multiple" + + +@dataclass +class SafeDeleteCase(BaseCase): + name_path: str + relative_path: str + + def __post_init__(self) -> None: + object.__setattr__(self, "relative_path", self.relative_path.replace("\\", "/")) + + +@dataclass +class DiagnosticCase(BaseCase): + language: Language + relative_path: str + name_path1: str + name_path2: str | None + message_fragment1: str + message_fragment2: str | None + + @property + def symbol1_id_str(self) -> str: + return self.name_path1.split("/")[-1] + + @property + def symbol2_id_str(self) -> str | None: + if self.name_path2 is None: + return None + return self.name_path2.split("/")[-1] + + def without_second_symbol(self) -> "DiagnosticCase": + result = copy(self) + result.name_path2 = None + result.message_fragment2 = None + return result + + def assert_matches(self, tool_output: dict) -> None: + """ + + :param tool_output: Output of diagnostics tool, representing the mapping `relative_path -> severity -> name_path -> diagnostics_results`. + :return: + """ + assert self.relative_path in tool_output, ( + f"Missing diagnostics for relative path {self.relative_path} in tool output keys: {list(tool_output.keys())}" + ) + severity_group = tool_output[self.relative_path] + assert "Error" in severity_group, severity_group + name_path_group = severity_group["Error"] + + for expected_name_path in [self.name_path1, self.name_path2]: + if expected_name_path is not None: + assert expected_name_path in name_path_group, name_path_group + + diagnostic_messages = [ + diagnostic["message"] for diagnostics_for_name_path in name_path_group.values() for diagnostic in diagnostics_for_name_path + ] + for expected_fragment in [self.message_fragment1, self.message_fragment2]: + if expected_fragment is not None: + assert any(expected_fragment in message for message in diagnostic_messages), diagnostic_messages + + +DIAGNOSTIC_CASES = [ + DiagnosticCase( + language=Language.PYTHON, + id=f"{Language.PYTHON.value}_missing_user", + relative_path=os.path.join("test_repo", "diagnostics_sample.py"), + name_path1="broken_factory", + name_path2="broken_consumer", + message_fragment1="missing_user", + message_fragment2="undefined_name", + ).to_pytest_param(), + DiagnosticCase( + language=Language.CLOJURE, + id=f"{Language.CLOJURE.value}_missing-greeting", + relative_path=os.path.join("src", "test_app", "diagnostics_sample.clj"), + name_path1="broken-factory", + name_path2="broken-consumer", + message_fragment1="missing-greeting", + message_fragment2="missing-consumer-value", + ).to_pytest_param(), + DiagnosticCase( + language=Language.GO, + id=f"{Language.GO.value}_missingGreeting", + relative_path="diagnostics_sample.go", + name_path1="brokenFactory", + name_path2="brokenConsumer", + message_fragment1="missingGreeting", + message_fragment2="missingConsumerValue", + ).to_pytest_param(), + DiagnosticCase( + language=Language.TYPESCRIPT, + id=f"{Language.TYPESCRIPT.value}_missingGreeting", + relative_path="diagnostics_sample.ts", + name_path1="brokenFactory", + name_path2="brokenConsumer", + message_fragment1="missingGreeting", + message_fragment2="missingConsumerValue", + ).to_pytest_param(), +] + + +FIND_DEFINING_SYMBOL_CASES = [ + FindDefiningSymbolCase( + language=Language.PYTHON, + id="python_user_in_services", + relative_path=os.path.join("test_repo", "services.py"), + identifier="User", + occurrence_index=1, + column_offset=1, + expected_name="User", + expected_definition_file="models.py", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.PYTHON_TY, + id="python_ty_user_in_services", + relative_path=os.path.join("test_repo", "services.py"), + identifier="User", + occurrence_index=1, + column_offset=1, + expected_name="User", + expected_definition_file="models.py", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.GO, + id="go_helper_in_main", + relative_path="main.go", + identifier="Helper", + occurrence_index=0, + column_offset=1, + expected_name="Helper", + expected_definition_file="main.go", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.JAVA, + id="java_model_in_main", + relative_path=os.path.join("src", "main", "java", "test_repo", "Main.java"), + identifier="Model", + occurrence_index=0, + column_offset=1, + expected_name="Model", + expected_definition_file="Model.java", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.KOTLIN, + id="kotlin_model_in_main", + relative_path=os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"), + identifier="Model", + occurrence_index=0, + column_offset=1, + expected_name="Model", + expected_definition_file="Model.kt", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.RUST, + id="rust_format_greeting", + relative_path=os.path.join("src", "main.rs"), + identifier="format_greeting", + occurrence_index=0, + column_offset=1, + expected_name="format_greeting", + expected_definition_file="lib.rs", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.PHP, + id="php_helper_function", + relative_path="index.php", + identifier="helperFunction", + occurrence_index=0, + column_offset=5, + expected_name="helperFunction", + expected_definition_file="helper.php", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.CLOJURE, + id="clojure_multiply_in_utils", + relative_path=os.path.join("src", "test_app", "utils.clj"), + identifier="multiply", + occurrence_index=0, + column_offset=1, + expected_name="multiply", + expected_definition_file=os.path.join("src", "test_app", "core.clj"), + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.CSHARP, + id="csharp_add_in_program", + relative_path="Program.cs", + identifier="Add", + occurrence_index=0, + column_offset=1, + expected_name="Add", + expected_definition_file="Program.cs", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.POWERSHELL, + id="powershell_convert_to_uppercase", + relative_path="main.ps1", + identifier="Convert-ToUpperCase", + occurrence_index=0, + column_offset=1, + expected_name="function Convert-ToUpperCase ()", + expected_definition_file="utils.ps1", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.CPP_CCLS, + id="cpp_add_in_a", + relative_path="a.cpp", + identifier="add", + occurrence_index=0, + column_offset=1, + expected_name="add", + expected_definition_file="b.cpp", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.LEAN4, + id="lean_add_in_main", + relative_path="Main.lean", + identifier="add", + occurrence_index=1, + column_offset=1, + expected_name="add", + expected_definition_file="Helper.lean", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.TYPESCRIPT, + id="typescript_helper_function", + relative_path="index.ts", + identifier="helperFunction", + occurrence_index=1, + column_offset=1, + expected_name="helperFunction", + expected_definition_file="index.ts", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.FSHARP, + id="fsharp_add_in_program", + relative_path="Program.fs", + identifier="add", + occurrence_index=0, + column_offset=1, + expected_name="add", + expected_definition_file="Calculator.fs", + ).to_pytest_param( + pytest.mark.xfail(reason="F# language server cannot reliably resolve defining symbols"), + ), +] + +FIND_DEFINING_SYMBOL_REGEX_CASES = [ + RegexDefiningSymbolCase( + language=Language.PYTHON, + id="python_import_user", + relative_path=os.path.join("test_repo", "services.py"), + regex=r"from \.models import Item, (User)", + containing_symbol_name_path="", + expected_name="User", + expected_definition_file="models.py", + ).to_pytest_param(), + RegexDefiningSymbolCase( + language=Language.PYTHON, + id="python_create_user_call", + relative_path=os.path.join("test_repo", "services.py"), + regex=r"=\s+(User)\(", + containing_symbol_name_path="UserService/create_user", + expected_name="User", + expected_definition_file="models.py", + ).to_pytest_param(), + RegexDefiningSymbolCase( + language=Language.PYTHON_TY, + id="python_ty_create_user_call", + relative_path=os.path.join("test_repo", "services.py"), + regex=r"=\s+(User)\(", + containing_symbol_name_path="UserService/create_user", + expected_name="User", + expected_definition_file="models.py", + ).to_pytest_param(), + RegexDefiningSymbolCase( + language=Language.GO, + id="go_greeter_var", + relative_path="main.go", + regex=r"var greeter (Greeter) =", + containing_symbol_name_path="main", + expected_name="Greeter", + expected_definition_file="main.go", + ).to_pytest_param(), +] + +FIND_IMPLEMENTATION_CASES = [ + FindImplementationCase( + language=Language.CSHARP, + id="csharp_greeter_format", + symbol_name="IGreeter/FormatGreeting", + definition_file=os.path.join("Services", "IGreeter.cs"), + implementation_file=os.path.join("Services", "ConsoleGreeter.cs"), + expected_symbol_name="FormatGreeting", + ).to_pytest_param(), + FindImplementationCase( + language=Language.GO, + id="go_greeter_format", + symbol_name="Greeter/FormatGreeting", + definition_file="main.go", + implementation_file="main.go", + expected_symbol_name="FormatGreeting", + ).to_pytest_param(), + FindImplementationCase( + language=Language.JAVA, + id="java_greeter_format", + symbol_name="Greeter/formatGreeting", + definition_file=os.path.join("src", "main", "java", "test_repo", "Greeter.java"), + implementation_file=os.path.join("src", "main", "java", "test_repo", "ConsoleGreeter.java"), + expected_symbol_name="formatGreeting", + ).to_pytest_param(), + FindImplementationCase( + language=Language.RUST, + id="rust_greeter_format", + symbol_name="Greeter/format_greeting", + definition_file=os.path.join("src", "lib.rs"), + implementation_file=os.path.join("src", "lib.rs"), + expected_symbol_name="format_greeting", + ).to_pytest_param(), + FindImplementationCase( + language=Language.TYPESCRIPT, + id="typescript_greeter_format", + symbol_name="Greeter/formatGreeting", + definition_file="formatters.ts", + implementation_file="formatters.ts", + expected_symbol_name="formatGreeting", + ).to_pytest_param(), +] + + +FIND_SYMBOL_REFERENCES_CASES = [ + FindSymbolCase( + language=Language.PYTHON, id="python_user_class", symbol_name="User", expected_kind="Class", expected_file="models.py" + ).to_pytest_param(), + FindSymbolCase( + language=Language.GO, id="go_helper_function", symbol_name="Helper", expected_kind="Function", expected_file="main.go" + ).to_pytest_param(), + FindSymbolCase( + language=Language.JAVA, id="java_model_class", symbol_name="Model", expected_kind="Class", expected_file="Model.java" + ).to_pytest_param(), + FindSymbolCase( + language=Language.KOTLIN, id="kotlin_model_struct", symbol_name="Model", expected_kind="Struct", expected_file="Model.kt" + ).to_pytest_param(), + FindSymbolCase( + language=Language.TYPESCRIPT, + id="typescript_demo_class", + symbol_name="DemoClass", + expected_kind="Class", + expected_file="index.ts", + ).to_pytest_param(), + FindSymbolCase( + language=Language.PHP, + id="php_helper_function", + symbol_name="helperFunction", + expected_kind="Function", + expected_file="helper.php", + ).to_pytest_param(), + FindSymbolCase( + language=Language.CLOJURE, + id="clojure_greet_function", + symbol_name="greet", + expected_kind="Function", + expected_file=os.path.join("src", "test_app", "core.clj"), + ).to_pytest_param(), + FindSymbolCase( + language=Language.CSHARP, + id="csharp_calculator_class", + symbol_name="Calculator", + expected_kind="Class", + expected_file="Program.cs", + ).to_pytest_param(), + FindSymbolCase( + language=Language.POWERSHELL, + id="powershell_greet_user", + symbol_name="Greet-User", + expected_kind="Function", + expected_file="main.ps1", + ).to_pytest_param(), + FindSymbolCase( + language=Language.CPP_CCLS, id="cpp_add_function", symbol_name="add", expected_kind="Function", expected_file="b.cpp" + ).to_pytest_param(), + FindSymbolCase( + language=Language.LEAN4, id="lean_add_method", symbol_name="add", expected_kind="Method", expected_file="Helper.lean" + ).to_pytest_param(), + FindSymbolCase( + language=Language.FSHARP, + id="fsharp_calculator_module", + symbol_name="Calculator", + expected_kind="Module", + expected_file="Calculator.fs", + ).to_pytest_param(pytest.mark.xfail(reason="F# language server is unreliable")), + FindSymbolCase( + language=Language.RUST, id="rust_add_function", symbol_name="add", expected_kind="Function", expected_file="lib.rs" + ).to_pytest_param(), +] + +FIND_REFERENCE_CASES = [ + FindReferenceCase( + language=Language.PYTHON, + id="python_user_refs", + symbol_name="User", + definition_file=os.path.join("test_repo", "models.py"), + reference_file=os.path.join("test_repo", "services.py"), + ).to_pytest_param(), + FindReferenceCase( + language=Language.GO, id="go_helper_refs", symbol_name="Helper", definition_file="main.go", reference_file="main.go" + ).to_pytest_param(), + FindReferenceCase( + language=Language.JAVA, + id="java_model_refs", + symbol_name="Model", + definition_file=os.path.join("src", "main", "java", "test_repo", "Model.java"), + reference_file=os.path.join("src", "main", "java", "test_repo", "Main.java"), + ).to_pytest_param(), + FindReferenceCase( + language=Language.KOTLIN, + id="kotlin_model_refs", + symbol_name="Model", + definition_file=os.path.join("src", "main", "kotlin", "test_repo", "Model.kt"), + reference_file=os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"), + ).to_pytest_param(), + FindReferenceCase( + language=Language.RUST, + id="rust_add_refs", + symbol_name="add", + definition_file=os.path.join("src", "lib.rs"), + reference_file=os.path.join("src", "main.rs"), + ).to_pytest_param(), + FindReferenceCase( + language=Language.PHP, + id="php_helper_refs", + symbol_name="helperFunction", + definition_file="helper.php", + reference_file="index.php", + ).to_pytest_param(), + FindReferenceCase( + language=Language.CLOJURE, + id="clojure_multiply_refs", + symbol_name="multiply", + definition_file=os.path.join("src", "test_app", "core.clj"), + reference_file=os.path.join("src", "test_app", "utils.clj"), + ).to_pytest_param(), + FindReferenceCase( + language=Language.CSHARP, + id="csharp_calculator_refs", + symbol_name="Calculator", + definition_file="Program.cs", + reference_file="Program.cs", + ).to_pytest_param(), + FindReferenceCase( + language=Language.POWERSHELL, + id="powershell_greet_user_refs", + symbol_name="Greet-User", + definition_file="main.ps1", + reference_file="main.ps1", + ).to_pytest_param(), + FindReferenceCase( + language=Language.CPP_CCLS, id="cpp_add_refs", symbol_name="add", definition_file="b.cpp", reference_file="a.cpp" + ).to_pytest_param(), + FindReferenceCase( + language=Language.LEAN4, id="lean_add_refs", symbol_name="add", definition_file="Helper.lean", reference_file="Main.lean" + ).to_pytest_param(), + FindReferenceCase( + language=Language.TYPESCRIPT, + id="typescript_helper_refs", + symbol_name="helperFunction", + definition_file="index.ts", + reference_file="use_helper.ts", + ).to_pytest_param(pytest.mark.xfail(False, reason="TypeScript language server is unreliable")), + FindReferenceCase( + language=Language.FSHARP, id="fsharp_add_refs", symbol_name="add", definition_file="Calculator.fs", reference_file="Program.fs" + ).to_pytest_param( + pytest.mark.xfail(reason="F# language server is unreliable"), # See issue #1040 + ), +] + +FIND_DEFINING_SYMBOL_REGEX_ERROR_CASES = [ + RegexDefiningSymbolErrorCase( + language=Language.PYTHON, + id="python_regex_multiple_matches", + relative_path=os.path.join("test_repo", "services.py"), + regex=r"(User)", + containing_symbol_name_path="", + error_fragment="Match must be unique", + ).to_pytest_param(), + RegexDefiningSymbolErrorCase( + language=Language.PYTHON, + id="python_regex_missing_group", + relative_path=os.path.join("test_repo", "services.py"), + regex=r"self.users.get\(id\)", + containing_symbol_name_path="UserService/get_user", + error_fragment="Regex must contain exactly one group", + ).to_pytest_param(), +] + +FIND_SYMBOL_NAME_PATH_CASES = [ + FindSymbolNamePathCase( + language=Language.PYTHON, + id="nested_class_exact", + name_path="OuterClass/NestedClass", + substring_matching=False, + expected_symbol_name="NestedClass", + expected_kind="Class", + expected_file=os.path.join("test_repo", "nested.py"), + ).to_pytest_param(), + FindSymbolNamePathCase( + language=Language.PYTHON, + id="nested_method_exact", + name_path="OuterClass/NestedClass/find_me", + substring_matching=False, + expected_symbol_name="find_me", + expected_kind="Method", + expected_file=os.path.join("test_repo", "nested.py"), + ).to_pytest_param(), + FindSymbolNamePathCase( + language=Language.PYTHON, + id="nested_class_substring", + name_path="OuterClass/NestedCl", + substring_matching=True, + expected_symbol_name="NestedClass", + expected_kind="Class", + expected_file=os.path.join("test_repo", "nested.py"), + ).to_pytest_param(), + FindSymbolNamePathCase( + language=Language.PYTHON, + id="nested_method_substring", + name_path="OuterClass/NestedClass/find_m", + substring_matching=True, + expected_symbol_name="find_me", + expected_kind="Method", + expected_file=os.path.join("test_repo", "nested.py"), + ).to_pytest_param(), + FindSymbolNamePathCase( + language=Language.PYTHON, + id="outer_class_absolute", + name_path="/OuterClass", + substring_matching=False, + expected_symbol_name="OuterClass", + expected_kind="Class", + expected_file=os.path.join("test_repo", "nested.py"), + ).to_pytest_param(), + FindSymbolNamePathCase( + language=Language.PYTHON, + id="nested_method_absolute_substring", + name_path="/OuterClass/NestedClass/find_m", + substring_matching=True, + expected_symbol_name="find_me", + expected_kind="Method", + expected_file=os.path.join("test_repo", "nested.py"), + ).to_pytest_param(), +] + +FIND_SYMBOL_NAME_PATH_NO_MATCH_CASES = [ + FindSymbolNoMatchCase(language=Language.PYTHON, id="nested_class_not_top_level", name_path="/NestedClass").to_pytest_param(), + FindSymbolNoMatchCase( + language=Language.PYTHON, id="nested_class_missing_parent", name_path="/NoSuchParent/NestedClass" + ).to_pytest_param(), +] + +FIND_SYMBOL_OVERLOADED_FUNCTION_CASES = [ + FindSymbolOverloadedCase( + language=Language.JAVA, id="java_overloaded_get_name", name_path="Model/getName", num_expected=2 + ).to_pytest_param(), +] + +NON_UNIQUE_SYMBOL_REFERENCE_ERROR_CASES = [ + NonUniqueSymbolReferenceCase( + language=Language.JAVA, + id="java_overloaded_get_name", + name_path="Model/getName", + relative_path=os.path.join("src", "main", "java", "test_repo", "Model.java"), + ).to_pytest_param(), +] + +SAFE_DELETE_BLOCKED_CASES = [ + SafeDeleteCase( + language=Language.PYTHON, + id="python_user", + name_path="User", + relative_path=os.path.join("test_repo", "models.py"), + ).to_pytest_param(), + SafeDeleteCase( + language=Language.JAVA, + id="java_model", + name_path="Model", + relative_path=os.path.join("src", "main", "java", "test_repo", "Model.java"), + ).to_pytest_param(), + SafeDeleteCase( + language=Language.KOTLIN, + id="kotlin_model", + name_path="Model", + relative_path=os.path.join("src", "main", "kotlin", "test_repo", "Model.kt"), + ).to_pytest_param( + *([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), + ), + SafeDeleteCase( + language=Language.TYPESCRIPT, + id="typescript_helper_function", + name_path="helperFunction", + relative_path="index.ts", + ).to_pytest_param(), +] + +SAFE_DELETE_SUCCEEDS_CASES = [ + SafeDeleteCase( + language=Language.PYTHON, + id="python_timer", + name_path="Timer", + relative_path=os.path.join("test_repo", "utils.py"), + ).to_pytest_param(), + SafeDeleteCase( + language=Language.JAVA, + id="java_model_user", + name_path="ModelUser", + relative_path=os.path.join("src", "main", "java", "test_repo", "ModelUser.java"), + ).to_pytest_param(), + SafeDeleteCase( + language=Language.KOTLIN, + id="kotlin_model_user", + name_path="ModelUser", + relative_path=os.path.join("src", "main", "kotlin", "test_repo", "ModelUser.kt"), + ).to_pytest_param( + *([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), + ), + SafeDeleteCase( + language=Language.TYPESCRIPT, + id="typescript_unused_standalone_function", + name_path="unusedStandaloneFunction", + relative_path="index.ts", + ).to_pytest_param(), +] @pytest.fixture @@ -37,6 +764,7 @@ def serena_config(): test_projects = [] for language in [ Language.PYTHON, + Language.PYTHON_TY, Language.GO, Language.JAVA, Language.KOTLIN, @@ -82,6 +810,13 @@ def read_project_file(project: Project, relative_path: str) -> str: return f.read() +def parse_edit_diagnostics_result(result: str) -> dict: + """Utility function to parse the diagnostic payload returned by edit tools.""" + assert EditingToolWithDiagnostics.DIAGNOSTICS_KEY in result + d = json.loads(result) + return d[EditingToolWithDiagnostics.DIAGNOSTICS_KEY] + + @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.""" @@ -119,7 +854,11 @@ def serena_agent(request: pytest.FixtureRequest, serena_config) -> Iterator[Sere class TestSerenaAgent: - @pytest.mark.parametrize("project", [None, str(get_repo_path(Language.PYTHON)), "non_existent_path"]) + @pytest.mark.parametrize( + "project", + [None, str(get_repo_path(Language.PYTHON)), "non_existent_path"], + ids=["no_project", "python_project_path", "invalid_project_path"], + ) def test_agent_instantiation(self, project: str | None): """ Tests agent instantiation for cases where @@ -131,113 +870,62 @@ class TestSerenaAgent: serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False) SerenaAgent(project=project, serena_config=serena_config) - def _assert_find_symbol(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str) -> None: - agent = serena_agent - find_symbol_tool = agent.get_tool(FindSymbolTool) - result = find_symbol_tool.apply(name_path_pattern=symbol_name, include_info=True) + def _symbol_matches_expected_name(self, symbol: dict, expected_name: str) -> bool: + return ( + symbol.get("name") == expected_name + or symbol.get("name_path", "").split("/")[-1] == expected_name + or expected_name in symbol.get("info", "") + ) - symbols = json.loads(result) - assert any( - symbol_name in s["name_path"] and expected_kind.lower() in s["kind"].lower() and expected_file in s["relative_path"] - for s in symbols - ), f"Expected to find {symbol_name} ({expected_kind}) in {expected_file}" - # testing retrieval of symbol info + def _assert_symbol_info_present( + self, + serena_agent: SerenaAgent, + symbol: dict, + expected_name: str | None = None, + ) -> None: if serena_agent.get_active_lsp_languages() == [Language.KOTLIN]: # kotlin LS doesn't seem to provide hover info right now, at least for the struct we test this on return - for s in symbols: - if s["kind"] in (SymbolKind.File.name, SymbolKind.Module.name): - # we ignore file and module symbols for the info test - continue - symbol_info = s.get("info") - assert symbol_info, f"Expected symbol info to be present for symbol: {s}" - assert symbol_name in s["info"], ( - f"[{serena_agent.get_active_lsp_languages()[0]}] Expected symbol info to contain symbol name {symbol_name}. Info: {s['info']}" + + if symbol["kind"] in (SymbolKind.File.name, SymbolKind.Module.name): + # we ignore file and module symbols for the info test + return + + symbol_info = symbol.get("info") + assert symbol_info, f"Expected symbol info to be present for symbol: {symbol}" + + if expected_name is not None: + assert expected_name in symbol_info, ( + f"[{serena_agent.get_active_lsp_languages()[0]}] Expected symbol info to contain symbol name " + f"{expected_name}. Info: {symbol_info}" ) - # special additional test for Java, since Eclipse returns hover in a complex format and we want to make sure to get it right - if s["kind"] == SymbolKind.Class.name and serena_agent.get_active_lsp_languages() == [Language.JAVA]: - assert "A simple model class" in symbol_info, f"Java class docstring not found in symbol info: {s}" - @pytest.mark.php - @pytest.mark.parametrize("serena_agent", [Language.PHP], indirect=True) - def test_find_symbol_within_php_file(self, serena_agent: SerenaAgent) -> None: - """Verify find_symbol with a PHP file path routes to the PHP language server. + # special additional test for Java, since Eclipse returns hover in a complex format and we want to make sure to get it right + if symbol["kind"] == SymbolKind.Class.name and serena_agent.get_active_lsp_languages() == [Language.JAVA]: + assert "A simple model class" in symbol_info, f"Java class docstring not found in symbol info: {symbol}" - This validates the fix in symbol.py (LanguageServerSymbolRetriever.find_symbols): - when within_relative_path points to a PHP file, the retriever must use - get_language_server() rather than iterating all language servers. Without this - fix, non-PHP servers reject the PHP file and no symbols are returned. - """ - find_symbol_tool = serena_agent.get_tool(FindSymbolTool) - sample_php = "sample.php" - - result = find_symbol_tool.apply(name_path_pattern="Dog/greet", relative_path=sample_php) - symbols = json.loads(result) - - assert len(symbols) > 0, ( - f"Expected to find Dog/greet in {sample_php} but got empty result. " - "This may indicate that find_symbol is not routing to the PHP language server for PHP files." - ) - assert any("greet" in s["name_path"] and sample_php in s["relative_path"] for s in symbols), ( - f"Dog/greet not found in {sample_php}. Symbols: {symbols}" - ) - - @pytest.mark.parametrize( - "serena_agent,symbol_name,expected_kind,expected_file", - [ - pytest.param(Language.PYTHON, "User", "Class", "models.py", marks=pytest.mark.python), - pytest.param(Language.GO, "Helper", "Function", "main.go", marks=pytest.mark.go), - pytest.param(Language.JAVA, "Model", "Class", "Model.java", marks=pytest.mark.java), - pytest.param( - Language.KOTLIN, - "Model", - "Struct", - "Model.kt", - marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), - ), - pytest.param(Language.TYPESCRIPT, "DemoClass", "Class", "index.ts", marks=pytest.mark.typescript), - pytest.param(Language.PHP, "helperFunction", "Function", "helper.php", marks=pytest.mark.php), - pytest.param(Language.CLOJURE, "greet", "Function", clj.CORE_PATH, marks=pytest.mark.clojure), - pytest.param(Language.CSHARP, "Calculator", "Class", "Program.cs", marks=pytest.mark.csharp), - pytest.param(Language.POWERSHELL, "Greet-User", "Function", "main.ps1", marks=pytest.mark.powershell), - pytest.param(Language.CPP_CCLS, "add", "Function", "b.cpp", marks=pytest.mark.cpp), - pytest.param(Language.HAXE, "Main", "Class", "Main.hx", marks=pytest.mark.haxe), - pytest.param(Language.LEAN4, "add", "Method", "Helper.lean", marks=pytest.mark.lean4), - pytest.param(Language.MSL, "greet", "Function", "main.mrc", marks=pytest.mark.msl), - ], - indirect=["serena_agent"], - ) - def test_find_symbol_stable(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str) -> None: - self._assert_find_symbol(serena_agent, symbol_name, expected_kind, expected_file) - - @pytest.mark.parametrize( - "serena_agent,symbol_name,expected_kind,expected_file", - [ - pytest.param(Language.FSHARP, "Calculator", "Module", "Calculator.fs", marks=pytest.mark.fsharp), - ], - indirect=["serena_agent"], - ) - @pytest.mark.xfail(reason="F# language server is unreliable") # See issue #1040 - def test_find_symbol_fsharp(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str) -> None: - self._assert_find_symbol(serena_agent, symbol_name, expected_kind, expected_file) - - @pytest.mark.parametrize( - "serena_agent,symbol_name,expected_kind,expected_file", - [ - pytest.param(Language.RUST, "add", "Function", "lib.rs", marks=pytest.mark.rust), - ], - indirect=["serena_agent"], - ) - @pytest.mark.xfail(reason="Rust language server is unreliable") # See issue #1040 - def test_find_symbol_rust(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str) -> None: - self._assert_find_symbol(serena_agent, symbol_name, expected_kind, expected_file) - - def _assert_find_symbol_references(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None: + @pytest.mark.parametrize("serena_agent,case", FIND_SYMBOL_REFERENCES_CASES, indirect=["serena_agent"]) + def test_find_symbol(self, serena_agent: SerenaAgent, case: FindSymbolCase) -> None: agent = serena_agent - - # Find the symbol location first find_symbol_tool = agent.get_tool(FindSymbolTool) - result = find_symbol_tool.apply(name_path_pattern=symbol_name, relative_path=def_file) + result = find_symbol_tool.apply(name_path_pattern=case.symbol_name, include_info=True) + symbols = json.loads(result) + assert any( + case.symbol_name in s["name_path"] + and case.expected_kind.lower() in s["kind"].lower() + and case.expected_file in s["relative_path"] + for s in symbols + ), ( + f"Expected to find {case.symbol_name} ({case.expected_kind}) in {case.expected_file}. Found name paths: {[s['name_path'] for s in symbols]}" + ) + for symbol in symbols: + self._assert_symbol_info_present(serena_agent, symbol, case.symbol_name) + + @pytest.mark.parametrize("serena_agent,case", FIND_REFERENCE_CASES, indirect=["serena_agent"]) + def test_find_symbol_references(self, serena_agent: SerenaAgent, case: FindReferenceCase) -> None: + # Find the symbol location first + find_symbol_tool = serena_agent.get_tool(FindSymbolTool) + result = find_symbol_tool.apply(name_path_pattern=case.symbol_name, relative_path=case.definition_file) time.sleep(1) symbols = json.loads(result) @@ -245,12 +933,12 @@ class TestSerenaAgent: def_symbol = symbols[0] # Now find references - find_refs_tool = agent.get_tool(FindReferencingSymbolsTool) + find_refs_tool = serena_agent.get_tool(FindReferencingSymbolsTool) result = find_refs_tool.apply(name_path=def_symbol["name_path"], relative_path=def_symbol["relative_path"]) def contains_ref_with_relative_path(refs, relative_path): """ - Checks for reference to relative path, regardless of output format (grouped an ungrouped) + Checks for reference to relative path, regardless of output format (grouped or ungrouped) """ if isinstance(refs, list): for ref in refs: @@ -265,227 +953,127 @@ class TestSerenaAgent: return False refs = json.loads(result) - assert contains_ref_with_relative_path(refs, ref_file), f"Expected to find reference to {symbol_name} in {ref_file}. refs={refs}" + assert contains_ref_with_relative_path(refs, case.reference_file), ( + f"Expected to find reference to {case.symbol_name} in {case.reference_file}. refs={refs}" + ) - @pytest.mark.parametrize( - "serena_agent,symbol_name,def_file,ref_file", - [ - pytest.param( - Language.PYTHON, - "User", - os.path.join("test_repo", "models.py"), - os.path.join("test_repo", "services.py"), - marks=pytest.mark.python, - ), - pytest.param(Language.GO, "Helper", "main.go", "main.go", marks=pytest.mark.go), - pytest.param( - Language.JAVA, - "Model", - os.path.join("src", "main", "java", "test_repo", "Model.java"), - os.path.join("src", "main", "java", "test_repo", "Main.java"), - marks=pytest.mark.java, - ), - pytest.param( - Language.KOTLIN, - "Model", - os.path.join("src", "main", "kotlin", "test_repo", "Model.kt"), - os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"), - marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), - ), - pytest.param(Language.RUST, "add", os.path.join("src", "lib.rs"), os.path.join("src", "main.rs"), marks=pytest.mark.rust), - pytest.param(Language.PHP, "helperFunction", "helper.php", "index.php", marks=pytest.mark.php), - pytest.param( - Language.CLOJURE, - "multiply", - clj.CORE_PATH, - clj.UTILS_PATH, - marks=pytest.mark.clojure, - ), - pytest.param(Language.CSHARP, "Calculator", "Program.cs", "Program.cs", marks=pytest.mark.csharp), - pytest.param(Language.POWERSHELL, "Greet-User", "main.ps1", "main.ps1", marks=pytest.mark.powershell), - pytest.param(Language.CPP_CCLS, "add", "b.cpp", "a.cpp", marks=pytest.mark.cpp), - pytest.param( - Language.HAXE, - "addNumbers", - os.path.join("src", "utils", "Helper.hx"), - os.path.join("src", "Main.hx"), - marks=pytest.mark.haxe, - ), - pytest.param(Language.LEAN4, "add", "Helper.lean", "Main.lean", marks=pytest.mark.lean4), - pytest.param(Language.MSL, "format.coins", "utils.mrc", "main.mrc", marks=pytest.mark.msl), - ], - indirect=["serena_agent"], - ) - def test_find_symbol_references_stable(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None: - self._assert_find_symbol_references(serena_agent, symbol_name, def_file, ref_file) + @pytest.mark.parametrize("serena_agent,case", FIND_DEFINING_SYMBOL_REGEX_CASES, indirect=["serena_agent"]) + def test_find_declaration(self, serena_agent: SerenaAgent, case: RegexDefiningSymbolCase) -> None: + tool = serena_agent.get_tool(FindDeclarationTool) + result = tool.apply( + regex=case.regex, + relative_path=case.relative_path, + containing_symbol_name_path=case.containing_symbol_name_path, + include_info=True, + ) + defining_symbol = json.loads(result) + assert defining_symbol is not None, f"Expected defining symbol for regex {case.regex!r} in {case.relative_path}" + assert defining_symbol.get("relative_path") is not None + assert case.expected_definition_file in defining_symbol["relative_path"], ( + f"Expected defining symbol in {case.expected_definition_file!r}, got: {defining_symbol}" + ) + assert self._symbol_matches_expected_name(defining_symbol, case.expected_name), ( + f"Expected defining symbol name {case.expected_name!r}, got: {defining_symbol}" + ) + self._assert_symbol_info_present(serena_agent, defining_symbol) - @pytest.mark.parametrize( - "serena_agent,symbol_name,def_file,ref_file", - [ - pytest.param(Language.TYPESCRIPT, "helperFunction", "index.ts", "use_helper.ts", marks=pytest.mark.typescript), - ], - indirect=["serena_agent"], - ) - @pytest.mark.xfail(False, reason="TypeScript language server is unreliable") # NOTE: Testing; may be resolved by #1120; See issue #1040 - def test_find_symbol_references_typescript(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None: - self._assert_find_symbol_references(serena_agent, symbol_name, def_file, ref_file) - - @pytest.mark.parametrize( - "serena_agent,symbol_name,def_file,ref_file", - [ - pytest.param(Language.FSHARP, "add", "Calculator.fs", "Program.fs", marks=pytest.mark.fsharp), - ], - indirect=["serena_agent"], - ) - @pytest.mark.xfail(reason="F# language server is unreliable") # See issue #1040 - def test_find_symbol_references_fsharp(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None: - self._assert_find_symbol_references(serena_agent, symbol_name, def_file, ref_file) - - @pytest.mark.parametrize( - "serena_agent,name_path,substring_matching,expected_symbol_name,expected_kind,expected_file", - [ - pytest.param( - Language.PYTHON, - "OuterClass/NestedClass", - False, - "NestedClass", - "Class", - os.path.join("test_repo", "nested.py"), - id="exact_qualname_class", - marks=pytest.mark.python, - ), - pytest.param( - Language.PYTHON, - "OuterClass/NestedClass/find_me", - False, - "find_me", - "Method", - os.path.join("test_repo", "nested.py"), - id="exact_qualname_method", - marks=pytest.mark.python, - ), - pytest.param( - Language.PYTHON, - "OuterClass/NestedCl", # Substring for NestedClass - True, - "NestedClass", - "Class", - os.path.join("test_repo", "nested.py"), - id="substring_qualname_class", - marks=pytest.mark.python, - ), - pytest.param( - Language.PYTHON, - "OuterClass/NestedClass/find_m", # Substring for find_me - True, - "find_me", - "Method", - os.path.join("test_repo", "nested.py"), - id="substring_qualname_method", - marks=pytest.mark.python, - ), - pytest.param( - Language.PYTHON, - "/OuterClass", # Absolute path - False, - "OuterClass", - "Class", - os.path.join("test_repo", "nested.py"), - id="absolute_qualname_class", - marks=pytest.mark.python, - ), - pytest.param( - Language.PYTHON, - "/OuterClass/NestedClass/find_m", # Absolute path with substring - True, - "find_me", - "Method", - os.path.join("test_repo", "nested.py"), - id="absolute_substring_qualname_method", - marks=pytest.mark.python, - ), - ], - indirect=["serena_agent"], - ) - def test_find_symbol_name_path( + @pytest.mark.parametrize("serena_agent,case", FIND_DEFINING_SYMBOL_REGEX_ERROR_CASES, indirect=["serena_agent"]) + def test_find_declaration_error( self, - serena_agent, - name_path: str, - substring_matching: bool, - expected_symbol_name: str, - expected_kind: str, - expected_file: str, - ): + serena_agent: SerenaAgent, + case: RegexDefiningSymbolErrorCase, + ) -> None: + tool = serena_agent.get_tool(FindDeclarationTool) + with pytest.raises(ValueError, match=case.error_fragment): + tool.apply( + regex=case.regex, + relative_path=case.relative_path, + containing_symbol_name_path=case.containing_symbol_name_path, + ) + + @pytest.mark.parametrize("serena_agent,diagnostic_case", DIAGNOSTIC_CASES, indirect=["serena_agent"]) + def test_get_diagnostics_for_file(self, serena_agent: SerenaAgent, diagnostic_case: DiagnosticCase) -> None: + diagnostics_tool = serena_agent.get_tool(GetDiagnosticsForFileTool) + result = diagnostics_tool.apply( + relative_path=diagnostic_case.relative_path, + min_severity=1, + ) + full_file_diagnostics = json.loads(result) + diagnostic_case.assert_matches(full_file_diagnostics) + + # testing diagnostics in range by removing second symbol + project_root = get_repo_path(diagnostic_case.language) + pos1 = find_identifier_pos(project_root / diagnostic_case.relative_path, diagnostic_case.symbol1_id_str) + pos2 = find_identifier_pos(project_root / diagnostic_case.relative_path, cast(str, diagnostic_case.symbol2_id_str)) + assert pos1 is not None + assert pos2 is not None + result = diagnostics_tool.apply( + relative_path=diagnostic_case.relative_path, + min_severity=1, + start_line=pos1[0], + end_line=pos2[0] - 1, + ) + diagnostics_in_range = json.loads(result) + diagnostic_case.without_second_symbol().assert_matches(diagnostics_in_range) + + @pytest.mark.parametrize("serena_agent,case", FIND_IMPLEMENTATION_CASES, indirect=["serena_agent"]) + def test_find_symbol_implementations(self, serena_agent: SerenaAgent, case: FindImplementationCase) -> None: + agent = serena_agent + find_symbol_tool = agent.get_tool(FindSymbolTool) + result = find_symbol_tool.apply(name_path_pattern=case.symbol_name, relative_path=case.definition_file) + symbols = json.loads(result) + assert symbols, f"Expected to find symbol {case.symbol_name} in {case.definition_file}" + def_symbol = symbols[0] + find_impl_tool = agent.get_tool(FindImplementationsTool) + result = find_impl_tool.apply(name_path=def_symbol["name_path"], relative_path=def_symbol["relative_path"], include_info=True) + implementations = json.loads(result) + assert any( + case.implementation_file in implementation["relative_path"] + and self._symbol_matches_expected_name(implementation, case.expected_symbol_name) + for implementation in implementations + ), f"Expected to find implementation of {case.symbol_name} in {case.implementation_file}. implementations={implementations}" + for implementation in implementations: + self._assert_symbol_info_present(serena_agent, implementation) + + @pytest.mark.parametrize("serena_agent,case", FIND_SYMBOL_NAME_PATH_CASES, indirect=["serena_agent"]) + def test_find_symbol_name_path(self, serena_agent: SerenaAgent, case: FindSymbolNamePathCase) -> None: agent = serena_agent find_symbol_tool = agent.get_tool(FindSymbolTool) result = find_symbol_tool.apply_ex( - name_path_pattern=name_path, + name_path_pattern=case.name_path, depth=0, relative_path=None, include_body=False, include_kinds=None, exclude_kinds=None, - substring_matching=substring_matching, + substring_matching=case.substring_matching, ) symbols = json.loads(result) assert any( - expected_symbol_name == s["name_path"].split("/")[-1] - and expected_kind.lower() in s["kind"].lower() - and expected_file in s["relative_path"] + case.expected_symbol_name == s["name_path"].split("/")[-1] + and case.expected_kind.lower() in s["kind"].lower() + and case.expected_file in s["relative_path"] for s in symbols - ), f"Expected to find {name_path} ({expected_kind}) in {expected_file}. Symbols: {symbols}" + ), f"Expected to find {case.name_path} ({case.expected_kind}) in {case.expected_file}. Symbols: {symbols}" - @pytest.mark.parametrize( - "serena_agent,name_path", - [ - pytest.param( - Language.PYTHON, - "/NestedClass", # Absolute path, NestedClass is not top-level - id="absolute_path_non_top_level_no_match", - marks=pytest.mark.python, - ), - pytest.param( - Language.PYTHON, - "/NoSuchParent/NestedClass", # Absolute path with non-existent parent - id="absolute_path_non_existent_parent_no_match", - marks=pytest.mark.python, - ), - ], - indirect=["serena_agent"], - ) - def test_find_symbol_name_path_no_match( - self, - serena_agent, - name_path: str, - ): + @pytest.mark.parametrize("serena_agent,case", FIND_SYMBOL_NAME_PATH_NO_MATCH_CASES, indirect=["serena_agent"]) + def test_find_symbol_name_path_no_match(self, serena_agent: SerenaAgent, case: FindSymbolNoMatchCase) -> None: agent = serena_agent find_symbol_tool = agent.get_tool(FindSymbolTool) result = find_symbol_tool.apply_ex( - name_path_pattern=name_path, + name_path_pattern=case.name_path, depth=0, substring_matching=True, ) symbols = json.loads(result) - assert not symbols, f"Expected to find no symbols for {name_path}. Symbols found: {symbols}" + assert not symbols, f"Expected to find no symbols for {case.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): + @pytest.mark.parametrize("serena_agent,case", FIND_SYMBOL_OVERLOADED_FUNCTION_CASES, indirect=["serena_agent"]) + def test_find_symbol_overloaded_function(self, serena_agent: SerenaAgent, case: FindSymbolOverloadedCase) -> None: """ Tests whether the FindSymbolTool can find all overloads of a function/method (provided that the overload id remains unspecified in the name path) @@ -494,51 +1082,38 @@ class TestSerenaAgent: find_symbol_tool = agent.get_tool(FindSymbolTool) result = find_symbol_tool.apply_ex( - name_path_pattern=name_path, + name_path_pattern=case.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}" + assert len(symbols) == case.num_expected, ( + f"Expected to find {case.num_expected} symbols for overloaded function {case.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): + @pytest.mark.parametrize("serena_agent,case", NON_UNIQUE_SYMBOL_REFERENCE_ERROR_CASES, indirect=["serena_agent"]) + def test_non_unique_symbol_reference_error( + self, + serena_agent: SerenaAgent, + case: NonUniqueSymbolReferenceCase, + ) -> None: """ 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) + with pytest.raises(ValueError, match=case.expected_error_fragment): + find_refs_tool.apply(name_path=case.name_path, relative_path=case.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="") + with pytest.raises(ValueError, match=case.expected_error_fragment): + replace_symbol_body_tool.apply(name_path=case.name_path, relative_path=case.relative_path, body="") @pytest.mark.parametrize( "serena_agent", [ - pytest.param( - Language.TYPESCRIPT, - marks=pytest.mark.typescript, - ), + pytest.param(Language.TYPESCRIPT, marks=get_pytest_markers(Language.TYPESCRIPT), id="typescript_unique_regex"), ], indirect=["serena_agent"], ) @@ -551,7 +1126,7 @@ class TestSerenaAgent: 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"); }', + repl='catch(error) {console.log("Never mind"); }', relative_path=relative_path, mode="regex", ) @@ -560,14 +1135,11 @@ class TestSerenaAgent: @pytest.mark.parametrize( "serena_agent", [ - pytest.param( - Language.TYPESCRIPT, - marks=pytest.mark.typescript, - ), + pytest.param(Language.TYPESCRIPT, marks=get_pytest_markers(Language.TYPESCRIPT), id="typescript_backslashes"), ], indirect=["serena_agent"], ) - @pytest.mark.parametrize("mode", ["literal", "regex"]) + @pytest.mark.parametrize("mode", ["literal", "regex"], ids=["literal_mode", "regex_mode"]) 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. @@ -591,10 +1163,71 @@ class TestSerenaAgent: @pytest.mark.parametrize( "serena_agent", [ - pytest.param( - Language.TYPESCRIPT, - marks=pytest.mark.typescript, - ), + pytest.param(Language.PYTHON, marks=get_pytest_markers(Language.PYTHON), id="python_services"), + pytest.param(Language.PYTHON_TY, marks=get_pytest_markers(Language.PYTHON_TY), id="python_ty_services"), + ], + indirect=["serena_agent"], + ) + def test_replace_content_reports_new_diagnostics(self, serena_agent: SerenaAgent): + """Tests that file-level edits report newly introduced diagnostics.""" + relative_path = os.path.join("test_repo", "services.py") + replace_content_tool = serena_agent.get_tool(ReplaceContentTool) + try: + replace_content_tool.ENABLE_DIAGNOSTICS = True + + with project_file_modification_context(serena_agent, relative_path): + result = replace_content_tool.apply( + relative_path=relative_path, + needle="return container", + repl="return missing_container", + mode="literal", + ) + + diagnostics = parse_edit_diagnostics_result(result) + relative_path_result = diagnostics[relative_path] + diagnostic_messages = json.dumps(relative_path_result) + assert "missing_container" in diagnostic_messages + assert "create_service_container" in diagnostic_messages + finally: + replace_content_tool.ENABLE_DIAGNOSTICS = False + + @pytest.mark.parametrize( + "serena_agent", + [ + pytest.param(Language.PYTHON, marks=get_pytest_markers(Language.PYTHON), id="python_container_body"), + pytest.param(Language.PYTHON_TY, marks=get_pytest_markers(Language.PYTHON_TY), id="python_ty_container_body"), + ], + indirect=["serena_agent"], + ) + def test_replace_symbol_body_reports_new_diagnostics(self, serena_agent: SerenaAgent): + """Tests that symbol-level edits report newly introduced diagnostics.""" + relative_path = os.path.join("test_repo", "services.py") + replace_symbol_body_tool = serena_agent.get_tool(ReplaceSymbolBodyTool) + try: + replace_symbol_body_tool.ENABLE_DIAGNOSTICS = True + + with project_file_modification_context(serena_agent, relative_path): + result = replace_symbol_body_tool.apply( + name_path="create_service_container", + relative_path=relative_path, + body=""" + def create_service_container() -> dict[str, Any]: + return missing_container + """, + ) + + diagnostics = parse_edit_diagnostics_result(result) + relative_path_result = diagnostics[relative_path] + diagnostic_messages = json.dumps(relative_path_result) + assert "missing_container" in diagnostic_messages + assert "create_service_container" in diagnostic_messages + finally: + replace_symbol_body_tool.ENABLE_DIAGNOSTICS = False + + @pytest.mark.parametrize( + "serena_agent", + [ + pytest.param(Language.TYPESCRIPT, marks=get_pytest_markers(Language.TYPESCRIPT), id="typescript_ambiguous_regex"), ], indirect=["serena_agent"], ) @@ -607,98 +1240,40 @@ class TestSerenaAgent: 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"); }', + repl='catch(error) {console.log("Never mind"); }', relative_path="ws_manager.js", mode="regex", ) - @pytest.mark.parametrize( - "serena_agent,name_path,relative_path", - [ - pytest.param( - Language.PYTHON, - "User", - os.path.join("test_repo", "models.py"), - marks=pytest.mark.python, - ), - pytest.param( - Language.JAVA, - "Model", - os.path.join("src", "main", "java", "test_repo", "Model.java"), - marks=pytest.mark.java, - ), - pytest.param( - Language.KOTLIN, - "Model", - os.path.join("src", "main", "kotlin", "test_repo", "Model.kt"), - marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), - ), - pytest.param( - Language.TYPESCRIPT, - "helperFunction", - "index.ts", - marks=pytest.mark.typescript, - ), - ], - indirect=["serena_agent"], - ) - def test_safe_delete_symbol_blocked_by_references(self, serena_agent: SerenaAgent, name_path: str, relative_path: str): + @pytest.mark.parametrize("serena_agent,case", SAFE_DELETE_BLOCKED_CASES, indirect=["serena_agent"]) + def test_safe_delete_symbol_blocked_by_references(self, serena_agent: SerenaAgent, case: SafeDeleteCase): """ Tests that SafeDeleteSymbol refuses to delete a symbol that is referenced elsewhere and returns a message listing the referencing files. """ # wrap in modification context as a safety net: if the tool has a bug and deletes anyway, # the file will be restored, preventing corruption of test resources - with project_file_modification_context(serena_agent, relative_path): + with project_file_modification_context(serena_agent, case.relative_path): safe_delete_tool = serena_agent.get_tool(SafeDeleteSymbol) - result = safe_delete_tool.apply(name_path_pattern=name_path, relative_path=relative_path) + result = safe_delete_tool.apply(name_path_pattern=case.name_path, relative_path=case.relative_path) assert "Cannot delete" in result, f"Expected deletion to be blocked due to existing references, but got: {result}" assert "referenced in" in result, f"Expected reference information in result, but got: {result}" - @pytest.mark.parametrize( - "serena_agent,name_path,relative_path", - [ - pytest.param( - Language.PYTHON, - "Timer", - os.path.join("test_repo", "utils.py"), - marks=pytest.mark.python, - ), - pytest.param( - Language.JAVA, - "ModelUser", - os.path.join("src", "main", "java", "test_repo", "ModelUser.java"), - marks=pytest.mark.java, - ), - pytest.param( - Language.KOTLIN, - "ModelUser", - os.path.join("src", "main", "kotlin", "test_repo", "ModelUser.kt"), - marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), - ), - pytest.param( - Language.TYPESCRIPT, - "unusedStandaloneFunction", - "index.ts", - marks=pytest.mark.typescript, - ), - ], - indirect=["serena_agent"], - ) - def test_safe_delete_symbol_succeeds_when_no_references(self, serena_agent: SerenaAgent, name_path: str, relative_path: str): + @pytest.mark.parametrize("serena_agent,case", SAFE_DELETE_SUCCEEDS_CASES, indirect=["serena_agent"]) + def test_safe_delete_symbol_succeeds_when_no_references(self, serena_agent: SerenaAgent, case: SafeDeleteCase): """ Tests that SafeDeleteSymbol successfully deletes a symbol that has no references and that the symbol is actually removed from the file. """ - with project_file_modification_context(serena_agent, relative_path): + with project_file_modification_context(serena_agent, case.relative_path): safe_delete_tool = serena_agent.get_tool(SafeDeleteSymbol) - result = safe_delete_tool.apply(name_path_pattern=name_path, relative_path=relative_path) + result = safe_delete_tool.apply(name_path_pattern=case.name_path, relative_path=case.relative_path) assert result == SUCCESS_RESULT, f"Expected successful deletion, but got: {result}" # verify the symbol was actually removed from the file - file_content = read_project_file(serena_agent.get_active_project(), relative_path) - assert name_path not in file_content, ( - f"Expected symbol {name_path} to be removed from {relative_path}, but it still appears in the file content" + file_content = read_project_file(serena_agent.get_active_project(), case.relative_path) + assert case.name_path not in file_content, ( + f"Expected symbol {case.name_path} to be removed from {case.relative_path}, but it still appears in the file content" ) diff --git a/test/serena/test_set_modes.py b/test/serena/test_set_modes.py deleted file mode 100644 index dadae4ba..00000000 --- a/test/serena/test_set_modes.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Tests for SerenaAgent.set_modes() to verify that mode switching works correctly.""" - -import logging - -from serena.agent import SerenaAgent -from serena.config.serena_config import ModeSelectionDefinition, SerenaConfig - - -class TestSetModes: - """Test that set_modes correctly changes active modes.""" - - def _create_agent(self, modes: ModeSelectionDefinition | None = None) -> SerenaAgent: - config = SerenaConfig(gui_log_window=False, web_dashboard=False, log_level=logging.ERROR) - return SerenaAgent(serena_config=config, modes=modes) - - def test_set_modes_changes_active_modes(self) -> None: - """Test that calling set_modes actually changes the active modes.""" - agent = self._create_agent(modes=ModeSelectionDefinition(default_modes=["editing", "interactive"])) - - initial_mode_names = sorted(m.name for m in agent.get_active_modes()) - assert "editing" in initial_mode_names - assert "interactive" in initial_mode_names - - # Switch to planning mode - agent.set_modes(["planning", "interactive"]) - - new_mode_names = sorted(m.name for m in agent.get_active_modes()) - assert "planning" in new_mode_names - assert "interactive" in new_mode_names - assert "editing" not in new_mode_names - - def test_set_modes_overrides_config_defaults(self) -> None: - """Test that set_modes takes precedence over config defaults.""" - config = SerenaConfig(gui_log_window=False, web_dashboard=False, log_level=logging.ERROR) - config.default_modes = ["editing", "interactive"] - agent = SerenaAgent(serena_config=config) - - # Verify config defaults are active - initial_mode_names = [m.name for m in agent.get_active_modes()] - assert "editing" in initial_mode_names - - # Switch modes — should override config defaults - agent.set_modes(["planning", "one-shot"]) - - new_mode_names = [m.name for m in agent.get_active_modes()] - assert "planning" in new_mode_names - assert "one-shot" in new_mode_names - assert "editing" not in new_mode_names - - def test_set_modes_persists_after_repeated_calls(self) -> None: - """Test that set_modes result persists (modes don't revert).""" - agent = self._create_agent(modes=ModeSelectionDefinition(default_modes=["editing"])) - - agent.set_modes(["planning"]) - mode_names_1 = [m.name for m in agent.get_active_modes()] - assert "planning" in mode_names_1 - - # Call get_active_modes again — should still be planning - mode_names_2 = [m.name for m in agent.get_active_modes()] - assert mode_names_1 == mode_names_2 - - def test_set_modes_can_switch_back(self) -> None: - """Test that modes can be switched back to original after switching away.""" - agent = self._create_agent(modes=ModeSelectionDefinition(default_modes=["editing", "interactive"])) - - # Switch away - agent.set_modes(["planning", "one-shot"]) - assert "planning" in [m.name for m in agent.get_active_modes()] - - # Switch back - agent.set_modes(["editing", "interactive"]) - mode_names = [m.name for m in agent.get_active_modes()] - assert "editing" in mode_names - assert "interactive" in mode_names - assert "planning" not in mode_names diff --git a/test/solidlsp/bash/test_bash_diagnostics.py b/test/solidlsp/bash/test_bash_diagnostics.py new file mode 100644 index 00000000..bd1cc494 --- /dev/null +++ b/test/solidlsp/bash/test_bash_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.bash +class TestBashDiagnostics: + @pytest.mark.parametrize("language_server", [Language.BASH], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.sh", + (), + min_count=1, + ) diff --git a/test/solidlsp/clojure/test_clojure_diagnostics.py b/test/solidlsp/clojure/test_clojure_diagnostics.py new file mode 100644 index 00000000..a7bff96b --- /dev/null +++ b/test/solidlsp/clojure/test_clojure_diagnostics.py @@ -0,0 +1,19 @@ +import os + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.clojure +class TestClojureDiagnostics: + @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + os.path.join("src", "test_app", "diagnostics_sample.clj"), + ("missing-greeting", "missing-consumer-value"), + min_count=2, + ) diff --git a/test/solidlsp/conftest.py b/test/solidlsp/conftest.py index 08bb6a01..ee563757 100644 --- a/test/solidlsp/conftest.py +++ b/test/solidlsp/conftest.py @@ -5,6 +5,12 @@ from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation PYTHON_BACKEND_LANGUAGES = [Language.PYTHON, Language.PYTHON_TY] +def is_diagnostics_test_file(relative_path: str) -> bool: + normalized_path = relative_path.replace("\\", "/") + filename = normalized_path.rsplit("/", 1)[-1].lower() + return filename.startswith(("diagnosticssample.", "diagnostics_sample.")) + + def has_malformed_name( symbol: UnifiedSymbolInformation, whitespace_allowed: bool = False, @@ -36,6 +42,9 @@ def request_all_symbols(language_server: SolidLanguageServer) -> list[UnifiedSym result: list[UnifiedSymbolInformation] = [] def visit(symbol: UnifiedSymbolInformation) -> None: + relative_path = symbol.get("location", {}).get("relativePath", "") + if relative_path and is_diagnostics_test_file(relative_path): + return result.append(symbol) for child in symbol.get("children", []): visit(child) diff --git a/test/solidlsp/cpp/test_clangd_logging.py b/test/solidlsp/cpp/test_clangd_logging.py new file mode 100644 index 00000000..8cef777e --- /dev/null +++ b/test/solidlsp/cpp/test_clangd_logging.py @@ -0,0 +1,43 @@ +import logging + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.language_servers.clangd_language_server import ClangdLanguageServer + + +@pytest.mark.cpp +class TestClangdLogging: + @pytest.mark.parametrize( + "expected, line", + [ + # Parametrize names instead of codes for human-readable output in test logs. + (logging.getLevelName(logging.INFO), "I[12:00:00.000] clangd version 22.1.3"), + (logging.getLevelName(logging.INFO), "V[12:00:00.000] verbose detail"), + (logging.getLevelName(logging.DEBUG), "D[12:00:00.000] debug detail"), + (logging.getLevelName(logging.ERROR), "E[12:00:00.000] something bad"), + (logging.getLevelName(logging.ERROR), "F[12:00:00.000] fatal"), + ], + ) + def test_log_record_detection(self, expected: str, line: str) -> None: + """ + Verifies clangd's stderr log classifier maps each level prefix to the + expected Python logging level and avoids misclassifying compile-command + payloads as ERROR. + """ + assert ClangdLanguageServer._determine_log_level(line) == logging.getLevelNamesMapping()[expected] + + def test_log_record_info_payload_mentions_fno_exceptions(self) -> None: + line = """clang++ -fno-exceptions -c no_exceptions.cpp""" + # Basic classifier incorrectly detects line as an error log. + assert SolidLanguageServer._determine_log_level(line) == logging.ERROR + # Clangd classifier correctly detects line as an info log. + assert ClangdLanguageServer._determine_log_level(line) == logging.INFO + + def test_log_record_info_payload_without_prefix(self) -> None: + # Multi-line records: first line carries the I[...] prefix, subsequent + # lines do not. Treat lack of prefix as continuation. + assert ClangdLanguageServer._determine_log_level("[/home/user/project/build]") == logging.INFO + + def test_log_record_info_payload_empty(self) -> None: + assert ClangdLanguageServer._determine_log_level("") == logging.INFO diff --git a/test/solidlsp/cpp/test_cpp_diagnostics.py b/test/solidlsp/cpp/test_cpp_diagnostics.py new file mode 100644 index 00000000..20a58368 --- /dev/null +++ b/test/solidlsp/cpp/test_cpp_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.cpp +class TestCppDiagnostics: + @pytest.mark.parametrize("language_server", [Language.CPP], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.cpp", + (), + min_count=1, + ) diff --git a/test/solidlsp/crystal/test_crystal_diagnostics.py b/test/solidlsp/crystal/test_crystal_diagnostics.py new file mode 100644 index 00000000..e650adc1 --- /dev/null +++ b/test/solidlsp/crystal/test_crystal_diagnostics.py @@ -0,0 +1,23 @@ +import shutil + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + +pytestmark = [ + pytest.mark.crystal, + pytest.mark.skipif(shutil.which("crystalline") is None, reason="Crystalline is not installed"), +] + + +class TestCrystalDiagnostics: + @pytest.mark.parametrize("language_server", [Language.CRYSTAL], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/diagnostics_sample.cr", + (), + min_count=1, + ) diff --git a/test/solidlsp/csharp/test_csharp_basic.py b/test/solidlsp/csharp/test_csharp_basic.py index 91f67781..97e44e5e 100644 --- a/test/solidlsp/csharp/test_csharp_basic.py +++ b/test/solidlsp/csharp/test_csharp_basic.py @@ -18,6 +18,7 @@ from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_types import SymbolKind from solidlsp.ls_utils import SymbolUtils from solidlsp.settings import SolidLSPSettings +from test.conftest import find_identifier_position, get_repo_path, language_has_verified_implementation_support from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @@ -176,6 +177,33 @@ class TestCSharpLanguageServer: assert "bool" in method_hover_text, f"Hover should include 'bool' return type, got: {method_hover_text}" assert "IsAdult" in method_hover_text, f"Hover should include 'IsAdult' method name, got: {method_hover_text}" + if language_has_verified_implementation_support(Language.CSHARP): + + @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.CSHARP) + pos = find_identifier_position(repo_path / "Services" / "IGreeter.cs", "FormatGreeting") + assert pos is not None, "Could not find IGreeter.FormatGreeting in fixture" + + implementations = language_server.request_implementation("Services/IGreeter.cs", *pos) + assert implementations, "Expected at least one implementation of IGreeter.FormatGreeting" + assert any("ConsoleGreeter.cs" in implementation.get("relativePath", "") for implementation in implementations), ( + f"Expected ConsoleGreeter.FormatGreeting in implementations, got: {implementations}" + ) + + @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.CSHARP) + pos = find_identifier_position(repo_path / "Services" / "IGreeter.cs", "FormatGreeting") + assert pos is not None, "Could not find IGreeter.FormatGreeting in fixture" + + implementing_symbols = language_server.request_implementing_symbols("Services/IGreeter.cs", *pos) + assert implementing_symbols, "Expected implementing symbols for IGreeter.FormatGreeting" + assert any( + symbol.get("name") == "FormatGreeting" and "ConsoleGreeter.cs" in symbol["location"].get("relativePath", "") + for symbol in implementing_symbols + ), f"Expected ConsoleGreeter.FormatGreeting symbol, got: {implementing_symbols}" + @pytest.mark.csharp class TestCSharpSolutionProjectOpening: diff --git a/test/solidlsp/csharp/test_csharp_diagnostics.py b/test/solidlsp/csharp/test_csharp_diagnostics.py new file mode 100644 index 00000000..de32fd1d --- /dev/null +++ b/test/solidlsp/csharp/test_csharp_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.csharp +class TestCsharpDiagnostics: + @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "DiagnosticsSample.cs", + (), + min_count=1, + ) diff --git a/test/solidlsp/dart/test_dart_diagnostics.py b/test/solidlsp/dart/test_dart_diagnostics.py new file mode 100644 index 00000000..ddc5e398 --- /dev/null +++ b/test/solidlsp/dart/test_dart_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.dart +class TestDartDiagnostics: + @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "lib/diagnostics_sample.dart", + (), + min_count=1, + ) diff --git a/test/solidlsp/elixir/test_elixir_basic.py b/test/solidlsp/elixir/test_elixir_basic.py index 11b983ca..7c44abc4 100644 --- a/test/solidlsp/elixir/test_elixir_basic.py +++ b/test/solidlsp/elixir/test_elixir_basic.py @@ -13,6 +13,7 @@ from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics from . import EXPERT_UNAVAILABLE, EXPERT_UNAVAILABLE_REASON @@ -138,3 +139,12 @@ class TestElixirBasic: f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", pytrace=False, ) + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "lib/diagnostics_sample.ex", + (), + min_count=1, + ) diff --git a/test/solidlsp/elm/test_elm_diagnostics.py b/test/solidlsp/elm/test_elm_diagnostics.py new file mode 100644 index 00000000..744201a2 --- /dev/null +++ b/test/solidlsp/elm/test_elm_diagnostics.py @@ -0,0 +1,20 @@ +import shutil + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.elm +@pytest.mark.skipif(shutil.which("node") is None or shutil.which("npm") is None, reason="Elm diagnostics require Node.js and npm") +class TestElmDiagnostics: + @pytest.mark.parametrize("language_server", [Language.ELM], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "DiagnosticsSample.elm", + (), + min_count=1, + ) diff --git a/test/solidlsp/erlang/test_erlang_basic.py b/test/solidlsp/erlang/test_erlang_basic.py index a1dba06a..dc39535b 100644 --- a/test/solidlsp/erlang/test_erlang_basic.py +++ b/test/solidlsp/erlang/test_erlang_basic.py @@ -10,6 +10,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics from . import ERLANG_LS_UNAVAILABLE, ERLANG_LS_UNAVAILABLE_REASON @@ -55,3 +56,12 @@ class TestErlangLanguageServerBasics: f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", pytrace=False, ) + + @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/diagnostics_sample.erl", + (), + min_count=1, + ) diff --git a/test/solidlsp/fortran/test_fortran_basic.py b/test/solidlsp/fortran/test_fortran_basic.py index e7a45235..9a73ece1 100644 --- a/test/solidlsp/fortran/test_fortran_basic.py +++ b/test/solidlsp/fortran/test_fortran_basic.py @@ -11,6 +11,7 @@ from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind from solidlsp.ls_utils import SymbolUtils +from test.conftest import find_identifier_position, get_repo_path, language_has_verified_implementation_support from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols # Mark all tests in this module as fortran tests @@ -38,6 +39,33 @@ class TestFortranLanguageServer: # Verify subroutine symbol assert SymbolUtils.symbol_tree_contains_name(symbols, "print_result"), "print_result subroutine not found in symbol tree" + if language_has_verified_implementation_support(Language.FORTRAN): + + @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.FORTRAN) + pos = find_identifier_position(repo_path / "modules" / "geometry.f90", "distance") + assert pos is not None, "Could not find interface distance in geometry.f90" + + implementations = language_server.request_implementation("modules/geometry.f90", *pos) + assert implementations, "Expected implementations for geometry_types.distance" + implementation_files = {implementation.get("relativePath", "") for implementation in implementations} + assert implementation_files == {"modules/geometry.f90"}, f"Unexpected implementation locations: {implementations}" + assert len(implementations) >= 2, f"Expected module procedure implementations, got: {implementations}" + + @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.FORTRAN) + pos = find_identifier_position(repo_path / "modules" / "geometry.f90", "distance") + assert pos is not None, "Could not find interface distance in geometry.f90" + + implementing_symbols = language_server.request_implementing_symbols("modules/geometry.f90", *pos) + assert implementing_symbols, "Expected implementing symbols for geometry_types.distance" + implementing_symbol_names = {symbol.get("name") for symbol in implementing_symbols} + assert {"distance_2d", "distance_3d"}.issubset(implementing_symbol_names), ( + f"Expected distance_2d and distance_3d, got: {implementing_symbols}" + ) + @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) def test_request_document_symbols(self, language_server: SolidLanguageServer) -> None: """Test that document symbols can be retrieved from Fortran files.""" diff --git a/test/solidlsp/fortran/test_fortran_diagnostics.py b/test/solidlsp/fortran/test_fortran_diagnostics.py new file mode 100644 index 00000000..f607cd0b --- /dev/null +++ b/test/solidlsp/fortran/test_fortran_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.fortran +class TestFortranDiagnostics: + @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.f90", + (), + min_count=1, + ) diff --git a/test/solidlsp/fsharp/test_fsharp_basic.py b/test/solidlsp/fsharp/test_fsharp_basic.py index d0f52443..f4f49ebc 100644 --- a/test/solidlsp/fsharp/test_fsharp_basic.py +++ b/test/solidlsp/fsharp/test_fsharp_basic.py @@ -7,8 +7,9 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils -from test.conftest import is_ci +from test.conftest import find_identifier_position, get_repo_path, is_ci, language_has_verified_implementation_support from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics # Currently, most F# tests fail, there seems to be a regression or instability. @@ -39,6 +40,33 @@ class TestFSharpLanguageServer: symbol_names = [s.get("name") for s in symbols] assert "main" in symbol_names, "main function not found in Program.fs symbols" + if language_has_verified_implementation_support(Language.FSHARP): + + @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.FSHARP) + pos = find_identifier_position(repo_path / "Formatter.fs", "FormatGreeting") + assert pos is not None, "Could not find IGreeter.FormatGreeting in fixture" + + implementations = language_server.request_implementation("Formatter.fs", *pos) + assert implementations, "Expected at least one implementation of IGreeter.FormatGreeting" + assert any("Formatter.fs" in implementation.get("relativePath", "") for implementation in implementations), ( + f"Expected ConsoleGreeter.FormatGreeting in implementations, got: {implementations}" + ) + + @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.FSHARP) + pos = find_identifier_position(repo_path / "Formatter.fs", "FormatGreeting") + assert pos is not None, "Could not find IGreeter.FormatGreeting in fixture" + + implementing_symbols = language_server.request_implementing_symbols("Formatter.fs", *pos) + assert implementing_symbols, "Expected implementing symbols for IGreeter.FormatGreeting" + assert any( + symbol.get("name") == "FormatGreeting" and "Formatter.fs" in symbol["location"].get("relativePath", "") + for symbol in implementing_symbols + ), f"Expected ConsoleGreeter.FormatGreeting symbol, got: {implementing_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.""" @@ -169,21 +197,13 @@ class TestFSharpLanguageServer: assert isinstance(result["value"], 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" + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "DiagnosticsSample.fs", + (), + min_count=1, + ) @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) def test_bare_symbol_names(self, language_server) -> None: diff --git a/test/solidlsp/go/test_go_basic.py b/test/solidlsp/go/test_go_basic.py index 93fcd98e..347f6022 100644 --- a/test/solidlsp/go/test_go_basic.py +++ b/test/solidlsp/go/test_go_basic.py @@ -7,6 +7,7 @@ from serena.symbol import LanguageServerSymbol from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils +from test.conftest import find_identifier_position, get_repo_path, language_has_verified_implementation_support from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @@ -47,6 +48,33 @@ class TestGoLanguageServer: refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) assert any("main.go" in ref.get("uri", "") for ref in refs), "Expected at least one reference result to point at main.go" + if language_has_verified_implementation_support(Language.GO): + + @pytest.mark.parametrize("language_server", [Language.GO], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.GO) + pos = find_identifier_position(repo_path / "main.go", "FormatGreeting") + assert pos is not None, "Could not find Greeter.FormatGreeting in fixture" + + implementations = language_server.request_implementation("main.go", *pos) + assert implementations, "Expected at least one implementation of Greeter.FormatGreeting" + assert any("main.go" in implementation.get("relativePath", "") for implementation in implementations), ( + f"Expected ConsoleGreeter.FormatGreeting in implementations, got: {implementations}" + ) + + @pytest.mark.parametrize("language_server", [Language.GO], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.GO) + pos = find_identifier_position(repo_path / "main.go", "FormatGreeting") + assert pos is not None, "Could not find Greeter.FormatGreeting in fixture" + + implementing_symbols = language_server.request_implementing_symbols("main.go", *pos) + assert implementing_symbols, "Expected implementing symbols for Greeter.FormatGreeting" + assert any( + symbol.get("name") == "FormatGreeting" and "main.go" in symbol["location"].get("relativePath", "") + for symbol in implementing_symbols + ), f"Expected FormatGreeting symbol, got: {implementing_symbols}" + def _filter_symbols_by_name_in_repo(symbols: list | None, target_name: str, repo_name: str = "test_repo") -> list: """Filter workspace symbols to exact name matches in the test repo.""" diff --git a/test/solidlsp/go/test_go_diagnostics.py b/test/solidlsp/go/test_go_diagnostics.py new file mode 100644 index 00000000..00b4cc48 --- /dev/null +++ b/test/solidlsp/go/test_go_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.go +class TestGoDiagnostics: + @pytest.mark.parametrize("language_server", [Language.GO], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.go", + ("missingGreeting", "missingConsumerValue"), + min_count=2, + ) diff --git a/test/solidlsp/groovy/test_groovy_basic.py b/test/solidlsp/groovy/test_groovy_basic.py index 47f22ed8..0637e3de 100644 --- a/test/solidlsp/groovy/test_groovy_basic.py +++ b/test/solidlsp/groovy/test_groovy_basic.py @@ -9,6 +9,7 @@ from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_utils import SymbolUtils from solidlsp.settings import SolidLSPSettings from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics @pytest.mark.groovy @@ -117,3 +118,12 @@ class TestGroovyLanguageServer: f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", pytrace=False, ) + + def test_file_diagnostics(self) -> None: + assert self.language_server is not None + assert_file_diagnostics( + self.language_server, + "src/main/groovy/com/example/DiagnosticsSample.groovy", + (), + min_count=1, + ) diff --git a/test/solidlsp/haskell/test_haskell_basic.py b/test/solidlsp/haskell/test_haskell_basic.py index 67452151..5b23ad3d 100644 --- a/test/solidlsp/haskell/test_haskell_basic.py +++ b/test/solidlsp/haskell/test_haskell_basic.py @@ -21,6 +21,7 @@ from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics @pytest.mark.haskell @@ -240,3 +241,12 @@ class TestHaskellLanguageServer: for sym in malformed_symbols ] pytest.fail(f"Found malformed symbols: {diagnostics}", pytrace=False) + + @pytest.mark.parametrize("language_server", [Language.HASKELL], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/DiagnosticsSample.hs", + (), + min_count=1, + ) diff --git a/test/solidlsp/haxe/test_haxe_diagnostics.py b/test/solidlsp/haxe/test_haxe_diagnostics.py new file mode 100644 index 00000000..2f789b60 --- /dev/null +++ b/test/solidlsp/haxe/test_haxe_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.haxe +class TestHaxeDiagnostics: + @pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/DiagnosticsSample.hx", + (), + min_count=1, + ) diff --git a/test/solidlsp/hlsl/test_hlsl_diagnostics.py b/test/solidlsp/hlsl/test_hlsl_diagnostics.py new file mode 100644 index 00000000..e356d2da --- /dev/null +++ b/test/solidlsp/hlsl/test_hlsl_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.hlsl +class TestHlslDiagnostics: + @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.hlsl", + (), + min_count=1, + ) diff --git a/test/solidlsp/java/test_java_basic.py b/test/solidlsp/java/test_java_basic.py index 56b3bbf4..4c50c3c4 100644 --- a/test/solidlsp/java/test_java_basic.py +++ b/test/solidlsp/java/test_java_basic.py @@ -5,7 +5,12 @@ 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 +from test.conftest import ( + find_identifier_position, + get_repo_path, + language_has_verified_implementation_support, + language_tests_enabled, +) from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols pytestmark = [pytest.mark.java, pytest.mark.skipif(not language_tests_enabled(Language.JAVA), reason="Java tests disabled")] @@ -52,6 +57,33 @@ class TestJavaLanguageServer: assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils missing from overview" assert SymbolUtils.symbol_tree_contains_name(symbols, "Model"), "Model missing from overview" + if language_has_verified_implementation_support(Language.JAVA): + + @pytest.mark.parametrize("language_server", [Language.JAVA], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.JAVA) + pos = find_identifier_position(repo_path / "src/main/java/test_repo/Greeter.java", "formatGreeting") + assert pos is not None, "Could not find Greeter.formatGreeting in fixture" + + implementations = language_server.request_implementation("src/main/java/test_repo/Greeter.java", *pos) + assert implementations, "Expected at least one implementation of Greeter.formatGreeting" + assert any("ConsoleGreeter.java" in implementation.get("relativePath", "") for implementation in implementations), ( + f"Expected ConsoleGreeter.formatGreeting in implementations, got: {implementations}" + ) + + @pytest.mark.parametrize("language_server", [Language.JAVA], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.JAVA) + pos = find_identifier_position(repo_path / "src/main/java/test_repo/Greeter.java", "formatGreeting") + assert pos is not None, "Could not find Greeter.formatGreeting in fixture" + + implementing_symbols = language_server.request_implementing_symbols("src/main/java/test_repo/Greeter.java", *pos) + assert implementing_symbols, "Expected implementing symbols for Greeter.formatGreeting" + assert any( + symbol.get("name") == "formatGreeting" and "ConsoleGreeter.java" in symbol["location"].get("relativePath", "") + for symbol in implementing_symbols + ), f"Expected ConsoleGreeter.formatGreeting symbol, got: {implementing_symbols}" + @pytest.mark.parametrize("language_server", [Language.JAVA], indirect=True) def test_bare_symbol_names(self, language_server) -> None: all_symbols = request_all_symbols(language_server) diff --git a/test/solidlsp/java/test_java_diagnostics.py b/test/solidlsp/java/test_java_diagnostics.py new file mode 100644 index 00000000..ebd59457 --- /dev/null +++ b/test/solidlsp/java/test_java_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.java +class TestJavaDiagnostics: + @pytest.mark.parametrize("language_server", [Language.JAVA], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/main/java/test_repo/DiagnosticsSample.java", + (), + min_count=1, + ) diff --git a/test/solidlsp/java/test_jdtls_path_resolution.py b/test/solidlsp/java/test_jdtls_path_resolution.py new file mode 100644 index 00000000..2ce735d0 --- /dev/null +++ b/test/solidlsp/java/test_jdtls_path_resolution.py @@ -0,0 +1,443 @@ +""" +Unit tests for the offline (upstream) JDTLS resolution helpers in +``solidlsp.language_servers.eclipse_jdtls``. + +These tests cover only the path/version/validation logic; they do not start +JDTLS and do not require Java to be installed. Subprocess interactions are +mocked. +""" + +from __future__ import annotations + +import platform +from pathlib import Path +from unittest.mock import patch + +import pytest + +_JAVA_EXE_NAME = "java.exe" if platform.system() == "Windows" else "java" + +from solidlsp.language_servers.eclipse_jdtls import ( + JDTLS_CONFIG_DIR_BY_PLATFORM, + JDTLS_MIN_JDK_VERSION, + EclipseJDTLS, +) +from solidlsp.ls_exceptions import SolidLSPException +from solidlsp.settings import SolidLSPSettings + + +@pytest.fixture +def custom_settings() -> SolidLSPSettings.CustomLSSettings: + """Empty CustomLSSettings instance for tests that don't need any keys set.""" + return SolidLSPSettings.CustomLSSettings({}) + + +def _make_fake_jdtls_install( + root: Path, *, with_launcher: bool = True, with_native_fragments: bool = True, with_configs: bool = True +) -> Path: + """ + Builds a minimal fake upstream JDTLS layout under ``root``: ``plugins/`` with + a main equinox launcher jar (and optionally native fragments) and + ``config_/`` directories. Returns ``root``. + """ + plugins = root / "plugins" + plugins.mkdir(parents=True, exist_ok=True) + if with_launcher: + (plugins / "org.eclipse.equinox.launcher_1.7.100.v20251111-0406.jar").touch() + if with_native_fragments: + (plugins / "org.eclipse.equinox.launcher.cocoa.macosx.aarch64_1.2.0.v20240329-1112.jar").touch() + (plugins / "org.eclipse.equinox.launcher.gtk.linux.x86_64_1.2.0.v20240329-1112.jar").touch() + (plugins / "org.eclipse.equinox.launcher.win32.win32.x86_64_1.2.0.v20240329-1112.jar").touch() + if with_configs: + for config_name in set(JDTLS_CONFIG_DIR_BY_PLATFORM.values()): + (root / config_name).mkdir(exist_ok=True) + return root + + +# ---------------------------------------------------------------------------- +# _resolve_launcher_jar +# ---------------------------------------------------------------------------- + + +class TestResolveLauncherJar: + def test_picks_main_launcher_excluding_native_fragments(self, tmp_path: Path) -> None: + plugins = tmp_path / "plugins" + plugins.mkdir() + main_jar = plugins / "org.eclipse.equinox.launcher_1.7.100.v20251111-0406.jar" + main_jar.touch() + # native fragments should NOT be picked + (plugins / "org.eclipse.equinox.launcher.cocoa.macosx.aarch64_1.2.0.v20240329-1112.jar").touch() + (plugins / "org.eclipse.equinox.launcher.gtk.linux.x86_64_1.2.0.v20240329-1112.jar").touch() + (plugins / "org.eclipse.equinox.launcher.win32.win32.x86_64_1.2.0.v20240329-1112.jar").touch() + + result = EclipseJDTLS.DependencyProvider._resolve_launcher_jar(plugins) + assert result == main_jar + + def test_picks_highest_version_when_multiple_main_launchers(self, tmp_path: Path) -> None: + plugins = tmp_path / "plugins" + plugins.mkdir() + (plugins / "org.eclipse.equinox.launcher_1.6.500.v20240916-1115.jar").touch() + newer = plugins / "org.eclipse.equinox.launcher_1.7.100.v20251111-0406.jar" + newer.touch() + (plugins / "org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar").touch() + + result = EclipseJDTLS.DependencyProvider._resolve_launcher_jar(plugins) + assert result == newer, "Expected the lexicographically highest launcher version to be selected" + + def test_raises_when_no_launcher_present(self, tmp_path: Path) -> None: + plugins = tmp_path / "plugins" + plugins.mkdir() + # only native fragments, no main launcher + (plugins / "org.eclipse.equinox.launcher.cocoa.macosx.aarch64_1.2.0.v20240329-1112.jar").touch() + + with pytest.raises(SolidLSPException, match="No main Equinox launcher jar found"): + EclipseJDTLS.DependencyProvider._resolve_launcher_jar(plugins) + + +# ---------------------------------------------------------------------------- +# _resolve_config_dir +# ---------------------------------------------------------------------------- + + +class TestResolveConfigDir: + @pytest.mark.parametrize( + "platform_id,expected_dir", + [ + ("osx-arm64", "config_mac_arm"), + ("darwin-arm64", "config_mac_arm"), + ("osx-x64", "config_mac"), + ("linux-arm64", "config_linux_arm"), + ("linux-x64", "config_linux"), + ("win-x64", "config_win"), + ], + ) + def test_maps_platform_to_correct_config_dir(self, tmp_path: Path, platform_id: str, expected_dir: str) -> None: + _make_fake_jdtls_install(tmp_path) + with patch("solidlsp.language_servers.eclipse_jdtls.PlatformUtils.get_platform_id") as mock_get_pid: + mock_get_pid.return_value.value = platform_id + result = EclipseJDTLS.DependencyProvider._resolve_config_dir(tmp_path) + assert result.name == expected_dir + assert result.is_dir() + + def test_raises_when_config_dir_missing(self, tmp_path: Path) -> None: + # plugins/ exists but no config_/ for current OS + (tmp_path / "plugins").mkdir() + with patch("solidlsp.language_servers.eclipse_jdtls.PlatformUtils.get_platform_id") as mock_get_pid: + mock_get_pid.return_value.value = "linux-x64" + with pytest.raises(SolidLSPException, match="Config directory .* not found"): + EclipseJDTLS.DependencyProvider._resolve_config_dir(tmp_path) + + def test_raises_for_unsupported_platform(self, tmp_path: Path) -> None: + _make_fake_jdtls_install(tmp_path) + with patch("solidlsp.language_servers.eclipse_jdtls.PlatformUtils.get_platform_id") as mock_get_pid: + mock_get_pid.return_value.value = "freebsd-riscv64" + with pytest.raises(SolidLSPException, match="Unsupported platform"): + EclipseJDTLS.DependencyProvider._resolve_config_dir(tmp_path) + + +# ---------------------------------------------------------------------------- +# _inspect_java +# ---------------------------------------------------------------------------- + + +class TestInspectJava: + @staticmethod + def _fake_subprocess_result(stderr: str, stdout: str = "", returncode: int = 0): + """Build a minimal CompletedProcess-like object.""" + + class _Result: + def __init__(self) -> None: + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + return _Result() + + def test_parses_temurin_21_output(self) -> None: + # Real Temurin 21.0.10 output (java -XshowSettings:properties -version writes to stderr) + stderr = ( + "Property settings:\n" + " java.home = /Users/me/Library/Java/JavaVirtualMachines/temurin-21.0.10/Contents/Home\n" + " java.version = 21.0.10\n" + 'openjdk version "21.0.10" 2026-01-20 LTS\n' + "OpenJDK Runtime Environment Temurin-21.0.10+7 (build 21.0.10+7-LTS)\n" + ) + with patch("subprocess.run", return_value=self._fake_subprocess_result(stderr)): + home, major = EclipseJDTLS.DependencyProvider._inspect_java("/usr/bin/java") + assert home == "/Users/me/Library/Java/JavaVirtualMachines/temurin-21.0.10/Contents/Home" + assert major == 21 + + def test_parses_openjdk_17_output(self) -> None: + stderr = 'Property settings:\n java.home = /usr/lib/jvm/java-17-openjdk-amd64\nopenjdk version "17.0.5" 2023-10-17\n' + with patch("subprocess.run", return_value=self._fake_subprocess_result(stderr)): + home, major = EclipseJDTLS.DependencyProvider._inspect_java("/usr/bin/java") + assert home == "/usr/lib/jvm/java-17-openjdk-amd64" + assert major == 17 + + def test_raises_when_java_home_property_missing(self) -> None: + stderr = 'java version "21.0.0"\n' + with patch("subprocess.run", return_value=self._fake_subprocess_result(stderr)): + with pytest.raises(SolidLSPException, match="Could not parse java.home"): + EclipseJDTLS.DependencyProvider._inspect_java("/usr/bin/fakejava") + + def test_raises_when_version_string_missing(self) -> None: + stderr = " java.home = /opt/jdk\n" + with patch("subprocess.run", return_value=self._fake_subprocess_result(stderr)): + with pytest.raises(SolidLSPException, match="Could not parse Java version"): + EclipseJDTLS.DependencyProvider._inspect_java("/usr/bin/fakejava") + + def test_raises_on_subprocess_error(self) -> None: + with patch("subprocess.run", side_effect=OSError("permission denied")): + with pytest.raises(SolidLSPException, match="Failed to run"): + EclipseJDTLS.DependencyProvider._inspect_java("/nonexistent/java") + + +# ---------------------------------------------------------------------------- +# _resolve_system_jdk +# ---------------------------------------------------------------------------- + + +class TestResolveSystemJdk: + """Verifies the priority chain: java_home setting > JAVA_HOME env > PATH.""" + + @staticmethod + def _make_jdk_layout(root: Path, java_exe_name: str = _JAVA_EXE_NAME) -> Path: + bin_dir = root / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) + (bin_dir / java_exe_name).touch() + return root + + def _patch_inspect_java(self, real_home: str, major: int): + return patch.object(EclipseJDTLS.DependencyProvider, "_inspect_java", return_value=(real_home, major)) + + def test_uses_explicit_java_home_setting(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + explicit_jdk = self._make_jdk_layout(tmp_path / "explicit-jdk") + monkeypatch.delenv("JAVA_HOME", raising=False) + + with self._patch_inspect_java(str(explicit_jdk), 21): + settings = SolidLSPSettings.CustomLSSettings({"java_home": str(explicit_jdk)}) + home, java_path = EclipseJDTLS.DependencyProvider._resolve_system_jdk(settings) + + assert Path(home) == explicit_jdk + assert Path(java_path).name == _JAVA_EXE_NAME + + def test_falls_back_to_java_home_env(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + env_jdk = self._make_jdk_layout(tmp_path / "env-jdk") + monkeypatch.setenv("JAVA_HOME", str(env_jdk)) + + with self._patch_inspect_java(str(env_jdk), 21): + settings = SolidLSPSettings.CustomLSSettings({}) + home, _ = EclipseJDTLS.DependencyProvider._resolve_system_jdk(settings) + + assert Path(home) == env_jdk + + def test_falls_back_to_which_java( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> None: + path_jdk = self._make_jdk_layout(tmp_path / "path-jdk") + java_path = path_jdk / "bin" / _JAVA_EXE_NAME + monkeypatch.delenv("JAVA_HOME", raising=False) + + with patch("solidlsp.language_servers.eclipse_jdtls.shutil.which", return_value=str(java_path)): + with self._patch_inspect_java(str(path_jdk), 21): + home, _ = EclipseJDTLS.DependencyProvider._resolve_system_jdk(custom_settings) + + assert Path(home) == path_jdk + + def test_raises_when_no_java_anywhere( + self, monkeypatch: pytest.MonkeyPatch, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> None: + monkeypatch.delenv("JAVA_HOME", raising=False) + with patch("solidlsp.language_servers.eclipse_jdtls.shutil.which", return_value=None): + with pytest.raises(SolidLSPException, match="Could not locate a Java installation"): + EclipseJDTLS.DependencyProvider._resolve_system_jdk(custom_settings) + + def test_raises_for_invalid_explicit_java_home(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # path exists but no bin/java + broken = tmp_path / "broken-jdk" + broken.mkdir() + monkeypatch.delenv("JAVA_HOME", raising=False) + + settings = SolidLSPSettings.CustomLSSettings({"java_home": str(broken)}) + with pytest.raises(SolidLSPException, match=r"java_home=.*invalid"): + EclipseJDTLS.DependencyProvider._resolve_system_jdk(settings) + + def test_raises_for_too_old_jdk(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + old_jdk = self._make_jdk_layout(tmp_path / "jdk-17") + monkeypatch.setenv("JAVA_HOME", str(old_jdk)) + + with self._patch_inspect_java(str(old_jdk), 17): + settings = SolidLSPSettings.CustomLSSettings({}) + with pytest.raises(SolidLSPException, match=f"requires JDK {JDTLS_MIN_JDK_VERSION}"): + EclipseJDTLS.DependencyProvider._resolve_system_jdk(settings) + + def test_uses_real_jdk_home_when_locator_is_macos_stub( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> None: + """ + Simulates the macOS /usr/bin/java stub: the locator points to /usr/bin/java but the JVM's + java.home is the actual JDK. The resolver should report the real JDK home. + """ + real_jdk = self._make_jdk_layout(tmp_path / "real-jdk-21") + macos_stub = tmp_path / "fake-usr" / "bin" / _JAVA_EXE_NAME + macos_stub.parent.mkdir(parents=True) + macos_stub.touch() + monkeypatch.delenv("JAVA_HOME", raising=False) + + with patch("solidlsp.language_servers.eclipse_jdtls.shutil.which", return_value=str(macos_stub)): + with self._patch_inspect_java(str(real_jdk), 21): + home, java_path = EclipseJDTLS.DependencyProvider._resolve_system_jdk(custom_settings) + + # the resolver must trust the JVM's reported java.home, not parent.parent of the stub + assert Path(home) == real_jdk + # and prefer the real-home java executable over the stub + assert Path(java_path) == real_jdk / "bin" / _JAVA_EXE_NAME + + +# ---------------------------------------------------------------------------- +# _setup_from_existing_install +# ---------------------------------------------------------------------------- + + +class TestSetupFromExistingInstall: + @pytest.fixture + def lombok_jar(self, tmp_path: Path) -> Path: + jar = tmp_path / "lombok-1.18.44.jar" + jar.touch() + return jar + + @pytest.fixture + def jdtls_root(self, tmp_path: Path) -> Path: + return _make_fake_jdtls_install(tmp_path / "jdtls") + + def _fake_jdk(self, tmp_path: Path) -> Path: + jdk = tmp_path / "jdk-21" + (jdk / "bin").mkdir(parents=True) + (jdk / "bin" / "java").touch() + return jdk + + def test_happy_path_returns_runtime_paths_with_no_gradle_and_no_intellicode( + self, tmp_path: Path, jdtls_root: Path, lombok_jar: Path, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> None: + jdk = self._fake_jdk(tmp_path) + with patch("solidlsp.language_servers.eclipse_jdtls.PlatformUtils.get_platform_id") as mock_pid: + mock_pid.return_value.value = "linux-x64" + with patch.object(EclipseJDTLS.DependencyProvider, "_resolve_system_jdk", return_value=(str(jdk), str(jdk / "bin" / "java"))): + result = EclipseJDTLS.DependencyProvider._setup_from_existing_install(str(jdtls_root), str(lombok_jar), custom_settings) + + assert result.gradle_path is None + assert result.intellicode_jar_path is None + assert result.intellisense_members_path is None + assert result.lombok_jar_path == str(lombok_jar) + assert result.jre_home_path == str(jdk) + assert Path(result.jdtls_launcher_jar_path).name.startswith("org.eclipse.equinox.launcher_") + assert Path(result.jdtls_readonly_config_path).name == "config_linux" + + def test_raises_for_nonexistent_jdtls_path( + self, tmp_path: Path, lombok_jar: Path, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> None: + with pytest.raises(SolidLSPException, match="not an existing directory"): + EclipseJDTLS.DependencyProvider._setup_from_existing_install(str(tmp_path / "does-not-exist"), str(lombok_jar), custom_settings) + + def test_raises_when_plugins_dir_missing( + self, tmp_path: Path, lombok_jar: Path, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> None: + empty_root = tmp_path / "no-plugins" + empty_root.mkdir() + with pytest.raises(SolidLSPException, match="'plugins/' directory not found"): + EclipseJDTLS.DependencyProvider._setup_from_existing_install(str(empty_root), str(lombok_jar), custom_settings) + + def test_raises_when_lombok_jar_missing( + self, jdtls_root: Path, tmp_path: Path, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> None: + with pytest.raises(SolidLSPException, match="lombok_path .* does not exist"): + EclipseJDTLS.DependencyProvider._setup_from_existing_install( + str(jdtls_root), str(tmp_path / "no-such-lombok.jar"), custom_settings + ) + + +# ---------------------------------------------------------------------------- +# _setup_runtime_dependencies (mode-switch logic) +# ---------------------------------------------------------------------------- + + +class TestSetupRuntimeDependenciesModeSwitch: + """Verifies the activation trigger: both jdtls_path and lombok_path => upstream mode.""" + + def test_both_set_invokes_upstream_mode(self) -> None: + settings = SolidLSPSettings.CustomLSSettings({"jdtls_path": "/x", "lombok_path": "/y"}) + with patch.object(EclipseJDTLS.DependencyProvider, "_setup_from_existing_install", return_value="upstream-result") as mock_upstream: + result = EclipseJDTLS.DependencyProvider._setup_runtime_dependencies("/ignored", settings) + mock_upstream.assert_called_once_with("/x", "/y", settings) + assert result == "upstream-result" + + def test_only_jdtls_path_set_raises(self) -> None: + settings = SolidLSPSettings.CustomLSSettings({"jdtls_path": "/x"}) + with pytest.raises(SolidLSPException, match="must be set together"): + EclipseJDTLS.DependencyProvider._setup_runtime_dependencies("/ignored", settings) + + def test_only_lombok_path_set_raises(self) -> None: + settings = SolidLSPSettings.CustomLSSettings({"lombok_path": "/y"}) + with pytest.raises(SolidLSPException, match="must be set together"): + EclipseJDTLS.DependencyProvider._setup_runtime_dependencies("/ignored", settings) + + +# ---------------------------------------------------------------------------- +# _compute_workspace_hash +# ---------------------------------------------------------------------------- + + +class TestComputeWorkspaceHash: + """ + Backwards-compatibility contract: users on the default route (no ``jdtls_path``) + must receive the *exact* same hash format that existed before upstream-JDTLS support + (i.e. ``md5(repository_root_path)``), so existing JDTLS workspaces and project caches + are reused without a one-time reindex after upgrading Serena. Upstream mode mixes the + launcher path into the hash to isolate it from the default workspace. + """ + + REPO = "/home/me/projects/widgets" + DEFAULT_LAUNCHER = "/srv/serena/static/eclipse-jdtls-1.49.0/plugins/org.eclipse.equinox.launcher_1.7.100.jar" + UPSTREAM_LAUNCHER = "/opt/homebrew/Cellar/jdtls/1.50.0/libexec/plugins/org.eclipse.equinox.launcher_1.7.0.jar" + + def test_default_mode_matches_pre_upstream_format(self) -> None: + """The hash MUST equal md5(repository_root_path) — the format produced by PR #1214.""" + import hashlib + + expected = hashlib.md5(self.REPO.encode()).hexdigest() + result = EclipseJDTLS.DependencyProvider._compute_workspace_hash( + self.REPO, self.DEFAULT_LAUNCHER, SolidLSPSettings.CustomLSSettings({}) + ) + assert result == expected + + def test_default_mode_ignores_launcher_path(self) -> None: + """Default-mode hash must not depend on the launcher jar path (so default users keep cache).""" + empty_settings = SolidLSPSettings.CustomLSSettings({}) + h1 = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.DEFAULT_LAUNCHER, empty_settings) + h2 = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.UPSTREAM_LAUNCHER, empty_settings) + assert h1 == h2 + + def test_upstream_mode_includes_launcher_path(self) -> None: + """When jdtls_path is set, different launcher paths must produce different hashes.""" + settings = SolidLSPSettings.CustomLSSettings({"jdtls_path": "/opt/homebrew/Cellar/jdtls/1.50.0/libexec"}) + h1 = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.DEFAULT_LAUNCHER, settings) + h2 = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.UPSTREAM_LAUNCHER, settings) + assert h1 != h2 + + def test_upstream_and_default_produce_different_hashes(self) -> None: + """Same repo + same launcher path but different mode → different ws_dir (isolation).""" + default_h = EclipseJDTLS.DependencyProvider._compute_workspace_hash( + self.REPO, self.UPSTREAM_LAUNCHER, SolidLSPSettings.CustomLSSettings({}) + ) + upstream_h = EclipseJDTLS.DependencyProvider._compute_workspace_hash( + self.REPO, + self.UPSTREAM_LAUNCHER, + SolidLSPSettings.CustomLSSettings({"jdtls_path": "/opt/homebrew/Cellar/jdtls/1.50.0/libexec"}), + ) + assert default_h != upstream_h + + def test_different_repo_paths_produce_different_hashes(self) -> None: + empty_settings = SolidLSPSettings.CustomLSSettings({}) + h1 = EclipseJDTLS.DependencyProvider._compute_workspace_hash("/a/repo", self.DEFAULT_LAUNCHER, empty_settings) + h2 = EclipseJDTLS.DependencyProvider._compute_workspace_hash("/b/repo", self.DEFAULT_LAUNCHER, empty_settings) + assert h1 != h2 diff --git a/test/solidlsp/json_ls/test_json_diagnostics.py b/test/solidlsp/json_ls/test_json_diagnostics.py new file mode 100644 index 00000000..7850083a --- /dev/null +++ b/test/solidlsp/json_ls/test_json_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.json +class TestJsonDiagnostics: + @pytest.mark.parametrize("language_server", [Language.JSON], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.json", + (), + min_count=1, + ) diff --git a/test/solidlsp/julia/test_julia_basic.py b/test/solidlsp/julia/test_julia_basic.py index 44adb43d..176587f7 100644 --- a/test/solidlsp/julia/test_julia_basic.py +++ b/test/solidlsp/julia/test_julia_basic.py @@ -6,6 +6,7 @@ from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language from test.conftest import is_ci from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics @pytest.mark.skipif(shutil.which("julia") is None and not is_ci, reason="Julia is not available") @@ -66,3 +67,12 @@ class TestJuliaLanguageServer: f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", pytrace=False, ) + + @pytest.mark.parametrize("language_server", [Language.JULIA], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.jl", + (), + min_count=1, + ) diff --git a/test/solidlsp/kotlin/test_kotlin_diagnostics.py b/test/solidlsp/kotlin/test_kotlin_diagnostics.py new file mode 100644 index 00000000..c189825f --- /dev/null +++ b/test/solidlsp/kotlin/test_kotlin_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.kotlin +class TestKotlinDiagnostics: + @pytest.mark.parametrize("language_server", [Language.KOTLIN], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/main/kotlin/test_repo/DiagnosticsSample.kt", + (), + min_count=1, + ) diff --git a/test/solidlsp/lean4/test_lean4_diagnostics.py b/test/solidlsp/lean4/test_lean4_diagnostics.py new file mode 100644 index 00000000..964bc2c6 --- /dev/null +++ b/test/solidlsp/lean4/test_lean4_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.lean4 +class TestLean4Diagnostics: + @pytest.mark.parametrize("language_server", [Language.LEAN4], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "DiagnosticsSample.lean", + (), + min_count=1, + ) diff --git a/test/solidlsp/lua/test_lua_basic.py b/test/solidlsp/lua/test_lua_basic.py index f140881b..5465fda1 100644 --- a/test/solidlsp/lua/test_lua_basic.py +++ b/test/solidlsp/lua/test_lua_basic.py @@ -10,6 +10,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind +from test.conftest import find_identifier_position, get_repo_path, language_has_verified_implementation_support from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @@ -82,6 +83,33 @@ class TestLuaLanguageServer: # Check for Logger class/table assert "Logger" in all_symbols or any("Logger" in s for s in all_symbols), "Logger not found in symbols" + if language_has_verified_implementation_support(Language.LUA): + + @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.LUA) + pos = find_identifier_position(repo_path / "src" / "animals.lua", "speak") + assert pos is not None, "Could not find Animal:speak in fixture" + + implementations = language_server.request_implementation("src/animals.lua", *pos) + assert implementations, "Expected at least one implementation of Animal:speak" + assert any("animals.lua" in implementation.get("relativePath", "") for implementation in implementations), ( + f"Expected Dog:speak in implementations, got: {implementations}" + ) + + @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.LUA) + pos = find_identifier_position(repo_path / "src" / "animals.lua", "speak") + assert pos is not None, "Could not find Animal:speak in fixture" + + implementing_symbols = language_server.request_implementing_symbols("src/animals.lua", *pos) + assert implementing_symbols, "Expected implementing symbols for Animal:speak" + assert any( + symbol.get("name") == "speak" and "animals.lua" in symbol["location"].get("relativePath", "") + for symbol in implementing_symbols + ), f"Expected Dog:speak symbol, got: {implementing_symbols}" + @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) def test_find_symbols_in_main(self, language_server: SolidLanguageServer) -> None: """Test finding functions in main.lua.""" @@ -133,7 +161,7 @@ class TestLuaLanguageServer: assert refs is not None assert isinstance(refs, list) - # add function appears in: main.lua (lines 16, 71), test_calculator.lua (lines 22, 23, 24) + # add function appears in: main.lua (lines 17, 78), test_calculator.lua (lines 22, 23, 24) # Note: The declaration itself may or may not be included as a reference assert len(refs) >= 5, f"Should find at least 5 references to calculator.add, found {len(refs)}" @@ -153,7 +181,7 @@ class TestLuaLanguageServer: # Check main.lua has usages assert "main.lua" in ref_files, "Should find add usages in main.lua" - assert 15 in ref_files["main.lua"] or 70 in ref_files["main.lua"], ( + assert 16 in ref_files["main.lua"] or 77 in ref_files["main.lua"], ( f"Should find add usage in main.lua, found at lines {ref_files.get('main.lua', [])}" ) @@ -189,7 +217,7 @@ class TestLuaLanguageServer: assert refs is not None assert isinstance(refs, list) - # trim function appears in: usage (line 32 in main.lua) + # trim function appears in: usage (line 33 in main.lua) # Note: The declaration itself may or may not be included as a reference assert len(refs) >= 1, f"Should find at least 1 reference to utils.trim, found {len(refs)}" @@ -209,8 +237,8 @@ class TestLuaLanguageServer: # Check main.lua has usage assert "main.lua" in ref_files, "Should find trim usage in main.lua" - assert 31 in ref_files["main.lua"], ( - f"Should find trim usage at line 32 (0-indexed: 31) in main.lua, found at lines {ref_files.get('main.lua', [])}" + assert 32 in ref_files["main.lua"], ( + f"Should find trim usage at line 33 (0-indexed: 32) in main.lua, found at lines {ref_files.get('main.lua', [])}" ) # Check for cross-file references from main.lua diff --git a/test/solidlsp/matlab/test_matlab_basic.py b/test/solidlsp/matlab/test_matlab_basic.py index 71724ff7..a1582887 100644 --- a/test/solidlsp/matlab/test_matlab_basic.py +++ b/test/solidlsp/matlab/test_matlab_basic.py @@ -17,6 +17,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics # Skip all tests if MATLAB is not available pytestmark = pytest.mark.matlab @@ -129,3 +130,12 @@ class TestMatlabLanguageServerReferences: f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", pytrace=False, ) + + @pytest.mark.parametrize("language_server", [Language.MATLAB], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.m", + (), + min_count=1, + ) diff --git a/test/solidlsp/nix/test_nix_basic.py b/test/solidlsp/nix/test_nix_basic.py index 23f4311a..e66ddfa5 100644 --- a/test/solidlsp/nix/test_nix_basic.py +++ b/test/solidlsp/nix/test_nix_basic.py @@ -13,6 +13,7 @@ from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind from test.conftest import is_ci from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics # Skip all Nix tests on Windows as Nix doesn't support Windows pytestmark = pytest.mark.skipif(platform.system() == "Windows", reason="Nix and nil are not available on Windows") @@ -250,3 +251,12 @@ class TestNixLanguageServer: f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", pytrace=False, ) + + @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.nix", + (), + min_count=1, + ) diff --git a/test/solidlsp/ocaml/test_ocaml_diagnostics.py b/test/solidlsp/ocaml/test_ocaml_diagnostics.py new file mode 100644 index 00000000..a76f95cb --- /dev/null +++ b/test/solidlsp/ocaml/test_ocaml_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.ocaml +class TestOcamlDiagnostics: + @pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "lib/diagnostics_sample.ml", + (), + min_count=1, + ) diff --git a/test/solidlsp/pascal/test_pascal_diagnostics.py b/test/solidlsp/pascal/test_pascal_diagnostics.py new file mode 100644 index 00000000..ad96a1ea --- /dev/null +++ b/test/solidlsp/pascal/test_pascal_diagnostics.py @@ -0,0 +1,25 @@ +import shutil + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled +from test.solidlsp.util.diagnostics import assert_file_diagnostics + +pytestmark = [ + pytest.mark.pascal, + pytest.mark.skipif(not language_tests_enabled(Language.PASCAL), reason="Pascal tests are disabled"), + pytest.mark.skipif(shutil.which("fpc") is None, reason="Pascal diagnostics require the Free Pascal compiler"), +] + + +class TestPascalDiagnostics: + @pytest.mark.parametrize("language_server", [Language.PASCAL], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.pas", + (), + min_count=1, + ) diff --git a/test/solidlsp/perl/test_perl_basic.py b/test/solidlsp/perl/test_perl_basic.py index 41682e3f..efb01d01 100644 --- a/test/solidlsp/perl/test_perl_basic.py +++ b/test/solidlsp/perl/test_perl_basic.py @@ -6,6 +6,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics @pytest.mark.perl @@ -88,3 +89,12 @@ class TestPerlLanguageServer: f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", pytrace=False, ) + + @pytest.mark.parametrize("language_server", [Language.PERL], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.pl", + (), + min_count=1, + ) diff --git a/test/solidlsp/php/test_php_diagnostics.py b/test/solidlsp/php/test_php_diagnostics.py new file mode 100644 index 00000000..1cfc67de --- /dev/null +++ b/test/solidlsp/php/test_php_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.php +class TestPhpDiagnostics: + @pytest.mark.parametrize("language_server", [Language.PHP], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.php", + (), + min_count=1, + ) diff --git a/test/solidlsp/powershell/test_powershell_diagnostics.py b/test/solidlsp/powershell/test_powershell_diagnostics.py new file mode 100644 index 00000000..ac8706cf --- /dev/null +++ b/test/solidlsp/powershell/test_powershell_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.powershell +class TestPowershellDiagnostics: + @pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.ps1", + (), + min_count=1, + ) diff --git a/test/solidlsp/python/test_python_basic.py b/test/solidlsp/python/test_python_basic.py index f57b37bd..f3d46f0f 100644 --- a/test/solidlsp/python/test_python_basic.py +++ b/test/solidlsp/python/test_python_basic.py @@ -12,6 +12,7 @@ import pytest from serena.project import Project from serena.util.text_utils import LineType from solidlsp import SolidLanguageServer +from test.conftest import PYTHON_LANGUAGE_BACKENDS from test.solidlsp.conftest import PYTHON_BACKEND_LANGUAGES, format_symbol_for_assert, has_malformed_name, request_all_symbols @@ -79,6 +80,26 @@ class TestPythonLanguageServerBasics: references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) assert len(references) > 1, "Should get valid references for create_user (using selectionRange if present)" + @pytest.mark.parametrize("language_server", PYTHON_LANGUAGE_BACKENDS, indirect=True) + def test_request_text_document_diagnostics_with_filters(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("test_repo", "diagnostics_sample.py") + + diagnostics = language_server.request_text_document_diagnostics(file_path) + assert len(diagnostics) >= 2 + diagnostic_messages = [diagnostic["message"] for diagnostic in diagnostics] + assert any("missing_user" in message for message in diagnostic_messages), diagnostic_messages + assert any("undefined_name" in message for message in diagnostic_messages), diagnostic_messages + + factory_diagnostics = language_server.request_text_document_diagnostics(file_path, start_line=3, end_line=5, min_severity=1) + factory_messages = [diagnostic["message"] for diagnostic in factory_diagnostics] + assert factory_messages, "Expected diagnostics in broken_factory range" + assert all("missing_user" in message for message in factory_messages), factory_messages + + consumer_diagnostics = language_server.request_text_document_diagnostics(file_path, start_line=7, end_line=10, min_severity=1) + consumer_messages = [diagnostic["message"] for diagnostic in consumer_diagnostics] + assert consumer_messages, "Expected diagnostics in broken_consumer range" + assert all("undefined_name" in message for message in consumer_messages), consumer_messages + class TestProjectBasics: @pytest.mark.parametrize("project", PYTHON_BACKEND_LANGUAGES, indirect=True) diff --git a/test/solidlsp/python/test_python_diagnostics.py b/test/solidlsp/python/test_python_diagnostics.py new file mode 100644 index 00000000..386d2830 --- /dev/null +++ b/test/solidlsp/python/test_python_diagnostics.py @@ -0,0 +1,19 @@ +import os + +import pytest + +from solidlsp import SolidLanguageServer +from test.conftest import PYTHON_LANGUAGE_BACKENDS +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.python +class TestPythonDiagnostics: + @pytest.mark.parametrize("language_server", PYTHON_LANGUAGE_BACKENDS, indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + os.path.join("test_repo", "diagnostics_sample.py"), + ("missing_user", "undefined_name"), + min_count=2, + ) diff --git a/test/solidlsp/rust/test_rust_basic.py b/test/solidlsp/rust/test_rust_basic.py index 814892d0..4c7ec2c0 100644 --- a/test/solidlsp/rust/test_rust_basic.py +++ b/test/solidlsp/rust/test_rust_basic.py @@ -5,6 +5,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils +from test.conftest import find_identifier_position, get_repo_path, language_has_verified_implementation_support from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @@ -57,12 +58,39 @@ class TestRustLanguageServer: assert SymbolUtils.symbol_tree_contains_name(symbols, "main"), "main missing from overview" assert SymbolUtils.symbol_tree_contains_name(symbols, "add"), "add missing from overview" + if language_has_verified_implementation_support(Language.RUST): + + @pytest.mark.parametrize("language_server", [Language.RUST], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.RUST) + pos = find_identifier_position(repo_path / os.path.join("src", "lib.rs"), "format_greeting") + assert pos is not None, "Could not find Greeter.format_greeting in fixture" + + implementations = language_server.request_implementation(os.path.join("src", "lib.rs"), *pos) + assert implementations, "Expected at least one implementation of Greeter.format_greeting" + assert any("src/lib.rs" in implementation.get("relativePath", "").replace("\\", "/") for implementation in implementations), ( + f"Expected ConsoleGreeter.format_greeting in implementations, got: {implementations}" + ) + + @pytest.mark.parametrize("language_server", [Language.RUST], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.RUST) + pos = find_identifier_position(repo_path / os.path.join("src", "lib.rs"), "format_greeting") + assert pos is not None, "Could not find Greeter.format_greeting in fixture" + + implementing_symbols = language_server.request_implementing_symbols(os.path.join("src", "lib.rs"), *pos) + assert implementing_symbols, "Expected implementing symbols for Greeter.format_greeting" + assert any( + symbol.get("name") == "format_greeting" and "src/lib.rs" in symbol["location"].get("relativePath", "").replace("\\", "/") + for symbol in implementing_symbols + ), f"Expected ConsoleGreeter.format_greeting symbol, got: {implementing_symbols}" + @pytest.mark.parametrize("language_server", [Language.RUST], indirect=True) def test_bare_symbol_names(self, language_server) -> None: all_symbols = request_all_symbols(language_server) malformed_symbols = [] for s in all_symbols: - if has_malformed_name(s): + if has_malformed_name(s, whitespace_allowed=True): malformed_symbols.append(s) if malformed_symbols: pytest.fail( diff --git a/test/solidlsp/rust/test_rust_diagnostics.py b/test/solidlsp/rust/test_rust_diagnostics.py new file mode 100644 index 00000000..b08f7cf7 --- /dev/null +++ b/test/solidlsp/rust/test_rust_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.rust +class TestRustDiagnostics: + @pytest.mark.parametrize("language_server", [Language.RUST], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/diagnostics_sample.rs", + (), + min_count=1, + ) diff --git a/test/solidlsp/scala/test_scala_language_server.py b/test/solidlsp/scala/test_scala_language_server.py index bfc6d8f1..4e60209d 100644 --- a/test/solidlsp/scala/test_scala_language_server.py +++ b/test/solidlsp/scala/test_scala_language_server.py @@ -6,6 +6,7 @@ import pytest from solidlsp.language_servers.scala_language_server import ScalaLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.settings import SolidLSPSettings +from test.solidlsp.util.diagnostics import assert_file_diagnostics pytest.skip("Scala must be compiled for these tests to run through, which is a huge hassle", allow_module_level=True) @@ -62,3 +63,12 @@ def test_scala_find_definition_and_references_across_files(scala_ls): assert first_def["range"]["start"]["character"] == 6 assert first_def["range"]["end"]["line"] == 7 assert first_def["range"]["end"]["character"] == 14 + + +def test_file_diagnostics(scala_ls) -> None: + assert_file_diagnostics( + scala_ls, + "src/main/scala/com/example/DiagnosticsSample.scala", + (), + min_count=1, + ) diff --git a/test/solidlsp/solidity/test_solidity_basic.py b/test/solidlsp/solidity/test_solidity_basic.py index e095871f..2279140e 100644 --- a/test/solidlsp/solidity/test_solidity_basic.py +++ b/test/solidlsp/solidity/test_solidity_basic.py @@ -13,6 +13,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from test.conftest import language_has_verified_implementation_support from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @@ -165,6 +166,33 @@ class TestSolidityLanguageServerBasics: ref_files = {ref.get("uri", "") for ref in references} assert any("Token.sol" in uri for uri in ref_files), "IERC20.transfer references should include Token.sol" + if language_has_verified_implementation_support(Language.SOLIDITY): + + @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.SOLIDITY], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + pos = _find_identifier_position(repo_path / "contracts/interfaces/IERC20.sol", "transfer") + assert pos is not None, "Should find 'transfer' identifier in IERC20.sol" + + implementations = language_server.request_implementation("contracts/interfaces/IERC20.sol", *pos) + assert implementations, "Expected Token.transfer to be returned as an implementation" + assert any("Token.sol" in implementation.get("relativePath", "") for implementation in implementations), ( + f"Expected Token.transfer implementation, got: {implementations}" + ) + + @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.SOLIDITY], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + pos = _find_identifier_position(repo_path / "contracts/interfaces/IERC20.sol", "transfer") + assert pos is not None, "Should find 'transfer' identifier in IERC20.sol" + + implementing_symbols = language_server.request_implementing_symbols("contracts/interfaces/IERC20.sol", *pos) + assert implementing_symbols, "Expected implementing symbols for IERC20.transfer" + assert any( + symbol.get("name") == "transfer" and "Token.sol" in symbol["location"].get("relativePath", "") + for symbol in implementing_symbols + ), f"Expected Token.transfer symbol, got: {implementing_symbols}" + @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) def test_bare_symbol_names(self, language_server) -> None: all_symbols = request_all_symbols(language_server) diff --git a/test/solidlsp/solidity/test_solidity_diagnostics.py b/test/solidlsp/solidity/test_solidity_diagnostics.py new file mode 100644 index 00000000..fd3cd6ba --- /dev/null +++ b/test/solidlsp/solidity/test_solidity_diagnostics.py @@ -0,0 +1,24 @@ +import sys + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.solidity +class TestSolidityDiagnostics: + @pytest.mark.xfail( + sys.platform == "darwin", + reason="Unknown — passes on Ubuntu but consistently fails on macOS CI; root cause not yet identified.", + strict=False, + ) + @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "contracts/DiagnosticsSample.sol", + (), + min_count=1, + ) diff --git a/test/solidlsp/swift/test_swift_basic.py b/test/solidlsp/swift/test_swift_basic.py index 5a0d8bd0..30e9759c 100644 --- a/test/solidlsp/swift/test_swift_basic.py +++ b/test/solidlsp/swift/test_swift_basic.py @@ -16,6 +16,7 @@ from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from test.conftest import is_ci from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics # Skip Swift tests on Windows due to complex GitHub Actions configuration WINDOWS_SKIP = platform.system() == "Windows" @@ -238,3 +239,12 @@ class TestSwiftProjectBasics: f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", pytrace=False, ) + + @pytest.mark.parametrize("language_server", [Language.SWIFT], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/diagnostics_sample.swift", + (), + min_count=1, + ) diff --git a/test/solidlsp/systemverilog/test_systemverilog_diagnostics.py b/test/solidlsp/systemverilog/test_systemverilog_diagnostics.py new file mode 100644 index 00000000..a783b828 --- /dev/null +++ b/test/solidlsp/systemverilog/test_systemverilog_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.systemverilog +class TestSystemverilogDiagnostics: + @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.sv", + (), + min_count=1, + ) diff --git a/test/solidlsp/terraform/test_terraform_diagnostics.py b/test/solidlsp/terraform/test_terraform_diagnostics.py new file mode 100644 index 00000000..318cc7f9 --- /dev/null +++ b/test/solidlsp/terraform/test_terraform_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.terraform +class TestTerraformDiagnostics: + @pytest.mark.parametrize("language_server", [Language.TERRAFORM], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.tf", + (), + min_count=1, + ) diff --git a/test/solidlsp/test_ls_common.py b/test/solidlsp/test_ls_common.py index e5e00915..cfa1d11d 100644 --- a/test/solidlsp/test_ls_common.py +++ b/test/solidlsp/test_ls_common.py @@ -3,13 +3,13 @@ import os import pytest from solidlsp import SolidLanguageServer -from solidlsp.ls_config import Language +from test.conftest import PYTHON_LANGUAGE_BACKENDS class TestLanguageServerCommonFunctionality: """Test common functionality of SolidLanguageServer base implementation (not language-specific behaviour).""" - @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("language_server", PYTHON_LANGUAGE_BACKENDS, indirect=True) def test_open_file_cache_invalidate(self, language_server: SolidLanguageServer) -> None: """ Tests that the file buffer cache is invalidated when the file is changed on disk. diff --git a/test/solidlsp/toml/test_toml_diagnostics.py b/test/solidlsp/toml/test_toml_diagnostics.py new file mode 100644 index 00000000..dde2fc83 --- /dev/null +++ b/test/solidlsp/toml/test_toml_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.toml +class TestTomlDiagnostics: + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.toml", + (), + min_count=1, + ) diff --git a/test/solidlsp/typescript/test_typescript_basic.py b/test/solidlsp/typescript/test_typescript_basic.py index ba86ba04..1c9ed1ae 100644 --- a/test/solidlsp/typescript/test_typescript_basic.py +++ b/test/solidlsp/typescript/test_typescript_basic.py @@ -5,6 +5,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils +from test.conftest import find_identifier_position, get_repo_path, language_has_verified_implementation_support from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @@ -33,6 +34,64 @@ class TestTypescriptLanguageServer: "index.ts should reference helperFunction (tried all positions in selectionRange)" ) + if language_has_verified_implementation_support(Language.TYPESCRIPT): + + @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.TYPESCRIPT) + pos = find_identifier_position(repo_path / "formatters.ts", "formatGreeting") + assert pos is not None, "Could not find Greeter.formatGreeting in fixture" + + implementations = language_server.request_implementation("formatters.ts", *pos) + assert implementations, "Expected at least one implementation of Greeter.formatGreeting" + assert any("formatters.ts" in implementation.get("relativePath", "") for implementation in implementations), ( + f"Expected ConsoleGreeter.formatGreeting in implementations, got: {implementations}" + ) + + @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.TYPESCRIPT) + pos = find_identifier_position(repo_path / "formatters.ts", "formatGreeting") + assert pos is not None, "Could not find Greeter.formatGreeting in fixture" + + implementing_symbols = language_server.request_implementing_symbols("formatters.ts", *pos) + assert implementing_symbols, "Expected implementing symbols for Greeter.formatGreeting" + assert any( + symbol.get("name") == "formatGreeting" and "formatters.ts" in symbol["location"].get("relativePath", "") + for symbol in implementing_symbols + ), f"Expected ConsoleGreeter.formatGreeting symbol, got: {implementing_symbols}" + + @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True) + def test_tsx_symbol_range_not_truncated_by_jsx(self, language_server: SolidLanguageServer) -> None: + # Regression: when the language id is sent as "typescript" instead of + # "typescriptreact" for .tsx files, tsserver parses JSX as syntax + # errors and recovers by truncating the enclosing symbol's range at + # the first multi-line JSX expression. find_symbol then returns a + # body that ends mid-component and hides everything below. + file_path = "jsx_component.tsx" + roots = language_server.request_document_symbols(file_path).root_symbols + + jsx_component = next((s for s in roots if s.get("name") == "JsxComponent"), None) + assert jsx_component is not None, "JsxComponent not found at root level of jsx_component.tsx" + + end_line = jsx_component["location"]["range"]["end"]["line"] + # JsxComponent's body extends to line 38 (0-based 37) in the fixture; + # the truncation bug cut it at the first multi-line JSX (~line 21). + # Use a generous lower bound so the test survives small fixture edits + # that don't affect the regression behaviour we care about. + assert end_line >= 30, ( + f"JsxComponent symbol range truncated at line {end_line + 1} (1-based); " + f"expected end at or past line 31 (1-based). " + f"This indicates the .tsx file was opened with the wrong languageId." + ) + + # The trailing helper must be visible as a top-level symbol — it lives + # past the truncation point and disappears entirely when the bug is + # active because tsserver stops emitting symbols after the parse error. + assert any(s.get("name") == "trailingHelper" for s in roots), ( + "trailingHelper missing from jsx_component.tsx root symbols; tsserver likely stopped parsing at the first JSX expression." + ) + @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True) def test_bare_symbol_names(self, language_server) -> None: all_symbols = request_all_symbols(language_server) diff --git a/test/solidlsp/typescript/test_typescript_diagnostics.py b/test/solidlsp/typescript/test_typescript_diagnostics.py new file mode 100644 index 00000000..5e879e16 --- /dev/null +++ b/test/solidlsp/typescript/test_typescript_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.typescript +class TestTypeScriptDiagnostics: + @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.ts", + ("missingGreeting", "missingConsumerValue"), + min_count=2, + ) diff --git a/test/solidlsp/util/diagnostics.py b/test/solidlsp/util/diagnostics.py new file mode 100644 index 00000000..dee66086 --- /dev/null +++ b/test/solidlsp/util/diagnostics.py @@ -0,0 +1,19 @@ +from collections.abc import Sequence + +from solidlsp import SolidLanguageServer + + +def assert_file_diagnostics( + language_server: SolidLanguageServer, + relative_file_path: str, + expected_message_fragments: Sequence[str], + min_count: int = 1, +) -> None: + diagnostics = language_server.request_text_document_diagnostics(relative_file_path, min_severity=1) + + assert isinstance(diagnostics, list), diagnostics + assert len(diagnostics) >= min_count, diagnostics + + diagnostic_messages = [diagnostic["message"] for diagnostic in diagnostics] + for fragment in expected_message_fragments: + assert any(fragment in message for message in diagnostic_messages), diagnostic_messages diff --git a/test/solidlsp/vue/test_vue_diagnostics.py b/test/solidlsp/vue/test_vue_diagnostics.py new file mode 100644 index 00000000..08ac2fed --- /dev/null +++ b/test/solidlsp/vue/test_vue_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.vue +class TestVueDiagnostics: + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/DiagnosticsSample.vue", + (), + min_count=1, + ) diff --git a/test/solidlsp/yaml_ls/test_yaml_diagnostics.py b/test/solidlsp/yaml_ls/test_yaml_diagnostics.py new file mode 100644 index 00000000..4ba774c7 --- /dev/null +++ b/test/solidlsp/yaml_ls/test_yaml_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.yaml +class TestYamlDiagnostics: + @pytest.mark.parametrize("language_server", [Language.YAML], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.yaml", + (), + min_count=1, + ) diff --git a/test/solidlsp/zig/test_zig_basic.py b/test/solidlsp/zig/test_zig_basic.py index 51b23650..e7413482 100644 --- a/test/solidlsp/zig/test_zig_basic.py +++ b/test/solidlsp/zig/test_zig_basic.py @@ -14,6 +14,7 @@ from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +from test.solidlsp.util.diagnostics import assert_file_diagnostics @pytest.mark.zig @@ -355,3 +356,12 @@ class TestZigLanguageServer: f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", pytrace=False, ) + + @pytest.mark.parametrize("language_server", [Language.ZIG], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/diagnostics_sample.zig", + (), + min_count=1, + ) diff --git a/uv.lock b/uv.lock index 659b9257..28f8baca 100644 --- a/uv.lock +++ b/uv.lock @@ -2856,7 +2856,7 @@ wheels = [ [[package]] name = "serena-agent" -version = "1.1.2" +version = "1.2.0" source = { editable = "." } dependencies = [ { name = "anthropic" },