java-functional-lsp 0.10.0__tar.gz → 0.11.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/CONTRIBUTING.md +6 -4
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/PKG-INFO +1 -1
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/editors/vscode/package-lock.json +3 -3
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/pyproject.toml +1 -1
- java_functional_lsp-0.11.0/src/java_functional_lsp/analyzers/__init__.py +29 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/src/java_functional_lsp/analyzers/base.py +8 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/src/java_functional_lsp/analyzers/exception_checker.py +64 -2
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/src/java_functional_lsp/analyzers/functional_checker.py +213 -48
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/src/java_functional_lsp/analyzers/mutation_checker.py +63 -4
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/src/java_functional_lsp/analyzers/null_checker.py +36 -1
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/src/java_functional_lsp/analyzers/spring_checker.py +69 -3
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/src/java_functional_lsp/fixes.py +224 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/src/java_functional_lsp/server.py +21 -2
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/tests/test_e2e.py +33 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/tests/test_exception_checker.py +30 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/tests/test_fixes.py +256 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/tests/test_functional_checker.py +157 -1
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/tests/test_mutation_checker.py +58 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/tests/test_null_checker.py +21 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/tests/test_server.py +48 -0
- java_functional_lsp-0.11.0/tests/test_spring_checker.py +111 -0
- java_functional_lsp-0.10.0/src/java_functional_lsp/analyzers/__init__.py +0 -1
- java_functional_lsp-0.10.0/tests/test_spring_checker.py +0 -63
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/.claude-plugin/plugin.json +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/.githooks/pre-commit +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/.githooks/pre-push +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/.github/CODEOWNERS +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/.github/ISSUE_TEMPLATE/feature-request.md +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/.github/SECURITY.md +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/.github/dependabot.yml +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/.github/release-drafter.yml +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/.github/workflows/publish.yml +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/.github/workflows/release-drafter.yml +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/.github/workflows/stale.yml +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/.github/workflows/test.yml +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/.github/workflows/update-homebrew.yml +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/.github/workflows/vscode-ext.yml +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/.gitignore +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/LICENSE +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/README.md +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/SKILL.md +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/commands/lint-java.md +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/editors/intellij/README.md +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/editors/intellij/lsp4ij-template.json +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/editors/vscode/.vscodeignore +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/editors/vscode/README.md +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/editors/vscode/package.json +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/editors/vscode/src/extension.ts +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/editors/vscode/tsconfig.json +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/hooks/hooks.json +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/hooks/java_linter_reminder.py +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/scripts/ensure-lsp.sh +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/scripts/generate-formula.py +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/src/java_functional_lsp/__init__.py +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/src/java_functional_lsp/__main__.py +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/src/java_functional_lsp/capabilities/__init__.py +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/src/java_functional_lsp/capabilities/handler_wiring.py +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/src/java_functional_lsp/capabilities/negotiator.py +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/src/java_functional_lsp/capabilities/probe.py +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/src/java_functional_lsp/capabilities/registry.py +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/src/java_functional_lsp/capabilities/static_builder.py +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/src/java_functional_lsp/cli.py +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/src/java_functional_lsp/merkle.py +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/src/java_functional_lsp/proxy.py +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/tests/__init__.py +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/tests/conftest.py +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/tests/test_base.py +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/tests/test_capabilities/__init__.py +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/tests/test_capabilities/test_handler_wiring.py +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/tests/test_capabilities/test_negotiator.py +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/tests/test_capabilities/test_probe.py +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/tests/test_capabilities/test_registry.py +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/tests/test_capabilities/test_static_builder.py +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/tests/test_cli.py +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/tests/test_config.py +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/tests/test_e2e_jdtls.py +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/tests/test_merkle.py +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/tests/test_merkle_proxy.py +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/tests/test_proxy.py +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/tests/test_suppress.py +0 -0
- {java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/uv.lock +0 -0
|
@@ -45,10 +45,12 @@ uv run pytest
|
|
|
45
45
|
1. Choose the appropriate analyzer in `src/java_functional_lsp/analyzers/`
|
|
46
46
|
2. Add the detection logic using tree-sitter node walking (see `base.py` helpers)
|
|
47
47
|
3. Add the rule ID and message to the module's `_MESSAGES` dict
|
|
48
|
-
4. Add a `DiagnosticData` entry to the module's `_DATA` dict with `fix_type`, `target_library`, and `rationale
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
4. Add a `DiagnosticData` entry to the module's `_DATA` dict with `fix_type`, `target_library`, and `rationale`. Where helpful for AI agents, also set:
|
|
49
|
+
- `recommended_api` — a library-agnostic API hint paired with `target_library` (e.g. `"forEach (NOT ifPresent — Vavr Option uses forEach)"`, `"@Value"`, `"Try.of(() -> ...).getOrElse(...)"`). Prevents agents from picking the wrong API surface.
|
|
50
|
+
- `suggested_snippet` — a concrete, paste-able snippet built per-instance from AST node text (real variable names, real return expressions). Pattern: define a `_build_<rule>_data(node)` helper that reads the AST and produces the snippet, then pass `data=_build_<rule>_data(node)` instead of `data=_DATA["rule-id"]`. Leave the field `None` rather than fabricating a misleading placeholder when the AST shape isn't trivially templatable.
|
|
51
|
+
5. Pass `data=_DATA["rule-id"]` (or the builder result) when creating the `Diagnostic`
|
|
52
|
+
6. Add tests in `tests/test_<analyzer>.py` (including a test verifying the `data` field — both `fix_type` and, when set, `recommended_api` / `suggested_snippet` with real AST-derived text)
|
|
53
|
+
7. Optionally add a quick fix generator in `src/java_functional_lsp/fixes.py` and register it in `_FIX_REGISTRY` + add its title to `_FIX_TITLES` in `server.py` (an import-time assertion catches mismatches). Skip the registry if the rewrite needs class-wide analysis you can't safely automate; the diagnostic + `suggested_snippet` are usually enough for an AI agent to apply the change.
|
|
52
54
|
8. Update the rules table in `README.md`
|
|
53
55
|
|
|
54
56
|
## Test Architecture
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: java-functional-lsp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
4
4
|
Summary: Java LSP server enforcing functional programming best practices — null safety, immutability, no exceptions
|
|
5
5
|
Project-URL: Homepage, https://github.com/aviadshiber/java-functional-lsp
|
|
6
6
|
Project-URL: Repository, https://github.com/aviadshiber/java-functional-lsp
|
|
@@ -2094,9 +2094,9 @@
|
|
|
2094
2094
|
}
|
|
2095
2095
|
},
|
|
2096
2096
|
"node_modules/fast-uri": {
|
|
2097
|
-
"version": "3.1.
|
|
2098
|
-
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.
|
|
2099
|
-
"integrity": "sha512-
|
|
2097
|
+
"version": "3.1.2",
|
|
2098
|
+
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
|
2099
|
+
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
|
|
2100
2100
|
"dev": true,
|
|
2101
2101
|
"funding": [
|
|
2102
2102
|
{
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "java-functional-lsp"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.11.0"
|
|
8
8
|
description = "Java LSP server enforcing functional programming best practices — null safety, immutability, no exceptions"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Java code quality analyzers using tree-sitter.
|
|
2
|
+
|
|
3
|
+
This module exposes ``KNOWN_RULES`` — the union of every rule code emitted by any analyzer
|
|
4
|
+
in this package. The server uses it as a guardrail: ``_FIX_TITLES`` keys must be a subset of
|
|
5
|
+
``KNOWN_RULES`` so a typo in a fix registration is caught at import time rather than as a
|
|
6
|
+
silently-missing code action at runtime.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .exception_checker import _MESSAGES as _EXCEPTION_MESSAGES
|
|
12
|
+
from .functional_checker import _MESSAGES as _FUNCTIONAL_MESSAGES
|
|
13
|
+
from .mutation_checker import _MESSAGES as _MUTATION_MESSAGES
|
|
14
|
+
from .null_checker import _MESSAGES as _NULL_MESSAGES
|
|
15
|
+
from .spring_checker import _MESSAGES as _SPRING_MESSAGES
|
|
16
|
+
|
|
17
|
+
# ``impure-method-io`` / ``impure-method-throw`` are internal data-payload keys; the rule
|
|
18
|
+
# code emitted on the wire is ``impure-method`` regardless of variant, so we register only
|
|
19
|
+
# the wire-visible name here.
|
|
20
|
+
_IMPURE_METHOD_INTERNAL_KEYS = {"impure-method-io", "impure-method-throw"}
|
|
21
|
+
|
|
22
|
+
_ALL_MESSAGE_KEYS: set[str] = (
|
|
23
|
+
set(_FUNCTIONAL_MESSAGES.keys())
|
|
24
|
+
| set(_MUTATION_MESSAGES.keys())
|
|
25
|
+
| set(_EXCEPTION_MESSAGES.keys())
|
|
26
|
+
| set(_SPRING_MESSAGES.keys())
|
|
27
|
+
| set(_NULL_MESSAGES.keys())
|
|
28
|
+
)
|
|
29
|
+
KNOWN_RULES: frozenset[str] = frozenset((_ALL_MESSAGE_KEYS - _IMPURE_METHOD_INTERNAL_KEYS) | {"impure-method"})
|
{java_functional_lsp-0.10.0 → java_functional_lsp-0.11.0}/src/java_functional_lsp/analyzers/base.py
RENAMED
|
@@ -26,6 +26,14 @@ class DiagnosticData:
|
|
|
26
26
|
fix_type: str # e.g. "REPLACE_WITH_VAVR_LIST", "WRAP_IN_OPTION"
|
|
27
27
|
target_library: str # e.g. "io.vavr.collection.List"
|
|
28
28
|
rationale: str # human+machine readable explanation
|
|
29
|
+
# Library-agnostic API hint that pairs with target_library. Examples:
|
|
30
|
+
# "forEach (NOT ifPresent — Vavr Option)", "@Value", "Try.of(...).getOrElse(...)".
|
|
31
|
+
# Lets agents pick the right method without re-reading docs.
|
|
32
|
+
recommended_api: str | None = None
|
|
33
|
+
# Concrete fix snippet built from the offending AST node — uses real variable
|
|
34
|
+
# names so an agent can paste it directly. None when the shape is too complex
|
|
35
|
+
# to synthesise safely.
|
|
36
|
+
suggested_snippet: str | None = None
|
|
29
37
|
|
|
30
38
|
|
|
31
39
|
@dataclass(frozen=True)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import dataclasses
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
7
8
|
from .base import (
|
|
@@ -30,6 +31,7 @@ _DATA = {
|
|
|
30
31
|
"Throwing exceptions breaks referential transparency."
|
|
31
32
|
" Use Either.left(error) to represent failures as values."
|
|
32
33
|
),
|
|
34
|
+
recommended_api="Either.left(...) / Try.failure(...)",
|
|
33
35
|
),
|
|
34
36
|
"catch-rethrow": DiagnosticData(
|
|
35
37
|
fix_type="USE_TRY_TO_EITHER",
|
|
@@ -37,6 +39,7 @@ _DATA = {
|
|
|
37
39
|
rationale=(
|
|
38
40
|
"Catching and rethrowing adds noise. Use Try.of(() -> ...).toEither() to convert exceptions to values."
|
|
39
41
|
),
|
|
42
|
+
recommended_api="Try.of(() -> ...).toEither()",
|
|
40
43
|
),
|
|
41
44
|
"try-catch-to-monadic": DiagnosticData(
|
|
42
45
|
fix_type="WRAP_IN_TRY",
|
|
@@ -45,10 +48,69 @@ _DATA = {
|
|
|
45
48
|
"try/catch mixes control flow with value handling."
|
|
46
49
|
" Use Try.of(...) for composable, value-based failure handling."
|
|
47
50
|
),
|
|
51
|
+
recommended_api="Try.of(() -> ...).getOrElse(...) / .recover(Type.class, e -> ...).get()",
|
|
48
52
|
),
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
|
|
56
|
+
def _build_throw_statement_data(throw_node: Any) -> DiagnosticData:
|
|
57
|
+
"""Build a DiagnosticData with a concrete ``Either.left(...)`` snippet.
|
|
58
|
+
|
|
59
|
+
Reads the throw expression from the first non-comment named child. tree-sitter-java
|
|
60
|
+
doesn't expose a field name for `throw_statement`'s expression, so we filter
|
|
61
|
+
`named_children` to skip comments and take the first remaining node.
|
|
62
|
+
|
|
63
|
+
We commit to ``Either.left`` here (rather than offering both ``Either.left`` and
|
|
64
|
+
``Try.failure`` in a comment) so the snippet is paste-able without manual cleanup;
|
|
65
|
+
the `recommended_api` mentions both alternatives.
|
|
66
|
+
"""
|
|
67
|
+
base = _DATA["throw-statement"]
|
|
68
|
+
expr_text = "error"
|
|
69
|
+
for child in throw_node.named_children:
|
|
70
|
+
if child.type in IGNORED_CHILDREN:
|
|
71
|
+
continue
|
|
72
|
+
if child.text:
|
|
73
|
+
expr_text = child.text.decode("utf-8")
|
|
74
|
+
break
|
|
75
|
+
snippet = f"return Either.left({expr_text});"
|
|
76
|
+
return dataclasses.replace(base, suggested_snippet=snippet)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _extract_return_expr(block_node: Any) -> str | None:
|
|
80
|
+
"""Return the text of the (last) return-statement expression in a block, or None."""
|
|
81
|
+
if block_node is None:
|
|
82
|
+
return None
|
|
83
|
+
stmts = [c for c in block_node.named_children if c.type not in IGNORED_CHILDREN]
|
|
84
|
+
if not stmts or stmts[-1].type != "return_statement":
|
|
85
|
+
return None
|
|
86
|
+
ret_children = [c for c in stmts[-1].named_children if c.type not in IGNORED_CHILDREN]
|
|
87
|
+
if not ret_children or not ret_children[0].text:
|
|
88
|
+
return None
|
|
89
|
+
decoded: str = ret_children[0].text.decode("utf-8")
|
|
90
|
+
return decoded
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _build_try_catch_to_monadic_data(try_node: Any) -> DiagnosticData:
|
|
94
|
+
"""Build a DiagnosticData with a Try.of(...).getOrElse(...) snippet drawn from the AST.
|
|
95
|
+
|
|
96
|
+
Best-effort: when the try/catch shape isn't a clean single-return-each pair, the
|
|
97
|
+
base data is returned without a snippet rather than synthesising garbage.
|
|
98
|
+
"""
|
|
99
|
+
base = _DATA["try-catch-to-monadic"]
|
|
100
|
+
body = try_node.child_by_field_name("body")
|
|
101
|
+
catches = [c for c in try_node.children if c.type == "catch_clause"]
|
|
102
|
+
if body is None or not catches:
|
|
103
|
+
return base
|
|
104
|
+
|
|
105
|
+
try_expr = _extract_return_expr(body)
|
|
106
|
+
catch_expr = _extract_return_expr(catches[0].child_by_field_name("body"))
|
|
107
|
+
if try_expr is None or catch_expr is None:
|
|
108
|
+
return base
|
|
109
|
+
|
|
110
|
+
snippet = f"return Try.of(() -> {try_expr}).getOrElse({catch_expr});"
|
|
111
|
+
return dataclasses.replace(base, suggested_snippet=snippet)
|
|
112
|
+
|
|
113
|
+
|
|
52
114
|
def _is_in_bean_method(node: Any) -> bool:
|
|
53
115
|
"""Check if node is inside a method annotated with @Bean."""
|
|
54
116
|
parent = node.parent
|
|
@@ -164,7 +226,7 @@ class ExceptionChecker:
|
|
|
164
226
|
severity=severity,
|
|
165
227
|
code="throw-statement",
|
|
166
228
|
message=_MESSAGES["throw-statement"],
|
|
167
|
-
data=
|
|
229
|
+
data=_build_throw_statement_data(node),
|
|
168
230
|
)
|
|
169
231
|
)
|
|
170
232
|
|
|
@@ -211,7 +273,7 @@ class ExceptionChecker:
|
|
|
211
273
|
severity=severity,
|
|
212
274
|
code="try-catch-to-monadic",
|
|
213
275
|
message=_MESSAGES["try-catch-to-monadic"],
|
|
214
|
-
data=
|
|
276
|
+
data=_build_try_catch_to_monadic_data(try_node),
|
|
215
277
|
)
|
|
216
278
|
)
|
|
217
279
|
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
import dataclasses
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any, Literal
|
|
6
8
|
|
|
7
9
|
from tree_sitter import Node, Tree
|
|
8
10
|
|
|
@@ -22,10 +24,14 @@ _MESSAGES = {
|
|
|
22
24
|
"Use io.vavr.collection.List for safe persistent immutability."
|
|
23
25
|
),
|
|
24
26
|
"null-check-to-monadic": ("Imperative null handling: Consider monadic flow with Option.of().map().getOrElse()."),
|
|
25
|
-
"impure-method": (
|
|
27
|
+
"impure-method-io": (
|
|
26
28
|
"Hidden side-effect: Method mixes pure logic with IO/state mutations. "
|
|
27
29
|
"Extract pure logic to a separate method; wrap side-effects in Try."
|
|
28
30
|
),
|
|
31
|
+
"impure-method-throw": (
|
|
32
|
+
"Hidden side-effect: Method mixes pure logic with exceptions. "
|
|
33
|
+
"Extract pure logic; return Either.left(...) or Try.failure(...) instead of throwing."
|
|
34
|
+
),
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
_DATA = {
|
|
@@ -36,6 +42,7 @@ _DATA = {
|
|
|
36
42
|
"Runtime mutation of List.of() causes UnsupportedOperationException. "
|
|
37
43
|
"Use Vavr for safe, persistent immutability."
|
|
38
44
|
),
|
|
45
|
+
recommended_api=".append / .appendAll / .update / .remove (returns a new persistent collection)",
|
|
39
46
|
),
|
|
40
47
|
"null-check-to-monadic": DiagnosticData(
|
|
41
48
|
fix_type="WRAP_IN_OPTION_MAP",
|
|
@@ -44,17 +51,147 @@ _DATA = {
|
|
|
44
51
|
"Imperative null checks create nested branching. "
|
|
45
52
|
"Use Option.of().map() for composable, null-safe monadic flow."
|
|
46
53
|
),
|
|
54
|
+
recommended_api="Option.of(...).map(...).getOrElse(...)",
|
|
47
55
|
),
|
|
48
|
-
"impure-method"
|
|
49
|
-
|
|
56
|
+
# impure-method has two variants (IO and throw) that share the rule code "impure-method"
|
|
57
|
+
# but carry distinct fix_type values so AI agents can filter on the variant without
|
|
58
|
+
# parsing target_library or message text. Splitting the rule code itself would break
|
|
59
|
+
# existing user severity-overrides like `rules: { "impure-method": "off" }`.
|
|
60
|
+
"impure-method-io": DiagnosticData(
|
|
61
|
+
fix_type="EXTRACT_PURE_LOGIC_IO",
|
|
50
62
|
target_library="io.vavr.control.Try",
|
|
51
63
|
rationale=(
|
|
52
|
-
"Mixing pure logic with
|
|
64
|
+
"Mixing pure logic with IO/state mutations breaks referential transparency. "
|
|
53
65
|
"Extract pure logic; wrap IO/state mutations in Try."
|
|
54
66
|
),
|
|
67
|
+
recommended_api="Try.of(() -> sideEffect()).onFailure(...)",
|
|
68
|
+
),
|
|
69
|
+
"impure-method-throw": DiagnosticData(
|
|
70
|
+
fix_type="EXTRACT_PURE_LOGIC_THROW",
|
|
71
|
+
target_library="io.vavr.control.Either",
|
|
72
|
+
rationale=(
|
|
73
|
+
"Mixing pure logic with exceptions breaks referential transparency. "
|
|
74
|
+
"Extract pure logic; return Either.left(...) or Try.failure(...) instead of throwing."
|
|
75
|
+
),
|
|
76
|
+
recommended_api="Either.left(...) / Try.of(() -> ...)",
|
|
55
77
|
),
|
|
56
78
|
}
|
|
57
79
|
|
|
80
|
+
|
|
81
|
+
def _build_null_check_to_monadic_data(
|
|
82
|
+
var_name: bytes, consequence: Node | None, alternative: Node | None, if_node: Node | None = None
|
|
83
|
+
) -> DiagnosticData:
|
|
84
|
+
"""Build an Option snippet using the real var name + real return expressions.
|
|
85
|
+
|
|
86
|
+
Inspects the if-then return expression (the body of ``if (x != null) return X.something();``)
|
|
87
|
+
and rewrites references to ``var_name`` as ``it`` in a ``.map(it -> ...)`` lambda. The else
|
|
88
|
+
branch's return expression — either nested in the if as an alternative or following the if
|
|
89
|
+
as a fallthrough — becomes the ``.getOrElse(...)`` argument. Falls back to the base data
|
|
90
|
+
(no snippet) when the then-branch isn't a single return.
|
|
91
|
+
"""
|
|
92
|
+
base = _DATA["null-check-to-monadic"]
|
|
93
|
+
if not var_name:
|
|
94
|
+
return base
|
|
95
|
+
var = var_name.decode("utf-8")
|
|
96
|
+
|
|
97
|
+
then_expr = _single_return_expr_text(consequence)
|
|
98
|
+
if then_expr is None:
|
|
99
|
+
return base
|
|
100
|
+
# Rewrite references to the checked variable as `it` in the lambda body. Use a word-boundary
|
|
101
|
+
# regex so a short var name like `s` doesn't match `s.toString()` inside another identifier.
|
|
102
|
+
# Skip the map when the body is exactly the variable itself (identity case).
|
|
103
|
+
if then_expr == var:
|
|
104
|
+
chain_body = f"Option.of({var})"
|
|
105
|
+
else:
|
|
106
|
+
lambda_body = re.sub(rf"\b{re.escape(var)}\b", "it", then_expr)
|
|
107
|
+
chain_body = f"Option.of({var}).map(it -> {lambda_body})"
|
|
108
|
+
|
|
109
|
+
# Prefer the nested else-branch; otherwise look at the statement immediately following the if
|
|
110
|
+
# (a common fallthrough pattern: `if (x != null) return ...; return fallback;`).
|
|
111
|
+
else_expr = _single_return_expr_text(alternative)
|
|
112
|
+
if else_expr is None and if_node is not None:
|
|
113
|
+
else_expr = _next_statement_return_expr(if_node)
|
|
114
|
+
|
|
115
|
+
if else_expr is None or else_expr == "null":
|
|
116
|
+
# No else (or else returns null) — return Option<T> directly; don't fabricate a default.
|
|
117
|
+
snippet = f"return {chain_body};"
|
|
118
|
+
else:
|
|
119
|
+
snippet = f"return {chain_body}.getOrElse({else_expr});"
|
|
120
|
+
|
|
121
|
+
return dataclasses.replace(base, suggested_snippet=snippet)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _next_statement_return_expr(if_node: Node) -> str | None:
|
|
125
|
+
"""If the statement immediately after ``if_node`` (in its enclosing block) is a single
|
|
126
|
+
`return <expr>;`, return that expression's text. Otherwise None."""
|
|
127
|
+
parent = if_node.parent
|
|
128
|
+
if parent is None or parent.type != "block":
|
|
129
|
+
return None
|
|
130
|
+
siblings = [c for c in parent.named_children if c.type not in ("line_comment", "block_comment")]
|
|
131
|
+
try:
|
|
132
|
+
idx = siblings.index(if_node)
|
|
133
|
+
except ValueError:
|
|
134
|
+
return None
|
|
135
|
+
if idx + 1 >= len(siblings):
|
|
136
|
+
return None
|
|
137
|
+
next_stmt = siblings[idx + 1]
|
|
138
|
+
return _single_return_expr_text(next_stmt)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _single_return_expr_text(block_or_stmt: Node | None) -> str | None:
|
|
142
|
+
"""Return the text of a single ``return <expr>;`` statement in a block (or the statement
|
|
143
|
+
itself). Returns None for anything else (multiple statements, no return, bare return)."""
|
|
144
|
+
if block_or_stmt is None:
|
|
145
|
+
return None
|
|
146
|
+
if block_or_stmt.type == "block":
|
|
147
|
+
stmts = [c for c in block_or_stmt.named_children if c.type not in ("line_comment", "block_comment")]
|
|
148
|
+
else:
|
|
149
|
+
stmts = [block_or_stmt]
|
|
150
|
+
if len(stmts) != 1 or stmts[0].type != "return_statement":
|
|
151
|
+
return None
|
|
152
|
+
ret_children = [c for c in stmts[0].named_children if c.type not in ("line_comment", "block_comment")]
|
|
153
|
+
if not ret_children or not ret_children[0].text:
|
|
154
|
+
return None
|
|
155
|
+
decoded: str = ret_children[0].text.decode("utf-8")
|
|
156
|
+
return decoded
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# Module-scope so it's allocated once at import rather than per diagnostic.
|
|
160
|
+
_VAVR_MUTATION_METHODS: dict[bytes, str] = {
|
|
161
|
+
b"add": "append",
|
|
162
|
+
b"addAll": "appendAll",
|
|
163
|
+
b"remove": "remove",
|
|
164
|
+
b"set": "update",
|
|
165
|
+
b"sort": "sorted",
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
# Identifier syntax: bare identifiers only (not `this.x`, `foo.bar()`, etc.) — used to gate
|
|
169
|
+
# whether the assignment-style snippet `x = x.append(...)` is safe to suggest.
|
|
170
|
+
_PLAIN_IDENTIFIER_RE = re.compile(r"^[A-Za-z_$][A-Za-z0-9_$]*$")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _build_frozen_mutation_data(method_name: bytes, var_name: bytes) -> DiagnosticData:
|
|
174
|
+
"""Build a Vavr-persistent snippet using the real var name and migration hint.
|
|
175
|
+
|
|
176
|
+
Returns the base data (no snippet) when ``var_name`` is anything other than a plain
|
|
177
|
+
identifier — chained LHS like ``foo.getList()`` or ``this.items`` would produce invalid
|
|
178
|
+
Java if used as the left-hand side of an assignment.
|
|
179
|
+
"""
|
|
180
|
+
base = _DATA["frozen-mutation"]
|
|
181
|
+
decoded_var = var_name.decode("utf-8") if var_name else ""
|
|
182
|
+
if not _PLAIN_IDENTIFIER_RE.match(decoded_var):
|
|
183
|
+
return base
|
|
184
|
+
vavr_method = _VAVR_MUTATION_METHODS.get(method_name, "append") if method_name else "append"
|
|
185
|
+
# Spell out that the *variable's type* must move to Vavr — pasting the assignment alone
|
|
186
|
+
# won't compile when the variable is still a java.util.List.
|
|
187
|
+
snippet = (
|
|
188
|
+
f"// Migrate `{decoded_var}` to io.vavr.collection.List:\n"
|
|
189
|
+
f"io.vavr.collection.List<...> {decoded_var} = io.vavr.collection.List.ofAll(...);\n"
|
|
190
|
+
f"{decoded_var} = {decoded_var}.{vavr_method}(...); // returns a new persistent collection"
|
|
191
|
+
)
|
|
192
|
+
return dataclasses.replace(base, suggested_snippet=snippet)
|
|
193
|
+
|
|
194
|
+
|
|
58
195
|
# Factory methods that produce frozen (unmodifiable) collections
|
|
59
196
|
_FROZEN_FACTORIES = {
|
|
60
197
|
b"of", # List.of(), Set.of(), Map.of()
|
|
@@ -211,7 +348,10 @@ class FunctionalChecker:
|
|
|
211
348
|
severity=severity,
|
|
212
349
|
code="frozen-mutation",
|
|
213
350
|
message=_MESSAGES["frozen-mutation"],
|
|
214
|
-
data=
|
|
351
|
+
data=_build_frozen_mutation_data(
|
|
352
|
+
method_name.text or b"",
|
|
353
|
+
obj_node.text or b"",
|
|
354
|
+
),
|
|
215
355
|
)
|
|
216
356
|
)
|
|
217
357
|
|
|
@@ -288,7 +428,12 @@ class FunctionalChecker:
|
|
|
288
428
|
severity=severity,
|
|
289
429
|
code="null-check-to-monadic",
|
|
290
430
|
message=_MESSAGES["null-check-to-monadic"],
|
|
291
|
-
data=
|
|
431
|
+
data=_build_null_check_to_monadic_data(
|
|
432
|
+
checked_var,
|
|
433
|
+
consequence,
|
|
434
|
+
if_node.child_by_field_name("alternative"),
|
|
435
|
+
if_node,
|
|
436
|
+
),
|
|
292
437
|
)
|
|
293
438
|
)
|
|
294
439
|
|
|
@@ -320,7 +465,13 @@ class FunctionalChecker:
|
|
|
320
465
|
return False
|
|
321
466
|
|
|
322
467
|
def _check_impure_method(self, tree: Tree, diagnostics: list[Diagnostic], config: dict[str, Any]) -> None:
|
|
323
|
-
"""Detect methods mixing pure logic with side-effects.
|
|
468
|
+
"""Detect methods mixing pure logic with side-effects.
|
|
469
|
+
|
|
470
|
+
Points the diagnostic at the first offending statement (throw or IO call) rather
|
|
471
|
+
than the method declaration, so the user immediately sees what to extract.
|
|
472
|
+
The message + data payload differ based on whether the side-effect is a throw
|
|
473
|
+
or an IO call — agents need to suggest the right Vavr type (Either vs Try).
|
|
474
|
+
"""
|
|
324
475
|
default = Severity.WARNING if config.get("strictPurity", False) else Severity.HINT
|
|
325
476
|
severity = severity_from_config(config, "impure-method", default=default)
|
|
326
477
|
if severity is None:
|
|
@@ -331,41 +482,66 @@ class FunctionalChecker:
|
|
|
331
482
|
if body is None:
|
|
332
483
|
continue
|
|
333
484
|
|
|
334
|
-
has_side_effect = False
|
|
335
|
-
has_pure_logic = False
|
|
336
|
-
|
|
337
485
|
statements = [c for c in body.named_children if c.type not in ("line_comment", "block_comment")]
|
|
338
486
|
if len(statements) < 2: # noqa: PLR2004
|
|
339
487
|
continue # Need at least 2 statements to have a mix
|
|
340
488
|
|
|
489
|
+
offender: Node | None = None
|
|
490
|
+
offender_kind: Literal["throw", "io"] | None = None
|
|
491
|
+
has_pure_logic = False
|
|
492
|
+
|
|
341
493
|
for stmt in statements:
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
else:
|
|
494
|
+
kind, node = self._classify_side_effect(stmt)
|
|
495
|
+
if kind is None:
|
|
345
496
|
has_pure_logic = True
|
|
497
|
+
elif offender is None:
|
|
498
|
+
offender = node
|
|
499
|
+
offender_kind = kind
|
|
346
500
|
|
|
347
|
-
if
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
501
|
+
if offender is None or not has_pure_logic:
|
|
502
|
+
continue
|
|
503
|
+
|
|
504
|
+
name_node = method.child_by_field_name("name")
|
|
505
|
+
# Range = the offending statement itself, not the method declaration.
|
|
506
|
+
range_node = offender if offender is not None else name_node
|
|
507
|
+
if range_node is None:
|
|
508
|
+
continue
|
|
509
|
+
|
|
510
|
+
if offender_kind == "throw":
|
|
511
|
+
message = _MESSAGES["impure-method-throw"]
|
|
512
|
+
data = _DATA["impure-method-throw"]
|
|
513
|
+
else:
|
|
514
|
+
message = _MESSAGES["impure-method-io"]
|
|
515
|
+
data = _DATA["impure-method-io"]
|
|
516
|
+
|
|
517
|
+
diagnostics.append(
|
|
518
|
+
Diagnostic(
|
|
519
|
+
line=range_node.start_point[0],
|
|
520
|
+
col=range_node.start_point[1],
|
|
521
|
+
end_line=range_node.end_point[0],
|
|
522
|
+
end_col=range_node.end_point[1],
|
|
523
|
+
severity=severity,
|
|
524
|
+
code="impure-method",
|
|
525
|
+
message=message,
|
|
526
|
+
data=data,
|
|
362
527
|
)
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
def _classify_side_effect(self, stmt: Node) -> tuple[Literal["throw", "io"] | None, Node | None]:
|
|
531
|
+
"""Find the first side-effect node within a statement subtree.
|
|
532
|
+
|
|
533
|
+
Returns (kind, node) where kind is "throw", "io", or None (if the statement
|
|
534
|
+
contains no side-effects). The node is the offending throw_statement or
|
|
535
|
+
method_invocation — used to position the diagnostic. The Literal annotation
|
|
536
|
+
catches typos in the discriminator at type-check time.
|
|
363
537
|
|
|
364
|
-
|
|
365
|
-
"""Check if a statement contains side-effect calls or throw statements.
|
|
538
|
+
Single TreeCursor traversal to detect both throws and IO calls in one pass.
|
|
366
539
|
|
|
367
|
-
|
|
368
|
-
|
|
540
|
+
Note: When a single statement contains *both* a throw and an IO call (rare —
|
|
541
|
+
e.g. ``log(x); throw new …;`` collapsed onto one line), the kind chosen reflects
|
|
542
|
+
AST traversal order rather than a semantic precedence. In practice the diagnostic
|
|
543
|
+
is still actionable because both side-effects need extracting; the choice only
|
|
544
|
+
affects which message variant fires.
|
|
369
545
|
"""
|
|
370
546
|
cursor = stmt.walk()
|
|
371
547
|
visited_children = False
|
|
@@ -374,24 +550,13 @@ class FunctionalChecker:
|
|
|
374
550
|
current: Node | None = cursor.node
|
|
375
551
|
if current is not None:
|
|
376
552
|
if current.type == "throw_statement":
|
|
377
|
-
return
|
|
378
|
-
if current.type == "method_invocation":
|
|
379
|
-
|
|
380
|
-
return True
|
|
553
|
+
return "throw", current
|
|
554
|
+
if current.type == "method_invocation" and is_side_effect_invocation(current):
|
|
555
|
+
return "io", current
|
|
381
556
|
if not cursor.goto_first_child():
|
|
382
557
|
visited_children = True
|
|
383
558
|
elif cursor.goto_next_sibling():
|
|
384
559
|
visited_children = False
|
|
385
560
|
elif not cursor.goto_parent():
|
|
386
561
|
break
|
|
387
|
-
return
|
|
388
|
-
|
|
389
|
-
@staticmethod
|
|
390
|
-
def _is_side_effect_invocation(invocation: Node) -> bool:
|
|
391
|
-
"""Check if a method_invocation node is a side-effect call.
|
|
392
|
-
|
|
393
|
-
Thin delegator kept for backward compatibility; the real logic lives in
|
|
394
|
-
the module-level ``is_side_effect_invocation`` so other modules (e.g.
|
|
395
|
-
``fixes.py``) can reuse it without importing the class.
|
|
396
|
-
"""
|
|
397
|
-
return is_side_effect_invocation(invocation)
|
|
562
|
+
return None, None
|