Merge branch 'main' into release-1.7

This commit is contained in:
Dominik Jain authored and Dominik Jain committed 2026-08-09 17:42:14 +02:00
commit 140d0b03ee
24 files changed
+994 -109

No files matched your search

+22 -4
View File
@@ -27,9 +27,11 @@
#
# CACHING
# Per-batch language-server cache keyed by os+batch (language-servers-<os>-<batch>),
# plus dedicated caches for the uv venv (keyed on uv.lock), swiftly, ZLS, FPC and
# the Go build/bin. Most niche toolchains restore from cache, so that batch is fast
# despite its size.
# plus dedicated caches for the uv venv (keyed on uv.lock), swiftly, ZLS, FPC, the
# Go build/bin, and npm for the web fixtures (Angular node_modules keyed on its
# lockfile; the npm download cache via setup-node, which also warms the svelte
# fixture's test-time `npm ci`). Most niche toolchains restore from cache, so that
# batch is fast despite its size.
#
# TESTING / SKIPS
# All language skip logic is centralized in test/conftest.py
@@ -110,6 +112,13 @@ jobs:
uses: actions/setup-node@v7
with:
node-version: '24.x'
# npm download cache, catch-all only (empty string disables caching in other
# batches -- they run no npm installs). Warms the svelte fixture's test-time
# `npm ci` and the global/LS npm installs; keyed on the committed lockfiles.
cache: ${{ matrix.batch == 'catch-all' && 'npm' || '' }}
cache-dependency-path: |
test/resources/repos/angular/test_repo/package-lock.json
test/resources/repos/svelte/test_repo/package-lock.json
- name: Ensure cached directory exist before calling cache-related actions
shell: bash
run: |
@@ -719,8 +728,17 @@ jobs:
echo "PERL_MB_OPT=--install_base \"$HOME/perl5\"" >> $GITHUB_ENV
echo "PERL_MM_OPT=INSTALL_BASE=$HOME/perl5" >> $GITHUB_ENV
echo "$HOME/perl5/bin" >> $GITHUB_PATH
- name: Install npm deps for Angular test repo
# The Angular fixture's node_modules is cached directly (exact lockfile match, like
# the uv venv cache) so the npm install below is skipped entirely on a hit.
- name: Cache Angular test repo node_modules
if: matrix.batch == 'catch-all'
id: cache-angular-npm
uses: actions/cache@v3
with:
path: test/resources/repos/angular/test_repo/node_modules
key: angular-npm-${{ runner.os }}-lock-${{ hashFiles('test/resources/repos/angular/test_repo/package-lock.json') }}
- name: Install npm deps for Angular test repo
if: matrix.batch == 'catch-all' && steps.cache-angular-npm.outputs.cache-hit != 'true'
shell: bash
run: |
if [ -d test/resources/repos/angular/test_repo ]; then
+21 -3
View File
@@ -4,6 +4,14 @@ Status of the `main` branch. Changes prior to the next official version change w
* General:
- Fix: Race conditions in ProjectServer when used by multiple clients in parallel
- Fix: `GitignoreParser` interpolated a directory's name unescaped into gitignore pattern position;
a directory named with pattern metacharacters (e.g. a stray `***`) could turn a scoped pattern
into one matching far more than intended, silently excluding most or all of the project from
indexing #1806
- Fix: cleanup of an independently-started LSP process and its children required enumerating the
system process table (`psutil`), which can be denied even for processes Serena owns in a
sandboxed environment; cleanup now signals the known process group directly (#1818)
- Fix: the README, the Language Support docs page and the project template omitted several already-supported language servers
- Fix: a tool call exceeding the timeout blocked the task executor indefinitely; the executor now
recovers without user-induced cancellation
- Add Grok Build support (context `grok`, setup CLI, hooks)
@@ -27,9 +35,11 @@ Status of the `main` branch. Changes prior to the next official version change w
* CLI:
- Fix: `start-mcp-server` help text for `--project-from-cwd` falsely promised a fallback to the CWD, which was
removed in v1.0.0 #1773
- Fix: `project health-check` always exited with code 0, even when the check failed, so callers
(CI, scripts) could not act on its verdict; it now exits with code 1 on failure. A `find_symbol`
result without any matches is now reported as a failure rather than as a warning.
- Improve `project health-check`:
- Fix: Process always exited with code 0, even when the check failed, so callers
(CI, scripts) could not act on its verdict; it now exits with code 1 on failure. A `find_symbol`
result without any matches is now reported as a failure rather than as a warning.
- Disable symbol groupers, remove flawed pattern search test
* Tools:
- `find_symbol`, `jet_brains_find_symbol`: Change tool description to improve tool search results in clients that load tools dynamically
@@ -55,6 +65,10 @@ Status of the `main` branch. Changes prior to the next official version change w
`intellicode_version`, `intellicode_xmx` and `intellicode_xms` settings remain accepted but are ignored #1821
- Kotlin: update the managed Kotlin LSP from `261.13587.0` to `262.9593.0`, including support for the
new platform-specific archive layout and Windows ARM64 builds
- Add support for Wolfram Language via the official [WolframResearch LSPServer](https://github.com/WolframResearch/LSPServer) paclet.
Requires Wolfram Mathematica 13.0+ or Wolfram Engine 12.1+. Set `WOLFRAM_PATH` environment variable or configure
`ls_path` in `ls_specific_settings`. Supports .wl and .wls files with diagnostics, document symbols,
within-file references, hover documentation, and formatting.
- Nix/nixd: support custom `ls_path` launchers and external JSON settings through `config_path` #1737
- Fix: Nix/nixd diagnostics now use published diagnostics instead of the unsupported
`textDocument/diagnostic` request, which terminated nixd #1802
@@ -86,6 +100,10 @@ Status of the `main` branch. Changes prior to the next official version change w
`Deno.*` globals, which the plain TypeScript language server does not. Overlaps TypeScript on
file extensions, so it is not auto-detected and must be selected explicitly; requires the
`deno` CLI on PATH
- Fix: `find_referencing_symbols` reported file-level containers for references located inside Go
struct bodies, interface bodies and `const` groups, because containing-symbol detection admitted
only Python's container kinds; `Struct`, `Interface` and `Constant` symbols are now admitted as
containers
* JetBrains:
- `jet_brains_find_symbol`: Disallow wildcard-only search, delegating to overview tool if request is for file
+1 -1
View File
@@ -110,7 +110,7 @@ Serena incorporates a powerful abstraction layer for the integration of language
The underlying language servers are typically open-source projects or at least freely available for use.
When using Serena's language server backend, we provide **support for over 40 programming languages**, including
Ada / SPARK, AL, Angular, Ansible, Bash, BSL, C#, C/C++, Clojure, Crystal, CUE, Dart, Deno, Elixir, Elm, Erlang, Fortran, F#, GDScript, Gleam, GLSL, Go, Groovy, Haskell, Haxe, HLSL, HTML, Java, JavaScript, JSON, Julia, Kotlin, LaTeX, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nextflow, Nix, OCaml, Pascal, Perl, PHP, PowerShell, Python, QML, R, Rego, Ruby, Rust, Scala, SCSS / Sass / CSS, Solidity, Svelte, Swift, SystemVerilog, Terraform, TOML, TypeScript, Vue, WGSL, YAML, and Zig.
Ada / SPARK, AL, Angular, Ansible, Bash, BSL, C#, C/C++, Clojure, Crystal, CUE, Dart, Deno, Elixir, Elm, Erlang, Fortran, F#, GDScript, Gleam, GLSL, Go, Groovy, Haskell, Haxe, HLSL, HTML, Java, JavaScript, JSON, Julia, Kotlin, LaTeX, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nextflow, Nix, OCaml, Pascal, Perl, PHP, PowerShell, Python, QML, R, Rego, Ruby, Rust, Scala, SCSS / Sass / CSS, Solidity, Svelte, Swift, SystemVerilog, Terraform, TOML, TypeScript, Vue, WGSL, Wolfram Language, YAML, and Zig.
### The Serena JetBrains Plugin
+3 -1
View File
@@ -177,8 +177,10 @@ Some languages require additional installations or setup steps, as noted.
globals, which the plain TypeScript language server does not; overlaps `typescript` on file extensions,
so it is not auto-detected and must be set as the language explicitly — do not also enable `typescript`
for the same files)
* **Vue**
* **Vue**
(3.x with TypeScript; requires Node.js v18+ and npm; supports .vue Single File Components with monorepo detection)
* **Wolfram Language**
(requires Wolfram Mathematica 13.0+ or Wolfram Engine 12.1+; uses the official [WolframResearch LSPServer](https://github.com/WolframResearch/LSPServer) paclet; supports .wl and .wls files; references are within-file only)
* **YAML**
* **JSON**
(experimental; must be explicitly added to the languages list; requires Node.js and npm)
+1
View File
@@ -381,6 +381,7 @@ markers = [
"ada: language server running for Ada / SPARK (uses AdaCore Ada Language Server)",
"qml: language server running for QML (uses Qt's qmlls)",
"gleam: language server running for Gleam (uses the Gleam compiler's bundled `gleam lsp`)",
"wolfram: language server running for Wolfram Language (requires Mathematica 13.0+ or Wolfram Engine 12.1+)",
]
[tool.codespell]
+10 -26
View File
@@ -921,14 +921,14 @@ class ProjectCommands(AutoRegisteringGroup):
@click.argument("project", type=click.Path(exists=True, file_okay=False, dir_okay=True), default=os.getcwd())
def health_check(project: str) -> None:
"""
Perform a comprehensive health check of the project's tools and language server.
Perform a basic health check which checks whether language server tools are functional for the project.
: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
from serena.tools import FindReferencingSymbolsTool, FindSymbolTool, GetSymbolsOverviewTool
logging.configure(level=logging.INFO)
project_path = os.path.abspath(project)
@@ -977,7 +977,6 @@ class ProjectCommands(AutoRegisteringGroup):
overview_tool = agent.get_tool(GetSymbolsOverviewTool)
find_symbol_tool = agent.get_tool(FindSymbolTool)
find_refs_tool = agent.get_tool(FindReferencingSymbolsTool)
search_pattern_tool = agent.get_tool(SearchForPatternTool)
# Test 1: Get symbols overview
log.info("Testing GetSymbolsOverviewTool on file: %s", target_file)
@@ -1006,42 +1005,27 @@ class ProjectCommands(AutoRegisteringGroup):
# Test 2: FindSymbolTool
log.info("Testing FindSymbolTool for symbol: %s", symbol_name)
find_symbol_result = agent.execute_task(
lambda: find_symbol_tool.apply(symbol_name, relative_path=target_file, include_body=True)
)
with find_symbol_tool.symbol_dict_grouper.disabled_context():
find_symbol_result = agent.execute_task(
lambda: find_symbol_tool.apply(symbol_name, relative_path=target_file, include_body=True)
)
find_symbol_data = json.loads(find_symbol_result)
log.info("FindSymbolTool found %d matches for symbol %s", len(find_symbol_data), symbol_name)
# Test 3: FindReferencingSymbolsTool
log.info("Testing FindReferencingSymbolsTool for symbol: %s", symbol_name)
try:
find_refs_result = agent.execute_task(lambda: find_refs_tool.apply(symbol_name, relative_path=target_file))
find_refs_data = json.loads(find_refs_result)
log.info("FindReferencingSymbolsTool found %d references for symbol %s", len(find_refs_data), symbol_name)
with find_refs_tool.symbol_dict_grouper.disabled_context():
find_refs_result = agent.execute_task(lambda: find_refs_tool.apply(symbol_name, relative_path=target_file))
find_refs_data = json.loads(find_refs_result)
log.info("FindReferencingSymbolsTool found %d references for symbol %s", len(find_refs_data), symbol_name)
except Exception as e:
log.warning("FindReferencingSymbolsTool failed for symbol %s: %s", symbol_name, str(e))
find_refs_data = []
# Test 4: SearchForPatternTool to verify references
log.info("Testing SearchForPatternTool for pattern: %s", symbol_name)
try:
search_result = agent.execute_task(
lambda: search_pattern_tool.apply(substring_pattern=symbol_name, restrict_search_to_code_files=True)
)
search_data = json.loads(search_result)
pattern_matches = sum(len(matches) for matches in search_data.values())
log.info("SearchForPatternTool found %d pattern matches for %s", pattern_matches, symbol_name)
except Exception as e:
log.warning("SearchForPatternTool failed for pattern %s: %s", symbol_name, str(e))
pattern_matches = 0
# Verify tools worked as expected
if not find_symbol_data:
raise ProjectCommands._HealthCheckFailure("FindSymbolTool returned no results")
if len(find_refs_data) == 0 and pattern_matches == 0:
log.warning("Both FindReferencingSymbolsTool and SearchForPatternTool found no matches - this might indicate an issue")
log.info("Health check completed successfully")
# expected failures and unexpected exceptions are reported alike: both mean the project's
+11 -11
View File
@@ -6,17 +6,17 @@ project_name: "project_name"
# bsl clojure cpp cpp_ccls crystal
# csharp csharp_omnisharp cue dart deno
# elixir elm erlang fortran fsharp
# gdscript go groovy haskell haxe
# hlsl html java json julia
# kotlin latex lean4 lua luau
# markdown matlab msl nextflow nix
# ocaml pascal perl php php_phpactor
# php_phpantom powershell python python_basedpyright python_jedi
# python_pyrefly python_ty qml r rego
# ruby ruby_solargraph rust scala scss
# solidity svelte swift systemverilog terraform
# toml typescript typescript_vts vue yaml
# zig
# gdscript gleam go groovy haskell
# haxe hlsl html java json
# julia kotlin latex lean4 lua
# luau markdown matlab msl nextflow
# nix ocaml pascal perl php
# php_phpactor php_phpantom powershell python python_basedpyright
# python_jedi python_pyrefly python_ty qml r
# rego ruby ruby_solargraph rust scala
# scss solidity svelte swift systemverilog
# terraform toml typescript typescript_vts vue
# wolfram yaml zig
# (This list may be outdated; generated with scripts/print_language_list.py;
# For the current list, see values of the LanguageServerId enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py)
+32 -9
View File
@@ -1,5 +1,6 @@
import logging
import os
import re
from collections.abc import Callable, Iterator
from dataclasses import dataclass, field
from pathlib import Path
@@ -11,6 +12,18 @@ from sensai.util.logging import LogTime
log = logging.getLogger(__name__)
# Characters meaningful to pathspec's gitignore grammar: glob wildcards, bracket expressions,
# the escape character itself, and '!'/'#' which change a whole pattern's meaning when they
# are its first character. Backslash-escaping them makes a literal name safe to interpolate.
_GITIGNORE_PATTERN_SPECIAL_CHARS_RE = re.compile(r"([\\*?\[\]!#])")
def _escape_gitignore_path_component(component: str) -> str:
"""Escape gitignore/pathspec pattern metacharacters in a single path component (no
separators) so it is matched as a literal name rather than as glob syntax.
"""
return _GITIGNORE_PATTERN_SPECIAL_CHARS_RE.sub(r"\\\1", component)
class ScanResult(NamedTuple):
"""Result of scanning a directory."""
@@ -223,11 +236,21 @@ class GitignoreParser:
"""
patterns = []
# Get the relative path from repo root to the gitignore directory
rel_dir = os.path.relpath(gitignore_dir, self.repo_root)
# Get the relative path from repo root to the gitignore directory. Normalize to
# forward slashes immediately: os.path.relpath returns native separators, but
# gitignore/pathspec patterns are always POSIX-style, and on Windows os.sep is
# backslash -- the same character pathspec uses as its escape character. Building
# the pattern with any raw os.sep would make a later blanket backslash->slash
# normalization indistinguishable from the escape backslashes below.
rel_dir = os.path.relpath(gitignore_dir, self.repo_root).replace(os.sep, "/")
if rel_dir == ".":
rel_dir = ""
# rel_dir is a filesystem path, but the code below interpolates it into pattern
# position; escape each of its components so a directory name containing pattern
# metacharacters (e.g. "***") is matched literally instead of as glob syntax.
rel_dir_pattern = "/".join(_escape_gitignore_path_component(part) for part in rel_dir.split("/")) if rel_dir else rel_dir
for line in content.splitlines():
# Strip trailing whitespace (but preserve leading whitespace for now)
line = line.rstrip()
@@ -256,20 +279,23 @@ class GitignoreParser:
if is_anchored:
line = line[1:]
# Adjust pattern based on gitignore file location
# Adjust pattern based on gitignore file location. Joined with a literal "/",
# never os.path.join/os.sep: gitignore patterns are always POSIX-style, and on
# Windows os.sep is backslash, indistinguishable from the escape backslashes
# rel_dir_pattern may already contain.
if rel_dir:
if is_anchored:
# Anchored patterns are relative to the gitignore directory
adjusted_pattern = os.path.join(rel_dir, line)
adjusted_pattern = f"{rel_dir_pattern}/{line}"
else:
# Non-anchored patterns can match anywhere below the gitignore directory
# We need to preserve this behavior
if line.startswith("**/"):
# Even if pattern starts with **, it should still be scoped to the subdirectory
adjusted_pattern = os.path.join(rel_dir, line)
adjusted_pattern = f"{rel_dir_pattern}/{line}"
else:
# Add the directory prefix but also allow matching in subdirectories
adjusted_pattern = os.path.join(rel_dir, "**", line)
adjusted_pattern = f"{rel_dir_pattern}/**/{line}"
else:
if is_anchored:
# Anchored patterns in root should only match at root level
@@ -283,9 +309,6 @@ class GitignoreParser:
if is_negation:
adjusted_pattern = "!" + adjusted_pattern
# Normalize path separators to forward slashes (gitignore uses forward slashes)
adjusted_pattern = adjusted_pattern.replace(os.sep, "/")
patterns.append(adjusted_pattern)
return patterns
@@ -88,6 +88,10 @@ class NextflowLanguageServer(SolidLanguageServer):
self._active_progress_tokens: set[str] = set()
self._progress_lock = threading.Lock()
# Whether the server's deferred workspace scan has been observed to complete
# (see _flush_deferred_workspace_scan).
self._workspace_scan_flushed = False
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
return self.DependencyProvider(self._custom_settings, self._ls_resources_dir)
@@ -297,6 +301,32 @@ class NextflowLanguageServer(SolidLanguageServer):
else:
log.warning("Nextflow LS did not signal scan completion within %.0fs; proceeding anyway", _WORKSPACE_SCAN_TIMEOUT)
def _flush_deferred_workspace_scan(self, relative_file_path: str) -> None:
"""
Forces the workspace scan, which the server defers past the first request of a session.
The scan runs in ``LanguageService.update0`` behind a 1s debounce, and only on a round with no
pending file change; the session's first ``didOpen`` re-defers it, leaving references to be
answered from an AST cache holding just that one file.
``completion`` calls ``updateNow``, which runs the update synchronously before replying, so two
such requests force the scan whatever the workspace size: the first drains the pending change,
the second finds none and scans. Their results are discarded.
"""
if self._workspace_scan_flushed:
return
params: lsp_types.CompletionParams = {
"textDocument": {"uri": self._resolve_file_uri(relative_file_path)},
"position": {"line": 0, "character": 0},
}
for _ in range(2):
try:
self.server.send.completion(params)
except Exception as e:
log.debug("Completion request used to flush the Nextflow workspace scan failed: %s", e)
self._workspace_scan_flushed = True
@override
def _send_references_request(self, relative_file_path: str, line: int, column: int) -> list[lsp_types.Location] | None:
"""
@@ -309,13 +339,14 @@ class NextflowLanguageServer(SolidLanguageServer):
come back empty. Sending a ``documentSymbol`` request for the same file first is a cheap way to
block until the update has been applied.
"""
self._flush_deferred_workspace_scan(relative_file_path)
self.server.send.document_symbol({"textDocument": {"uri": self._resolve_file_uri(relative_file_path)}})
return super()._send_references_request(relative_file_path, line, column)
@override
def _get_wait_time_for_cross_file_referencing(self) -> float:
"""Small safety buffer; the actual synchronisation happens in _send_references_request."""
return 1.0
"""No blind wait is needed: _send_references_request synchronises with the server explicitly."""
return 0.0
@override
def _document_symbols_cache_fingerprint(self) -> Hashable:
@@ -0,0 +1,183 @@
"""Wolfram Language server integration using the official WolframResearch LSPServer paclet."""
import glob
import logging
import os
import platform
import shutil
from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
WOLFRAM_PATH_ENV_VAR = "WOLFRAM_PATH"
class WolframLanguageServer(SolidLanguageServer):
"""
Wolfram Language server using the official WolframResearch LSPServer paclet
(https://github.com/WolframResearch/LSPServer), which is bundled with Wolfram
installations and communicates via stdio.
Requires Wolfram Mathematica 13.0+ or Wolfram Engine 12.1+.
The WolframKernel executable is discovered via the ``ls_path`` entry in
``ls_specific_settings.wolfram``, the ``WOLFRAM_PATH`` environment variable
(pointing to the executable or the installation directory), the system PATH,
or common installation locations.
"""
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
super().__init__(config, repository_root_path, None, "wolfram", solidlsp_settings)
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
return self.DependencyProvider(self._custom_settings, self._ls_resources_dir)
class DependencyProvider(LanguageServerDependencyProviderSinglePath):
def _get_or_install_core_dependency(self) -> str:
"""
Discover the WolframKernel executable.
:return: path to the WolframKernel executable
:raises FileNotFoundError: if WolframKernel cannot be found
"""
return _find_wolfram_kernel()
def _create_launch_command(self, core_path: str) -> list[str]:
return [core_path, "-noprompt", "-noinit", "-run", 'Needs["LSPServer`"];LSPServer`StartServer[]']
def _create_base_initialize_params(self) -> dict:
return {
"capabilities": {
"workspace": {"workspaceFolders": True},
"textDocument": {
"definition": {"dynamicRegistration": True},
"references": {"dynamicRegistration": True},
"documentSymbol": {
"dynamicRegistration": True,
"hierarchicalDocumentSymbolSupport": True,
},
"hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]},
"formatting": {"dynamicRegistration": True},
"publishDiagnostics": {"relatedInformation": True},
},
},
}
def _start_server(self) -> None:
def do_nothing(params: dict) -> None:
return
def window_log_message(msg: dict) -> None:
log.info(f"Wolfram LSP: window/logMessage: {msg}")
self.server.on_request("client/registerCapability", do_nothing)
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_notification("$/progress", do_nothing)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
log.info("Starting Wolfram LSPServer process")
self.server.start()
initialize_params = self._create_initialize_params()
init_response = self.server.send.initialize(initialize_params)
log.info(f"Wolfram LSP capabilities: {list(init_response.get('capabilities', {}).keys())}")
self.server.notify.initialized({})
log.info("Wolfram LSPServer initialized and ready.")
def _find_wolfram_kernel() -> str:
"""Find the WolframKernel executable via WOLFRAM_PATH, the system PATH, or common install locations."""
# 1. WOLFRAM_PATH environment variable (executable or installation directory)
env_path = os.environ.get(WOLFRAM_PATH_ENV_VAR)
if env_path:
if os.path.isfile(env_path) and os.access(env_path, os.X_OK):
log.info(f"Using WolframKernel from {WOLFRAM_PATH_ENV_VAR}: {env_path}")
return env_path
kernel_in_dir = _find_kernel_in_install_dir(env_path)
if kernel_in_dir:
log.info(f"Using WolframKernel from {WOLFRAM_PATH_ENV_VAR} directory: {kernel_in_dir}")
return kernel_in_dir
# 2. System PATH
kernel_path = shutil.which("WolframKernel")
if kernel_path:
log.info(f"Using WolframKernel from PATH: {kernel_path}")
return kernel_path
# 3. Common installation locations
system = platform.system()
search_locations: list[str] = []
if system == "Darwin":
search_locations = [
"/Applications/Mathematica.app/Contents/MacOS/WolframKernel",
"/Applications/Wolfram.app/Contents/MacOS/WolframKernel",
"/Applications/Wolfram Engine.app/Contents/MacOS/WolframKernel",
]
for pattern in [
"/Applications/Mathematica*.app/Contents/MacOS/WolframKernel",
"/Applications/Wolfram*.app/Contents/MacOS/WolframKernel",
]:
search_locations.extend(sorted(glob.glob(pattern), reverse=True))
elif system == "Linux":
search_locations = [
"/usr/local/bin/WolframKernel",
"/usr/bin/WolframKernel",
]
for pattern in [
"/usr/local/Wolfram/Mathematica/*/Executables/WolframKernel",
"/usr/local/Wolfram/WolframEngine/*/Executables/WolframKernel",
"/opt/Wolfram/Mathematica/*/Executables/WolframKernel",
]:
search_locations.extend(sorted(glob.glob(pattern), reverse=True))
elif system == "Windows":
for pattern in [
"C:\\Program Files\\Wolfram Research\\Mathematica\\*\\WolframKernel.exe",
"C:\\Program Files\\Wolfram Research\\Wolfram Engine\\*\\WolframKernel.exe",
]:
search_locations.extend(sorted(glob.glob(pattern), reverse=True))
for location in search_locations:
if os.path.isfile(location) and os.access(location, os.X_OK):
log.info(f"Found WolframKernel at: {location}")
return location
raise FileNotFoundError(
"WolframKernel not found. Please either:\n"
f"1. Set the {WOLFRAM_PATH_ENV_VAR} environment variable to your Wolfram installation\n"
"2. Add WolframKernel to your system PATH\n"
"3. Configure ls_path in ls_specific_settings.wolfram\n"
"4. Install Wolfram Mathematica (13.0+) or Wolfram Engine (12.1+) from https://www.wolfram.com/"
)
def _find_kernel_in_install_dir(install_dir: str) -> str | None:
"""Try to locate WolframKernel within a Wolfram installation directory."""
system = platform.system()
if system == "Darwin":
candidates = [
os.path.join(install_dir, "Contents", "MacOS", "WolframKernel"),
os.path.join(install_dir, "MacOS", "WolframKernel"),
]
elif system == "Windows":
candidates = [
os.path.join(install_dir, "WolframKernel.exe"),
]
else:
candidates = [
os.path.join(install_dir, "Executables", "WolframKernel"),
os.path.join(install_dir, "WolframKernel"),
]
for candidate in candidates:
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
return None
+17 -6
View File
@@ -2533,8 +2533,9 @@ class SolidLanguageServer(ABC):
) -> ls_types.UnifiedSymbolInformation | None:
"""
Finds the first symbol containing the position for the given file.
For Python, container symbols are considered to be those with kinds corresponding to
functions, methods, or classes (typically: Function (12), Method (6), Class (5)).
Container symbols are those with kinds corresponding to functions, methods, classes, structs
and interfaces, as well as variables and constants (which may be one-liners, e.g. members
of a Go ``const`` group).
The method operates as follows:
- Request the document symbols for the file.
@@ -2546,7 +2547,7 @@ class SolidLanguageServer(ABC):
among those above the given line.
- If no container candidates are found, return None.
:param relative_file_path: The relative path to the Python file.
:param relative_file_path: The relative path to the file.
:param line: The 0-indexed line number.
:param column: The 0-indexed column (also called character). If not passed, the lookup will be based
only on the line.
@@ -2589,8 +2590,15 @@ class SolidLanguageServer(ABC):
location["relativePath"] = relative_file_path
location["uri"] = Path(absolute_file_path).as_uri()
# Allowed container kinds, currently only for Python
container_symbol_kinds = {ls_types.SymbolKind.Method, ls_types.SymbolKind.Function, ls_types.SymbolKind.Class}
# allowed container kinds; Struct and Interface cover languages (e.g. Go, Rust, C) whose
# containers are not reported as classes
container_symbol_kinds = {
ls_types.SymbolKind.Method,
ls_types.SymbolKind.Function,
ls_types.SymbolKind.Class,
ls_types.SymbolKind.Struct,
ls_types.SymbolKind.Interface,
}
def is_position_in_range(line: int, range_d: ls_types.Range) -> bool:
start = range_d["start"]
@@ -2613,7 +2621,10 @@ class SolidLanguageServer(ABC):
for s in document_symbols.iter_symbols()
if s["kind"] in container_symbol_kinds and s["location"]["range"]["start"]["line"] != s["location"]["range"]["end"]["line"]
]
var_containers = [s for s in document_symbols.iter_symbols() if s["kind"] == ls_types.SymbolKind.Variable]
# variables and constants are admitted even as one-liners (e.g. members of a Go const group)
var_containers = [
s for s in document_symbols.iter_symbols() if s["kind"] in {ls_types.SymbolKind.Variable, ls_types.SymbolKind.Constant}
]
candidate_containers.extend(var_containers)
if not candidate_containers:
+11
View File
@@ -196,6 +196,11 @@ class LanguageServerId(str, Enum):
it reports no symbols for them, so they are not treated as source files here).
Automatically downloads the language server JAR; requires Java 17+ (JAVA_HOME or 'java' on PATH).
"""
WOLFRAM = "wolfram"
"""Wolfram Language server using the official WolframResearch LSPServer paclet.
Requires Wolfram Mathematica 13.0+ or Wolfram Engine 12.1+.
Set WOLFRAM_PATH environment variable or configure ls_path in ls_specific_settings.
"""
# Experimental or deprecated Language Servers
TYPESCRIPT_VTS = "typescript_vts"
"""Use the typescript language server through the natively bundled vscode extension via https://github.com/yioneko/vtsls"""
@@ -598,6 +603,8 @@ class LanguageServerId(str, Enum):
# only scripts: the language server does have a service for .config files, but it provides
# no symbols for them, so treating them as source files would only pollute the symbol index
return FilenameMatcher(".nf")
case self.WOLFRAM:
return FilenameMatcher(".wl", ".wls")
case self.HTML:
return FilenameMatcher(".html", ".htm")
case self.SCSS:
@@ -901,6 +908,10 @@ class LanguageServerId(str, Enum):
from solidlsp.language_servers.nextflow_language_server import NextflowLanguageServer
return NextflowLanguageServer
case self.WOLFRAM:
from solidlsp.language_servers.wolfram_language_server import WolframLanguageServer
return WolframLanguageServer
case self.HTML:
from solidlsp.language_servers.vscode_html_language_server import VsCodeHtmlLanguageServer
+25 -28
View File
@@ -2,7 +2,6 @@ import asyncio
import json
import logging
import socket
import subprocess
import threading
import time
from abc import ABC, abstractmethod
@@ -32,6 +31,7 @@ from solidlsp.lsp_protocol_handler.server import (
make_response,
)
from solidlsp.util import subprocess_util
from solidlsp.util.subprocess_util import ManagedSubprocess
log = logging.getLogger(__name__)
@@ -507,22 +507,21 @@ class StdioLanguageServer(LanguageServerInterface):
"""
super().__init__(ls_id, determine_log_level, logger, request_timeout)
self.process_launch_info = process_launch_info
self.process: subprocess.Popen[bytes] | None = None
self.start_independent_lsp_process = start_independent_lsp_process
self._process_launch_info = process_launch_info
self._process: ManagedSubprocess[bytes] | None = None
self._start_independent_lsp_process = start_independent_lsp_process
self._stdin_lock = threading.Lock()
def is_running(self) -> bool:
return self.process is not None and self.process.returncode is None
return self._process is not None and self._process.returncode is None
def _start(self) -> None:
log.info("Starting language server process via command: %s", self.process_launch_info.cmd)
log.info("Starting language server process via command: %s", self._process_launch_info.cmd)
process = subprocess_util.LanguageServerSubprocessLauncher.get_instance().launch(
self.process_launch_info, start_new_session=self.start_independent_lsp_process
process = subprocess_util.ManagedSubprocessLauncher.get_instance().launch(
self._process_launch_info, name=f"LS[{self.ls_id.value}]", start_new_session=self._start_independent_lsp_process
)
self.process = process
self._process = process
# Check if process terminated immediately
if process.returncode is not None:
@@ -545,7 +544,7 @@ class StdioLanguageServer(LanguageServerInterface):
).start()
def _stop(self, timeout: float) -> None:
if self.process is None:
if self._process is None:
log.debug("Server process is None, cannot shutdown.")
return
@@ -553,16 +552,14 @@ class StdioLanguageServer(LanguageServerInterface):
# send LSP shutdown and close stdin to signal no more input
try:
self._send_shutdown_in_thread()
self._safely_close_pipe(self.process.stdin)
self._safely_close_pipe(self._process.stdin)
except Exception as e:
log.debug(f"Exception during graceful shutdown: {e}")
# Ignore errors here, we are proceeding to terminate anyway.
# terminate the process
subprocess_util.terminate_process_tree_with_kill_fallback(
self.process, terminate_timeout=timeout, process_name=f"LS[{self.ls_id.value}]"
)
self._process.terminate(timeout=timeout)
finally:
self.process = None
self._process = None
@staticmethod
def _safely_close_pipe(pipe: IO[AnyStr] | None) -> None:
@@ -573,7 +570,7 @@ class StdioLanguageServer(LanguageServerInterface):
except Exception:
pass
def _read_bytes_from_process(self, process, stream, num_bytes) -> bytes:
def _read_bytes_from_process(self, process: ManagedSubprocess[bytes], stream: IO[bytes], num_bytes: int) -> bytes:
"""Read exactly num_bytes from process stdout"""
data = b""
while len(data) < num_bytes:
@@ -597,10 +594,10 @@ class StdioLanguageServer(LanguageServerInterface):
"""
exception: Exception | None = None
try:
while self.process and self.process.stdout:
if self.process.poll() is not None: # process has terminated
while self._process and self._process.stdout:
if self._process.poll() is not None: # process has terminated
break
line = self.process.stdout.readline()
line = self._process.stdout.readline()
if not line:
continue
try:
@@ -610,10 +607,10 @@ class StdioLanguageServer(LanguageServerInterface):
if num_bytes is None:
continue
while line and line.strip():
line = self.process.stdout.readline()
line = self._process.stdout.readline()
if not line:
continue
body = self._read_bytes_from_process(self.process, self.process.stdout, num_bytes)
body = self._read_bytes_from_process(self._process, self._process.stdout, num_bytes)
self._handle_body(body)
except LanguageServerTerminatedException as e:
@@ -636,11 +633,11 @@ class StdioLanguageServer(LanguageServerInterface):
Continuously read from the language server process stderr and log the messages
"""
try:
while self.process and self.process.stderr:
if self.process.poll() is not None:
while self._process and self._process.stderr:
if self._process.poll() is not None:
# process has terminated
break
line = self.process.stderr.readline()
line = self._process.stderr.readline()
if not line:
continue
line_str = line.decode(ENCODING, errors="replace")
@@ -654,7 +651,7 @@ class StdioLanguageServer(LanguageServerInterface):
log.info("Language server stderr reader thread has terminated")
def _send_payload(self, payload: StringDict) -> None:
if not self.process or not self.process.stdin:
if not self._process or not self._process.stdin:
return
self._trace("solidlsp", "ls", payload)
msg = create_message(payload)
@@ -662,8 +659,8 @@ class StdioLanguageServer(LanguageServerInterface):
# Use lock to prevent concurrent writes to stdin that cause buffer corruption
with self._stdin_lock:
try:
self.process.stdin.writelines(msg)
self.process.stdin.flush()
self._process.stdin.writelines(msg)
self._process.stdin.flush()
except (BrokenPipeError, ConnectionResetError, OSError) as e:
# Log the error but don't raise to prevent cascading failures
log.error(f"Failed to write to stdin: {e}")
+124 -16
View File
@@ -6,10 +6,11 @@ import signal
import subprocess
import threading
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, cast
from typing import IO, TYPE_CHECKING, Any, Generic, TypeVar, cast
import oslex
import psutil
from sensai.util.string import ToStringMixin
if TYPE_CHECKING:
import ctypes
@@ -17,6 +18,7 @@ if TYPE_CHECKING:
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
log = logging.getLogger(__name__)
TStream = TypeVar("TStream", bound=str | bytes)
def subprocess_kwargs() -> dict:
@@ -70,9 +72,73 @@ def convert_shell_cmd(cmd: str | list[str]) -> str:
return oslex.join(cmd) if isinstance(cmd, list) else cmd
class LanguageServerSubprocessLauncher:
class ManagedSubprocess(Generic[TStream], ToStringMixin):
"""
Launcher for language server subprocesses, which are started for stdio-based communication.
Represents a subprocess.Popen instance with additional lifecycle management, including the ability to terminate the process
and its children gracefully, with a fallback to forceful termination if necessary.
"""
def __init__(self, popen: subprocess.Popen[TStream], name: str, start_new_session: bool) -> None:
"""
:param popen: the subprocess.Popen instance representing the launched process
:param start_new_session: whether the process was launched in its own session, i.e. whether it is the
leader of its own process group
"""
self._popen = popen
self._name = name
# a process launched with start_new_session=True is its own group leader, so its PGID is its PID
self._process_group_id = popen.pid if (start_new_session and os.name == "posix") else None
def _tostring_includes(self) -> list[str]:
return ["_name"]
@property
def stdin(self) -> "IO[TStream] | None":
return self._popen.stdin
@property
def stdout(self) -> "IO[TStream] | None":
return self._popen.stdout
@property
def stderr(self) -> "IO[TStream] | None":
return self._popen.stderr
@property
def returncode(self) -> int | None:
"""
:return: the exit code of the process as of the most recent status check (see :meth:`poll`),
or None if the process was not yet known to have terminated
"""
return self._popen.returncode
def poll(self) -> int | None:
"""
Checks whether the process has terminated, updating :attr:`returncode` accordingly.
:return: the exit code of the process or None if it is still running
"""
return self._popen.poll()
def terminate(self, timeout: float) -> None:
"""
Terminates the process and its children, forcefully killing them if they do not exit in time.
:param timeout: the time to wait for the process to terminate gracefully before killing it
:param process_name: the name of the process (used for logging purposes); should start with a capital letter
"""
terminate_process_tree_with_kill_fallback(
self._popen,
terminate_timeout=timeout,
process_name=self._name,
process_group_id=self._process_group_id,
)
class ManagedSubprocessLauncher:
"""
Launcher for managed subprocesses (see :class:`ManagedSubprocess`), which are started for stdio-based communication.
It is home to the concern of launching a subprocess with well-defined lifecycle properties,
ensuring, in particular, that a launched subprocess cannot outlive this process (insofar as
the platform allows) -- even if the subprocess is started in its own session (see
@@ -85,16 +151,16 @@ class LanguageServerSubprocessLauncher:
_PR_SET_PDEATHSIG = 1
"""the PR_SET_PDEATHSIG option value for prctl(2)"""
_instance: "LanguageServerSubprocessLauncher | None" = None
_instance: "ManagedSubprocessLauncher | None" = None
_instance_lock = threading.Lock()
def __init__(self) -> None:
self._libc = self._load_libc()
self._spawner: "LanguageServerSubprocessLauncher._PDeathSigSpawner | None" = None
self._spawner: "ManagedSubprocessLauncher._PDeathSigSpawner | None" = None
self._spawner_lock = threading.Lock()
@classmethod
def get_instance(cls) -> "LanguageServerSubprocessLauncher":
def get_instance(cls) -> "ManagedSubprocessLauncher":
if cls._instance is None:
with cls._instance_lock:
if cls._instance is None:
@@ -115,7 +181,7 @@ class LanguageServerSubprocessLauncher:
return ctypes.CDLL(None)
except OSError as e:
log.warning(
"Could not load libc (%s); language server processes will not be protected against "
"Could not load libc (%s); subprocesses will not be protected against "
"orphaning if this process is killed without a chance to shut down cleanly",
e,
)
@@ -130,11 +196,12 @@ class LanguageServerSubprocessLauncher:
if self._libc is not None:
self._libc.prctl(self._PR_SET_PDEATHSIG, signal.SIGTERM)
def launch(self, process_launch_info: "ProcessLaunchInfo", start_new_session: bool) -> subprocess.Popen[bytes]:
def launch(self, process_launch_info: "ProcessLaunchInfo", name: str, start_new_session: bool) -> ManagedSubprocess[bytes]:
"""
Launches a language server process from ``process_launch_info``.
Launches a subprocess from ``process_launch_info``.
:param process_launch_info: the command, environment and working directory to launch with
:param name: the name of the process (used for logging purposes); should start with a capital letter
:param start_new_session: whether to start the process in its own session (own process
group, detached from ours)
"""
@@ -158,8 +225,8 @@ class LanguageServerSubprocessLauncher:
if use_pdeathsig:
kwargs["preexec_fn"] = self._set_pdeathsig_on_parent_exit
def do_popen() -> subprocess.Popen[bytes]:
return cast(
def do_popen() -> ManagedSubprocess[bytes]:
popen = cast(
"subprocess.Popen[bytes]",
subprocess.Popen(
cmd,
@@ -172,6 +239,7 @@ class LanguageServerSubprocessLauncher:
**kwargs,
),
)
return ManagedSubprocess(popen, name, start_new_session)
# perform the actual Popen call, funneling the fork() through the dedicated spawner thread
# when pdeathsig applies
@@ -192,7 +260,7 @@ class LanguageServerSubprocessLauncher:
"""
def __init__(self) -> None:
self._queue: queue.Queue[tuple[Callable[[], subprocess.Popen], "queue.Queue"]] = queue.Queue()
self._queue: queue.Queue[tuple[Callable[[], ManagedSubprocess[bytes]], "queue.Queue"]] = queue.Queue()
self._thread = threading.Thread(target=self._run, name="solidlsp-pdeathsig-spawner", daemon=True)
self._thread.start()
@@ -204,7 +272,7 @@ class LanguageServerSubprocessLauncher:
except BaseException as e:
result_queue.put((e, None))
def spawn(self, func: Callable[[], subprocess.Popen]) -> subprocess.Popen:
def spawn(self, func: Callable[[], ManagedSubprocess[bytes]]) -> ManagedSubprocess[bytes]:
result_queue: queue.Queue = queue.Queue(maxsize=1)
self._queue.put((func, result_queue))
error, process = result_queue.get()
@@ -246,7 +314,33 @@ def _signal_process_tree(process: subprocess.Popen[bytes], terminate: bool = Tru
signal_process(process)
def terminate_process_tree_with_kill_fallback(process: subprocess.Popen, terminate_timeout: float, process_name: str = "Process") -> None:
def _signal_process_group(pgid: int, terminate: bool = True) -> None:
"""
Sends a signal to every process in the given POSIX process group by group ID, without
enumerating the system process table. Requires the caller to know the group already exists
and is owned by us (see ``terminate_process_tree_with_kill_fallback``'s ``process_group_id``).
:param pgid: the process group ID to signal
:param terminate: if True, signal terminate (SIGTERM), otherwise signal kill (SIGKILL)
"""
sig = signal.SIGTERM if terminate else signal.SIGKILL
try:
os.killpg(pgid, sig)
except ProcessLookupError:
# the group is already gone; nothing left to signal
pass
except PermissionError as e:
log.warning(f"Permission denied signaling process group {pgid} with {sig.name}: {e}")
except Exception as e:
log.warning(f"Unexpected error signaling process group {pgid} with {sig.name}: {e}")
def terminate_process_tree_with_kill_fallback(
process: subprocess.Popen,
terminate_timeout: float,
process_name: str = "Process",
process_group_id: int | None = None,
) -> None:
"""
Attempts to terminate the given process and its children by signaling them to terminate,
and if that fails (i.e. they don't exit within the given timeout), forcefully kills them.
@@ -256,9 +350,23 @@ def terminate_process_tree_with_kill_fallback(process: subprocess.Popen, termina
:param process: the process to terminate
:param terminate_timeout: the time to wait for the process to terminate gracefully before killing it
:param process_name: the name of the process (used for logging purposes); should start with capital letter
:param process_group_id: if given, the POSIX process group ID that ``process`` leads (i.e. it was
launched with ``start_new_session=True``, so its PGID equals its PID). When set, cleanup
signals the whole group directly via ``os.killpg`` instead of walking the process tree with
``psutil``, which requires system-wide process-table enumeration (``sysctl(KERN_PROC_ALL)`` on
macOS) that can be denied even for processes we started and own. Only pass this for a process
that was started in its own session: signaling the group of a process that shares ours would
also signal us.
"""
log.debug(f"Terminating process {process.pid}, current status: {process.poll()}")
_signal_process_tree(process, terminate=True)
def signal_tree(terminate: bool) -> None:
if process_group_id is not None:
_signal_process_group(process_group_id, terminate=terminate)
else:
_signal_process_tree(process, terminate=terminate)
signal_tree(terminate=True)
try:
log.debug(f"Waiting for process {process.pid} to terminate...")
exit_code = process.wait(timeout=terminate_timeout)
@@ -266,7 +374,7 @@ def terminate_process_tree_with_kill_fallback(process: subprocess.Popen, termina
except subprocess.TimeoutExpired:
# If termination failed, forcefully kill the process
log.warning(f"{process_name} (pid={process.pid}) termination timed out, killing process forcefully...")
_signal_process_tree(process, terminate=False)
signal_tree(terminate=False)
try:
exit_code = process.wait(timeout=2.0)
log.info(f"{process_name} killed successfully with exit code {exit_code}.")
+9
View File
@@ -492,6 +492,15 @@ def _determine_disabled_language_servers() -> list[LanguageServerId]:
if is_ci:
result.append(LanguageServerId.KOTLIN) # IntelliJ-based Kotlin LSP crashes on JVM restart under CI memory limits
# Disable Wolfram tests if WolframKernel is not available (checked with the same
# discovery logic used by the language server itself)
from solidlsp.language_servers.wolfram_language_server import _find_wolfram_kernel
try:
_find_wolfram_kernel()
except FileNotFoundError:
result.append(LanguageServerId.WOLFRAM)
return result
@@ -0,0 +1,21 @@
package main
type Severity int
const (
SevLow Severity = iota
SevHigh
)
type Alert struct {
Level Severity
Msg string
}
func (a Alert) IsHigh() bool {
return a.Level == SevHigh
}
type Notifier interface {
Notify(level Severity) error
}
@@ -0,0 +1,3 @@
sayHello[name_String] := StringJoin["Hello, ", name, "!"]
formatResult[value_] := StringJoin["Result: ", ToString[value]]
@@ -0,0 +1,17 @@
Get["lib/helper.wl"]
calculateSum[a_, b_] := a + b
processData[data_List] := Module[{result},
result = Total[data];
formatResult[result]
]
main[] := Module[{result, greeting},
result = calculateSum[5, 3];
greeting = sayHello["World"];
Print[greeting];
Print[result]
]
main[]
+78 -1
View File
@@ -1,11 +1,13 @@
import os
import shutil
import sys
import tempfile
from pathlib import Path
import pytest
from pathspec import PathSpec
from serena.util.file_system import GitignoreParser, GitignoreSpec, match_path
from serena.util.file_system import GitignoreParser, GitignoreSpec, _escape_gitignore_path_component, match_path
class TestGitignoreParser:
@@ -715,6 +717,81 @@ src/*.o
# foo.txt in other/ should NOT be ignored (outside foo/ subtree)
assert not parser.should_ignore("other/foo.txt"), "other/foo.txt should NOT be ignored by foo/.gitignore"
@pytest.mark.skipif(sys.platform == "win32", reason="'*' is illegal in Windows filenames; this directory name cannot exist there")
def test_gitignore_dir_name_with_glob_metachars_is_not_a_wildcard(self):
"""A directory named with glob metacharacters (e.g. a stray '***' venv) must be
matched literally, not interpreted as a pattern (issue #1806).
"""
test_dir = self.repo_path / "test_metachar_dirname"
test_dir.mkdir()
(test_dir / "pkg").mkdir()
(test_dir / "***").mkdir()
(test_dir / "pkg" / "mod.py").touch()
(test_dir / "***" / "junk.txt").touch()
gitignore = test_dir / "***" / ".gitignore"
gitignore.write_text("*\n")
parser = GitignoreParser(str(test_dir))
# pkg/mod.py is outside the "***" directory and must not be affected by its gitignore
assert not parser.should_ignore("pkg/mod.py"), "pkg/mod.py should not be ignored by ***/.gitignore"
# junk.txt inside "***" is still ignored by its own gitignore's "*" pattern
assert parser.should_ignore("***/junk.txt"), "***/junk.txt should be ignored by its own gitignore"
def test_gitignore_dir_name_with_metachars_anchored_pattern(self):
"""Same as above, for the anchored-pattern join site."""
test_dir = self.repo_path / "test_metachar_dirname_anchored"
test_dir.mkdir()
(test_dir / "pkg").mkdir()
(test_dir / "a[1]").mkdir()
(test_dir / "pkg" / "mod.py").touch()
(test_dir / "a[1]" / "mod.py").touch()
gitignore = test_dir / "a[1]" / ".gitignore"
gitignore.write_text("/mod.py\n")
parser = GitignoreParser(str(test_dir))
assert not parser.should_ignore("pkg/mod.py"), "pkg/mod.py should not be ignored by a[1]/.gitignore"
assert parser.should_ignore("a[1]/mod.py"), "a[1]/mod.py should be ignored by its own /mod.py pattern"
@pytest.mark.skipif(sys.platform == "win32", reason="'?' is illegal in Windows filenames; this directory name cannot exist there")
def test_gitignore_dir_name_with_metachars_implicit_double_star_pattern(self):
"""A non-anchored pattern with no leading '**/' is joined as (rel_dir, "**", line):
the third join site. An unescaped '?' in the directory name would leak the pattern
into a sibling directory whose name merely matches the wildcard (issue #1806).
"""
test_dir = self.repo_path / "test_metachar_dirname_implicit_doublestar"
test_dir.mkdir()
(test_dir / "q?").mkdir()
(test_dir / "qA").mkdir()
(test_dir / "qA" / "sub").mkdir()
(test_dir / "qA" / "sub" / "mod.py").touch()
gitignore = test_dir / "q?" / ".gitignore"
gitignore.write_text("mod.py\n")
parser = GitignoreParser(str(test_dir))
# "q?/.gitignore" must not reach into the sibling "qA/" directory just because "?"
# would match the "A" in "qA" if left as a wildcard.
assert not parser.should_ignore("qA/sub/mod.py"), "qA/sub/mod.py should not be ignored by q?/.gitignore"
def test_escape_gitignore_path_component_escapes_all_metachars(self):
"""Pure-function coverage for the '*' and '?' escaping cases the two directory-creation
tests above cannot exercise on Windows (those characters are illegal in Windows
filenames, so this runs on every platform instead of touching the filesystem).
"""
assert _escape_gitignore_path_component("***") == "\\*\\*\\*"
assert _escape_gitignore_path_component("q?") == "q\\?"
assert _escape_gitignore_path_component("a[1]") == "a\\[1\\]"
assert _escape_gitignore_path_component("plain") == "plain"
class TestGitignoreParserPermissionError:
"""Test PermissionError handling in GitignoreParser."""
+59 -1
View File
@@ -4,11 +4,13 @@ from pathlib import Path
import pytest
from serena.symbol import LanguageServerSymbol
from serena.util.text_utils import find_text_coordinates
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import LanguageServerId
from solidlsp.ls_types import SymbolKind
from solidlsp.ls_utils import SymbolUtils
from test.conftest import find_identifier_position, get_repo_path, ls_has_verified_implementation_support
from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols
from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, read_repo_file, request_all_symbols
@pytest.mark.go
@@ -88,6 +90,62 @@ class TestGoLanguageServer:
assert body.startswith(name), f"Expected grouped var {name} body to start with the identifier, got {body[:24]!r}"
assert not body.startswith("var"), f"Grouped var {name} body must not include the 'var' keyword"
@pytest.mark.parametrize("language_server", [LanguageServerId.GO], indirect=True)
def test_request_containing_symbol_const_group_member(self, language_server: SolidLanguageServer) -> None:
"""A reference inside a ``const`` group must be attributed to the enclosing constant."""
file_path = os.path.join("containment_sample.go")
file_content = read_repo_file(language_server, file_path)
coords = find_text_coordinates(file_content, r"SevLow (Severity) = iota")
assert coords is not None, "Could not find the Severity reference in the const group"
containing_symbol = language_server.request_containing_symbol(file_path, coords.line, coords.col)
assert containing_symbol is not None, "Expected a containing symbol for a reference inside a const group"
assert containing_symbol["name"] == "SevLow"
assert containing_symbol["kind"] == SymbolKind.Constant
@pytest.mark.parametrize("language_server", [LanguageServerId.GO], indirect=True)
def test_request_containing_symbol_struct_field(self, language_server: SolidLanguageServer) -> None:
"""A reference in a struct field declaration must be attributed to the enclosing struct."""
file_path = os.path.join("containment_sample.go")
file_content = read_repo_file(language_server, file_path)
coords = find_text_coordinates(file_content, r"Level (Severity)")
assert coords is not None, "Could not find the Severity reference in the struct field"
containing_symbol = language_server.request_containing_symbol(file_path, coords.line, coords.col)
assert containing_symbol is not None, "Expected a containing symbol for a reference inside a struct body"
assert containing_symbol["name"] == "Alert"
assert containing_symbol["kind"] == SymbolKind.Struct
@pytest.mark.parametrize("language_server", [LanguageServerId.GO], indirect=True)
def test_request_containing_symbol_interface_method(self, language_server: SolidLanguageServer) -> None:
"""A reference in an interface method signature must be attributed to the enclosing interface."""
file_path = os.path.join("containment_sample.go")
file_content = read_repo_file(language_server, file_path)
coords = find_text_coordinates(file_content, r"Notify\(level (Severity)\)")
assert coords is not None, "Could not find the Severity reference in the interface method"
containing_symbol = language_server.request_containing_symbol(file_path, coords.line, coords.col)
assert containing_symbol is not None, "Expected a containing symbol for a reference inside an interface body"
assert containing_symbol["name"] == "Notifier"
assert containing_symbol["kind"] == SymbolKind.Interface
@pytest.mark.parametrize("language_server", [LanguageServerId.GO], indirect=True)
def test_request_referencing_symbols_attributes_containers(self, language_server: SolidLanguageServer) -> None:
"""References to a type used in a const group, a struct field and an interface method must
be attributed to the constant, the struct and the interface respectively, not to the file.
"""
file_path = os.path.join("containment_sample.go")
all_symbols, _ = language_server.request_document_symbols(file_path).get_all_symbols_and_roots()
severity_symbol = next((sym for sym in all_symbols if sym.get("name") == "Severity"), None)
assert severity_symbol is not None, "Could not find the 'Severity' type symbol in containment_sample.go"
sel_start = severity_symbol["selectionRange"]["start"]
ref_symbols = [
ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"])
]
assert ref_symbols, "Expected references to the Severity type"
ref_names = {ref["name"] for ref in ref_symbols}
assert "SevLow" in ref_names
assert "Alert" in ref_names
assert "Notifier" in ref_names
assert all(ref["kind"] != SymbolKind.File for ref in ref_symbols), f"File-level fallback attribution in {ref_names}"
if ls_has_verified_implementation_support(LanguageServerId.GO):
@pytest.mark.parametrize("language_server", [LanguageServerId.GO], indirect=True)
@@ -7,6 +7,7 @@ import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import LanguageServerId
from solidlsp.ls_utils import SymbolUtils
from test.conftest import start_default_ls_context
pytestmark = pytest.mark.skipif(shutil.which("java") is None, reason="Nextflow language server requires a Java runtime")
@@ -74,6 +75,25 @@ class TestNextflowLanguageServer:
# main.nf, 0-indexed line 12: greetings = GREET(names)
assert ("main.nf", 12) in ref_lines, f"invocation of GREET in main.nf not found. Found: {ref_lines}"
def test_find_references_across_files_on_a_fresh_server(self) -> None:
"""Cross-file references must not depend on another request having opened the referencing file.
The module-scoped ``language_server`` fixture is shared, so by the time
``test_find_references_across_files`` runs, earlier tests have already opened main.nf and thereby
forced the server to compile it. This test starts its own server and touches nothing but the
defining file, which is what a real session's first request looks like.
"""
module_path = os.path.join("modules", "greet", "main.nf")
with start_default_ls_context(LanguageServerId.NEXTFLOW) as language_server:
symbol = self._get_symbol(language_server, module_path, "GREET")
start = symbol["selectionRange"]["start"]
refs = language_server.request_references(module_path, start["line"], start["character"])
ref_lines = self._reference_lines(refs)
# main.nf, 0-indexed line 12: greetings = GREET(names)
assert ("main.nf", 12) in ref_lines, f"invocation of GREET in main.nf not found. Found: {ref_lines}"
@staticmethod
def _reference_lines(references: list[dict]) -> set[tuple[str, int]]:
"""(relative path, 0-indexed start line) per reference; paths use the platform separator."""
+227
View File
@@ -0,0 +1,227 @@
"""Regression tests for issue #1818: LSP process-group cleanup must not require enumerating
the system process table (``psutil.Process.children(recursive=True)``), which can be denied
even for processes we started and own (``Operation not permitted`` from
``sysctl(KERN_PROC_ALL)`` in a sandboxed macOS environment).
``StdioLanguageServer`` already starts every LSP process in its own session
(``start_independent_lsp_process`` defaults to True, see ``ls_config.py``), which makes the
process its own POSIX process group leader, with a PGID equal to its PID at launch;
``subprocess_util.terminate_process_tree_with_kill_fallback`` accepts that PGID as
``process_group_id`` and, when given, signals the whole group directly via ``os.killpg``
instead of walking the tree with ``psutil``. No language markers: these exercise
``subprocess_util`` directly with plain Python helper processes and run in catch-all.
"""
from __future__ import annotations
import os
import platform
import signal
import subprocess
import sys
import textwrap
import time
import psutil
import pytest
from solidlsp.util.subprocess_util import _signal_process_group, terminate_process_tree_with_kill_fallback
pytestmark = pytest.mark.skipif(platform.system() == "Windows", reason="process groups / os.killpg are POSIX-specific")
def _group_is_gone(pgid: int) -> bool:
try:
os.killpg(pgid, 0)
return False
except ProcessLookupError:
return True
def _wait_until(predicate, timeout: float = 5.0, interval: float = 0.1) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(interval)
return predicate()
def _process_alive(pid: int) -> bool:
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
class _DenyingProcess:
"""Stand-in for ``psutil.Process`` that always raises ``AccessDenied``, used to simulate
a sandboxed environment denying process-table enumeration without needing one. A real class
(not a plain function) so it substitutes cleanly for ``psutil.Process`` in the
``subprocess.Popen | psutil.Process`` type union that subprocess_util.py evaluates eagerly.
"""
def __new__(cls, pid: int) -> "_DenyingProcess":
raise psutil.AccessDenied(pid)
def _spawn_ready(src: str) -> subprocess.Popen:
"""Starts ``src`` in its own session and waits for it to print READY, mirroring
test_pdeathsig.py's driver pattern (deterministic sync instead of a blind sleep).
"""
proc = subprocess.Popen([sys.executable, "-c", src], start_new_session=True, stdout=subprocess.PIPE, text=True)
ready_line = proc.stdout.readline()
assert ready_line.strip() == "READY", f"helper process failed to start: {ready_line!r}"
return proc
class TestSignalProcessGroup:
def test_nonexistent_group_is_treated_as_already_clean(self) -> None:
bogus_pgid = 2**30 # not a real PGID; must be handled like an already-gone group
_signal_process_group(bogus_pgid, terminate=True) # must not raise
def test_permission_error_is_caught_and_logged(self, monkeypatch: pytest.MonkeyPatch) -> None:
def raise_eperm(pgid: int, sig: int) -> None:
raise PermissionError("simulated sandbox denial")
monkeypatch.setattr(os, "killpg", raise_eperm)
_signal_process_group(12345, terminate=True) # must not raise
class TestTerminateProcessTreeWithKillFallback:
def test_terminates_child_and_grandchild_via_group_id(self) -> None:
"""A focused POSIX cleanup test: start a child and grandchild in one new session,
invoke the cleanup utility by PGID only, and verify both are gone, without ever
calling psutil.
"""
src = textwrap.dedent(
"""
import subprocess, sys, time
subprocess.Popen([sys.executable, "-c", "import time; time.sleep(300)"])
print("READY", flush=True)
time.sleep(300)
"""
)
proc = _spawn_ready(src)
pgid = proc.pid
try:
assert not _group_is_gone(pgid), "process group should be alive before cleanup"
terminate_process_tree_with_kill_fallback(proc, terminate_timeout=5.0, process_group_id=pgid)
assert _wait_until(lambda: _group_is_gone(pgid)), f"process group {pgid} survived cleanup"
finally:
if proc.poll() is None:
proc.kill()
proc.wait(timeout=2.0)
def test_falls_back_to_kill_when_group_ignores_sigterm(self) -> None:
src = textwrap.dedent(
"""
import signal, time
signal.signal(signal.SIGTERM, signal.SIG_IGN)
print("READY", flush=True)
time.sleep(300)
"""
)
proc = _spawn_ready(src)
pgid = proc.pid
try:
terminate_process_tree_with_kill_fallback(proc, terminate_timeout=1.0, process_group_id=pgid)
assert _wait_until(lambda: _group_is_gone(pgid)), f"process group {pgid} survived SIGKILL fallback"
finally:
if proc.poll() is None:
proc.kill()
proc.wait(timeout=2.0)
def test_already_exited_group_does_not_raise(self) -> None:
proc = subprocess.Popen([sys.executable, "-c", "pass"], start_new_session=True)
pgid = proc.pid
proc.wait(timeout=5.0)
assert _wait_until(lambda: _group_is_gone(pgid)), "group should be released once the leader is reaped"
terminate_process_tree_with_kill_fallback(proc, terminate_timeout=1.0, process_group_id=pgid)
def test_process_group_id_none_never_calls_killpg(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""start_independent_lsp_process=False means the process shares our own process
group, so cleanup must never call killpg: doing so could signal Serena itself.
"""
def fail_if_called(pgid: int, sig: int) -> None:
raise AssertionError("os.killpg must not be called when process_group_id is None")
monkeypatch.setattr(os, "killpg", fail_if_called)
proc = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(300)"])
try:
terminate_process_tree_with_kill_fallback(proc, terminate_timeout=5.0, process_group_id=None)
proc.wait(timeout=5.0)
finally:
if proc.poll() is None:
proc.kill()
proc.wait(timeout=2.0)
class TestPsutilDenialConsequences:
"""Demonstrates the actual production consequence when process-table enumeration is denied,
without depending on macOS: ``psutil.AccessDenied`` is the same exception class regardless of
which syscall the platform used to deny it. Without a known ``process_group_id``,
``_signal_process_tree`` falls back to signaling only the ``Popen`` object it was given (see
its ``except (psutil.NoSuchProcess, psutil.AccessDenied, Exception): pass`` branch), so a
child the leader spawned itself leaks. Passing the group id (this fix) avoids psutil
entirely and reaps it regardless.
"""
@staticmethod
def _spawn_leader_with_child() -> tuple[subprocess.Popen, int]:
src = textwrap.dedent(
"""
import subprocess, sys, time
child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(300)"])
print(child.pid, flush=True)
print("READY", flush=True)
time.sleep(300)
"""
)
proc = subprocess.Popen([sys.executable, "-c", src], start_new_session=True, stdout=subprocess.PIPE, text=True)
child_pid = int(proc.stdout.readline().strip())
ready_line = proc.stdout.readline()
assert ready_line.strip() == "READY", f"helper process failed to start: {ready_line!r}"
return proc, child_pid
def test_psutil_denial_without_group_id_leaks_the_leaders_child(self, monkeypatch: pytest.MonkeyPatch) -> None:
proc, child_pid = self._spawn_leader_with_child()
monkeypatch.setattr("solidlsp.util.subprocess_util.psutil.Process", _DenyingProcess)
try:
# The call shape every site used before this fix: no process_group_id.
terminate_process_tree_with_kill_fallback(proc, terminate_timeout=2.0, process_name="leader")
assert _wait_until(lambda: proc.poll() is not None), "leader itself should still die (direct signal, not enumerated)"
time.sleep(0.3)
assert _process_alive(child_pid), (
"expected the leader's own child to leak when psutil is denied and no process_group_id is given "
"(this is the #1818 defect: process-table denial silently drops descendants)"
)
finally:
for pid in (child_pid, proc.pid):
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
if proc.poll() is None:
proc.wait(timeout=2.0)
def test_process_group_id_survives_psutil_denial(self, monkeypatch: pytest.MonkeyPatch) -> None:
proc, child_pid = self._spawn_leader_with_child()
pgid = proc.pid
monkeypatch.setattr("solidlsp.util.subprocess_util.psutil.Process", _DenyingProcess)
try:
terminate_process_tree_with_kill_fallback(proc, terminate_timeout=5.0, process_group_id=pgid)
assert _wait_until(lambda: _group_is_gone(pgid)), f"process group {pgid} survived cleanup despite psutil denial"
assert not _process_alive(child_pid), "leader's child leaked even though the group id path avoids psutil entirely"
finally:
for pid in (child_pid, proc.pid):
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
if proc.poll() is None:
proc.wait(timeout=2.0)
View File
Whitespace-only changes.
@@ -0,0 +1,66 @@
import pytest
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import LanguageServerId
from test.conftest import language_server_tests_enabled
pytestmark = [
pytest.mark.wolfram,
pytest.mark.skipif(
not language_server_tests_enabled(LanguageServerId.WOLFRAM), reason="Wolfram tests disabled (WolframKernel not available)"
),
]
# Note: the LSPServer paclet computes textDocument/references per document, i.e. only
# references within the file containing the queried position are returned; cross-file
# references are not supported by the language server.
class TestWolframLanguageServer:
@pytest.mark.parametrize("language_server", [LanguageServerId.WOLFRAM], indirect=True)
def test_wolfram_symbols(self, language_server: SolidLanguageServer):
"""
Test if we can find the top-level symbols in the main.wl file.
"""
all_symbols, _ = language_server.request_document_symbols("main.wl").get_all_symbols_and_roots()
symbol_names = {s["name"] for s in all_symbols}
assert "calculateSum" in symbol_names
assert "main" in symbol_names
@pytest.mark.parametrize("language_server", [LanguageServerId.WOLFRAM], indirect=True)
def test_wolfram_symbols_in_subdirectory(self, language_server: SolidLanguageServer):
"""
Test if we can find the top-level symbols in a file in a subdirectory.
"""
all_symbols, _ = language_server.request_document_symbols("lib/helper.wl").get_all_symbols_and_roots()
symbol_names = {s["name"] for s in all_symbols}
assert "sayHello" in symbol_names
assert "formatResult" in symbol_names
@pytest.mark.parametrize("language_server", [LanguageServerId.WOLFRAM], indirect=True)
def test_wolfram_within_file_references(self, language_server: SolidLanguageServer):
"""
Test finding references to a function within the same file.
"""
# 'calculateSum' is defined on line 2 of main.wl and called on line 10 (0-based)
references = language_server.request_references("main.wl", line=2, column=0)
reference_lines = {(ref["relativePath"], ref["range"]["start"]["line"]) for ref in references}
assert ("main.wl", 2) in reference_lines, f"Expected the definition site among references, got {reference_lines}"
assert ("main.wl", 10) in reference_lines, f"Expected the call site among references, got {reference_lines}"
@pytest.mark.parametrize("language_server", [LanguageServerId.WOLFRAM], indirect=True)
def test_wolfram_no_cross_file_references(self, language_server: SolidLanguageServer):
"""
Test documenting that references are computed per document by the LSPServer paclet.
'sayHello' is defined in lib/helper.wl and called in main.wl, but querying references
from its definition must only return locations within lib/helper.wl. If this test
starts failing, the language server gained cross-file reference support and the
documentation (docs page, CHANGELOG, module comment above) should be updated.
"""
references = language_server.request_references("lib/helper.wl", line=0, column=0)
assert references, "Expected at least one within-file reference in lib/helper.wl fixture"
reference_paths = {ref["relativePath"] for ref in references}
assert reference_paths == {"lib/helper.wl"}, f"Expected references only within lib/helper.wl, got {reference_paths}"