Merge pull request #1433 from oraios/features/diagnostics_and_ls_extensions

Features: diagnostics and LS extensions
This commit is contained in:
Dr. Dominik Jain authored and GitHub committed 2026-04-30 18:26:23 +02:00
commit d9d49fae7d
165 files changed
+5328 -552

No files matched your search

+2 -2
View File
@@ -235,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
+3 -1
View File
@@ -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
news/news.json
+1
View File
@@ -31,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
+12 -1
View File
@@ -8,10 +8,21 @@ Status of the `main` branch. Changes prior to the next official version change w
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`.
* 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:
+3 -2
View File
@@ -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
+176
View File
@@ -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()
+112
View File
@@ -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()
+76
View File
@@ -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()
+8 -4
View File
@@ -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,
)
)
+8 -2
View File
@@ -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"]:
@@ -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()
+27
View File
@@ -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]
@@ -26,3 +26,5 @@ included_optional_tools:
- jet_brains_safe_delete
- jet_brains_debug
- serena_info
- jet_brains_run_inspections
- jet_brains_list_inspections
+266 -1
View File
@@ -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
+52 -39
View File
@@ -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):
+73
View File
@@ -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)
+258 -16
View File
@@ -6,15 +6,19 @@ 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
@@ -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 = "<file>"
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 `<file>`.
: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):
+59 -2
View File
@@ -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.
+203
View File
@@ -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 "<file>" 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 "<file>"
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
@@ -18,12 +18,64 @@ 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).
_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<ver>/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<ver>/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,7 +105,7 @@ 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
@@ -61,29 +113,29 @@ class BashLanguageServer(SolidLanguageServer):
bash_language_server_version = self._custom_settings.get("bash_language_server_version", "5.6.0")
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
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 +144,49 @@ 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_ls_dir = os.path.join(self._ls_resources_dir, "bash-lsp")
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 _create_launch_command(self, core_path: str) -> list[str]:
return [core_path, "start"]
@@ -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.
@@ -152,6 +152,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.
@@ -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
@@ -32,9 +33,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 +67,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", "2.8.0")
elm_compiler_version = elm_config.get("elm_compiler_version", "0.19.1-6")
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 +84,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,6 +92,12 @@ 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",
),
]
)
@@ -98,6 +108,10 @@ class ElmLanguageServer(SolidLanguageServer):
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 +142,7 @@ class ElmLanguageServer(SolidLanguageServer):
"hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]},
"codeAction": {"dynamicRegistration": True},
"rename": {"dynamicRegistration": True},
"publishDiagnostics": {"relatedInformation": True},
},
"workspace": {
"workspaceFolders": True,
@@ -203,3 +218,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)
@@ -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)
+4
View File
@@ -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:
@@ -215,7 +215,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": {},
+47 -2
View File
@@ -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.")
@@ -124,6 +124,7 @@ class Lean4LanguageServer(SolidLanguageServer):
"documentationFormat": ["markdown", "plaintext"],
},
},
"publishDiagnostics": {"relatedInformation": True},
},
"workspace": {
"workspaceFolders": True,
@@ -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,
@@ -777,6 +777,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 +839,7 @@ class PascalLanguageServer(SolidLanguageServer):
},
"formatting": {"dynamicRegistration": True},
"rangeFormatting": {"dynamicRegistration": True},
"publishDiagnostics": {"relatedInformation": True},
},
"workspace": {
"workspaceFolders": True,
@@ -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
@@ -33,6 +37,7 @@ log = logging.getLogger(__name__)
PSES_VERSION = "4.4.0"
PSES_SHA256 = "690b91092989a0f66e6f43986166aaef69d64b559a9fda51feed882e1103fbcc"
PSES_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com")
PSSCRIPTANALYZER_VERSION = "1.25.0"
class PowerShellLanguageServer(SolidLanguageServer):
@@ -157,6 +162,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 +373,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)
@@ -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)
@@ -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."""
@@ -7,13 +7,17 @@ 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
@@ -70,6 +74,7 @@ class SolidityLanguageServer(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."
solidity_language_server_version = self._custom_settings.get("solidity_language_server_version", "0.8.4")
forge_version = self._custom_settings.get("forge_version", "1.5.1")
npm_registry = self._custom_settings.get("npm_registry")
deps = RuntimeDependencyCollection(
@@ -84,17 +89,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")
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 +123,33 @@ 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_ls_dir = os.path.join(self._ls_resources_dir, "solidity-lsp")
managed_bin_dir = os.path.join(solidity_ls_dir, "node_modules", ".bin")
return {"PATH": managed_bin_dir + os.pathsep + os.environ.get("PATH", "")}
def _create_launch_command(self, core_path: str) -> list[str]:
return [core_path, "--stdio"]
@@ -148,6 +192,7 @@ class SolidityLanguageServer(SolidLanguageServer):
"dynamicRegistration": True,
"contentFormat": ["markdown", "plaintext"], # type: ignore[list-item]
},
"publishDiagnostics": {"relatedInformation": True},
},
"workspace": {
"workspaceFolders": True,
@@ -224,3 +269,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('/')}"
@@ -67,6 +67,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 +89,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()
@@ -248,9 +255,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},
},
@@ -298,6 +307,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
@@ -357,6 +370,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)
@@ -402,6 +416,20 @@ class TypeScriptLanguageServer(SolidLanguageServer):
self.INDEXING_PROGRESS_TIMEOUT,
)
@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
@@ -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,
+1
View File
@@ -132,6 +132,7 @@ class ZigLanguageServer(SolidLanguageServer):
"dynamicRegistration": True,
"contentFormat": ["markdown", "plaintext"],
},
"publishDiagnostics": {"relatedInformation": True},
},
"workspace": {
"workspaceFolders": True,
+389 -2
View File
@@ -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
@@ -515,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
@@ -554,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
@@ -571,6 +576,388 @@ class SolidLanguageServer(ABC):
self._has_waited_for_cross_file_references = False
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.
+6
View File
@@ -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:
+17
View File
@@ -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)
+1 -1
View File
@@ -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. """
+8
View File
@@ -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:
"""
+61
View File
@@ -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)
@@ -329,3 +333,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
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
if then
echo "$missingGreeting"
fi
@@ -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))
@@ -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"
}
]
]
@@ -0,0 +1,8 @@
int brokenFactory() {
return missingGreeting;
}
int brokenConsumer() {
int value = brokenFactory();
return value + missingConsumerValue;
}
@@ -0,0 +1,5 @@
def broken_factory : String
missingGreeting
end
broken_consumer = missingConsumerValue
@@ -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);
}
}
}
@@ -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;
}
}
}
}
@@ -0,0 +1,10 @@
namespace TestProject.Services
{
public class ConsoleGreeter : IGreeter
{
public string FormatGreeting(string name)
{
return $"Hello, {name}!";
}
}
}
@@ -0,0 +1,7 @@
namespace TestProject.Services
{
public interface IGreeter
{
string FormatGreeting(string name);
}
}
@@ -0,0 +1,3 @@
String brokenFactory() {
return 'broken'
}
@@ -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
@@ -0,0 +1,5 @@
module DiagnosticsSample exposing (brokenFactory)
brokenFactory : String
brokenFactory =
"unterminated
@@ -0,0 +1,5 @@
-module(diagnostics_sample).
-export([broken_factory/0, broken_consumer/0]).
broken_factory() -> MissingGreeting.
broken_consumer() -> MissingConsumerValue.
@@ -0,0 +1,5 @@
program diagnostics_sample
implicit none
integer :: duplicated_value
real :: duplicated_value
end program diagnostics_sample
@@ -0,0 +1,11 @@
module DiagnosticsSample
let brokenConsumerValue = 1
let brokenFactory () =
missingGreeting
let brokenConsumer () =
let value = brokenFactory ()
printfn "%A" value
missingConsumerValue
@@ -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
@@ -1,7 +1,9 @@
module Program
open Calculator
open Formatter
open Models
open Shapes
[<EntryPoint>]
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
0 // return success
@@ -0,0 +1,10 @@
module Shapes
[<AbstractClass>]
type Shape() =
abstract member Area: unit -> float
type Circle(radius: float) =
inherit Shape()
override _.Area() = System.Math.PI * radius * radius
@@ -7,8 +7,11 @@
<ItemGroup>
<Compile Include="Calculator.fs" />
<Compile Include="DiagnosticsSample.fs" />
<Compile Include="Formatter.fs" />
<Compile Include="Shapes.fs" />
<Compile Include="Models/Person.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
</Project>
</Project>
@@ -0,0 +1,11 @@
package main
func brokenFactory() string {
return missingGreeting
}
func brokenConsumer() {
value := brokenFactory()
_ = value
_ = missingConsumerValue
}
+12
View File
@@ -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 + "!"
}
@@ -0,0 +1,13 @@
package com.example
class DiagnosticsSample {
static String brokenFactory() {
missingGreeting
}
static void brokenConsumer() {
def value = brokenFactory()
println(value)
println(missingConsumerValue)
}
}
@@ -0,0 +1,7 @@
module DiagnosticsSample where
brokenFactory :: String
brokenFactory = missingGreeting
brokenConsumer :: String
brokenConsumer = missingConsumerValue
@@ -0,0 +1,10 @@
class DiagnosticsSample {
static function brokenFactory():String {
return missingGreeting;
}
static function brokenConsumer():Void {
trace(brokenFactory());
trace(missingConsumerValue);
}
}
@@ -0,0 +1,3 @@
float4 main() : SV_Target {
return missingGreeting;
}
@@ -0,0 +1,8 @@
package test_repo;
public class ConsoleGreeter implements Greeter {
@Override
public String formatGreeting(String name) {
return "Hello, " + name + "!";
}
}
@@ -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);
}
}
@@ -0,0 +1,5 @@
package test_repo;
public interface Greeter {
String formatGreeting(String name);
}
@@ -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
@@ -0,0 +1,4 @@
{
"name": "diagnostics",
"value":
}
@@ -0,0 +1,3 @@
function broken_factory(
value = 1
end
@@ -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
+251
View File
@@ -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" "$@"
+94
View File
@@ -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
@@ -0,0 +1,11 @@
package test_repo
fun brokenFactory(): String {
return missingGreeting
}
fun brokenConsumer() {
val value = brokenFactory()
println(value)
println(missingConsumerValue)
}
@@ -0,0 +1 @@
def brokenFactory : Nat :=
+10 -1
View File
@@ -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 {})
main(arg or {})
@@ -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,
}
@@ -0,0 +1,3 @@
function value = diagnostics_sample()
value = missingGreeting;
end
@@ -0,0 +1,4 @@
{
greeting = missingGreeting;
consumer = missingConsumerValue;
}
@@ -0,0 +1 @@
let broken_factory (
@@ -1,3 +1,4 @@
(library
(public_name test_repo)
(name test_repo))
(name test_repo)
(modules test_repo))
@@ -0,0 +1,4 @@
program DiagnosticsSample;
begin
writeln(
end.
@@ -0,0 +1,5 @@
use strict;
use warnings;
print $missingGreeting;
print $missingConsumerValue;
@@ -0,0 +1,11 @@
<?php
function brokenFactory(): string {
return $missingGreeting;
}
function brokenConsumer(): void {
$value = brokenFactory();
echo $value;
echo $missingConsumerValue;
}
@@ -0,0 +1,4 @@
function Invoke-BrokenFactory {
if ($true) {
Write-Output "broken"
}
@@ -0,0 +1,11 @@
from .models import User
def broken_factory() -> User:
return missing_user
def broken_consumer() -> None:
created_user = broken_factory()
print(created_user)
print(undefined_name)
@@ -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
@@ -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
@@ -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);
}
@@ -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}!")
}
}
@@ -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");
}
@@ -0,0 +1,9 @@
package com.example
object DiagnosticsSample {
def brokenFactory(): String = missingGreeting
def brokenConsumer(): Unit = {
println(brokenFactory())
println(missingConsumerValue)
}
}
@@ -0,0 +1,7 @@
pragma solidity ^0.8.0;
contract DiagnosticsSample {
function brokenFactory() pure returns (string memory) {
return "broken";
}
}
@@ -11,6 +11,7 @@ let package = Package(
targets: [
.target(
name: "test_repo",
dependencies: []),
dependencies: [],
path: "src"),
]
)
)
@@ -0,0 +1,3 @@
func brokenFactory() -> String {
return
}
@@ -0,0 +1,5 @@
module diagnostics_sample;
initial begin
if (
end
endmodule
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff. Show more