java-functional-lsp 0.6.3__tar.gz → 0.7.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.7.0/.githooks/pre-commit +41 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/PKG-INFO +1 -1
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/pyproject.toml +1 -1
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/src/java_functional_lsp/__init__.py +1 -1
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/src/java_functional_lsp/analyzers/base.py +52 -0
- java_functional_lsp-0.7.0/src/java_functional_lsp/analyzers/exception_checker.py +218 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/src/java_functional_lsp/analyzers/functional_checker.py +37 -21
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/src/java_functional_lsp/fixes.py +292 -1
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/src/java_functional_lsp/proxy.py +50 -2
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/src/java_functional_lsp/server.py +1 -0
- java_functional_lsp-0.7.0/tests/test_exception_checker.py +407 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/tests/test_fixes.py +461 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/uv.lock +1 -1
- java_functional_lsp-0.6.3/.githooks/pre-commit +0 -35
- java_functional_lsp-0.6.3/src/java_functional_lsp/analyzers/exception_checker.py +0 -97
- java_functional_lsp-0.6.3/tests/test_exception_checker.py +0 -131
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/.claude-plugin/plugin.json +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/.githooks/pre-push +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/.github/CODEOWNERS +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/.github/ISSUE_TEMPLATE/feature-request.md +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/.github/SECURITY.md +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/.github/dependabot.yml +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/.github/release-drafter.yml +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/.github/workflows/publish.yml +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/.github/workflows/release-drafter.yml +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/.github/workflows/stale.yml +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/.github/workflows/test.yml +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/.github/workflows/update-homebrew.yml +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/.github/workflows/vscode-ext.yml +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/.gitignore +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/CONTRIBUTING.md +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/LICENSE +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/README.md +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/SKILL.md +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/commands/lint-java.md +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/editors/intellij/README.md +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/editors/intellij/lsp4ij-template.json +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/editors/vscode/.vscodeignore +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/editors/vscode/README.md +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/editors/vscode/package-lock.json +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/editors/vscode/package.json +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/editors/vscode/src/extension.ts +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/editors/vscode/tsconfig.json +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/hooks/hooks.json +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/hooks/java_linter_reminder.py +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/scripts/ensure-lsp.sh +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/scripts/generate-formula.py +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/src/java_functional_lsp/__main__.py +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/src/java_functional_lsp/analyzers/__init__.py +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/src/java_functional_lsp/analyzers/mutation_checker.py +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/src/java_functional_lsp/analyzers/null_checker.py +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/src/java_functional_lsp/analyzers/spring_checker.py +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/src/java_functional_lsp/cli.py +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/tests/__init__.py +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/tests/conftest.py +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/tests/test_base.py +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/tests/test_cli.py +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/tests/test_config.py +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/tests/test_e2e.py +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/tests/test_functional_checker.py +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/tests/test_mutation_checker.py +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/tests/test_null_checker.py +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/tests/test_proxy.py +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/tests/test_spring_checker.py +0 -0
- {java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/tests/test_suppress.py +0 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Pre-commit: run lint, format check, type check, and tests.
|
|
3
|
+
# Bypass with: git commit --no-verify
|
|
4
|
+
|
|
5
|
+
set -e
|
|
6
|
+
|
|
7
|
+
echo "Running pre-commit checks..."
|
|
8
|
+
|
|
9
|
+
echo " Lint..."
|
|
10
|
+
uv run ruff check src/ tests/
|
|
11
|
+
|
|
12
|
+
echo " Format..."
|
|
13
|
+
uv run ruff format --check src/ tests/
|
|
14
|
+
|
|
15
|
+
echo " Type check..."
|
|
16
|
+
uv run mypy src/
|
|
17
|
+
|
|
18
|
+
echo " Version bump check..."
|
|
19
|
+
# If source files changed, version must be bumped compared to the base branch.
|
|
20
|
+
# Compares the actual version value (not just file presence) so it works with --amend.
|
|
21
|
+
src_changed=$(git diff --cached --name-only -- 'src/' | grep -v '__pycache__' | head -1)
|
|
22
|
+
if [ -n "$src_changed" ]; then
|
|
23
|
+
# Resolve base branch: try remote main, then local main, then parent commit.
|
|
24
|
+
base_branch=$(git rev-parse --verify origin/main 2>/dev/null || git rev-parse --verify main 2>/dev/null || echo "HEAD~1")
|
|
25
|
+
# Extract the quoted version value (e.g., "0.6.4") from pyproject.toml
|
|
26
|
+
base_version=$(git show "$base_branch":pyproject.toml 2>/dev/null | grep -o 'version = "[^"]*"' | head -1)
|
|
27
|
+
staged_version=$(git show :pyproject.toml 2>/dev/null | grep -o 'version = "[^"]*"' | head -1)
|
|
28
|
+
# Skip check if either version is unresolvable (new project, missing file)
|
|
29
|
+
if [ -n "$base_version" ] && [ -n "$staged_version" ] && [ "$base_version" = "$staged_version" ]; then
|
|
30
|
+
echo "ERROR: Source files changed but version was not bumped."
|
|
31
|
+
echo "Update the version in both pyproject.toml and src/java_functional_lsp/__init__.py"
|
|
32
|
+
echo ""
|
|
33
|
+
echo "To bypass (docs/tests-only changes): git commit --no-verify"
|
|
34
|
+
exit 1
|
|
35
|
+
fi
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
echo " Tests..."
|
|
39
|
+
uv run pytest -q
|
|
40
|
+
|
|
41
|
+
echo "All checks passed."
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: java-functional-lsp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.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
|
|
@@ -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.7.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" }
|
{java_functional_lsp-0.6.3 → java_functional_lsp-0.7.0}/src/java_functional_lsp/analyzers/base.py
RENAMED
|
@@ -49,6 +49,10 @@ class Analyzer(Protocol):
|
|
|
49
49
|
...
|
|
50
50
|
|
|
51
51
|
|
|
52
|
+
#: Tree-sitter child node types that should be skipped when iterating named_children.
|
|
53
|
+
#: Shared across analyzers and fix generators so comment-filtering stays consistent.
|
|
54
|
+
IGNORED_CHILDREN = ("line_comment", "block_comment")
|
|
55
|
+
|
|
52
56
|
_parser: Parser | None = None
|
|
53
57
|
_language: Language | None = None
|
|
54
58
|
|
|
@@ -144,6 +148,54 @@ def has_ancestor(node: Node, type_names: set[str]) -> bool:
|
|
|
144
148
|
return False
|
|
145
149
|
|
|
146
150
|
|
|
151
|
+
def references_var(node: Node, var_name: bytes) -> bool:
|
|
152
|
+
"""Return True if any identifier descendant of ``node`` has text equal to ``var_name``.
|
|
153
|
+
|
|
154
|
+
Used to detect whether an expression references a given variable (e.g. an
|
|
155
|
+
exception variable inside a catch-return expression). Uses a TreeCursor walk
|
|
156
|
+
for performance — no recursion, no allocation per node.
|
|
157
|
+
"""
|
|
158
|
+
cursor = node.walk()
|
|
159
|
+
visited_children = False
|
|
160
|
+
while True:
|
|
161
|
+
if not visited_children:
|
|
162
|
+
cur = cursor.node
|
|
163
|
+
if cur is not None and cur.type == "identifier" and cur.text == var_name:
|
|
164
|
+
return True
|
|
165
|
+
if not cursor.goto_first_child():
|
|
166
|
+
visited_children = True
|
|
167
|
+
elif cursor.goto_next_sibling():
|
|
168
|
+
visited_children = False
|
|
169
|
+
elif not cursor.goto_parent():
|
|
170
|
+
break
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def has_error_or_missing(node: Node) -> bool:
|
|
175
|
+
"""Return True if the subtree rooted at ``node`` contains any ERROR or MISSING nodes.
|
|
176
|
+
|
|
177
|
+
Used by fix generators as a defensive gate: tree-sitter's error recovery
|
|
178
|
+
produces partial trees for malformed input (common during incremental typing
|
|
179
|
+
in editors), and emitting a rewrite from such input can produce invalid edits.
|
|
180
|
+
"""
|
|
181
|
+
if node.has_error or node.is_missing:
|
|
182
|
+
return True
|
|
183
|
+
cursor = node.walk()
|
|
184
|
+
visited_children = False
|
|
185
|
+
while True:
|
|
186
|
+
if not visited_children:
|
|
187
|
+
cur = cursor.node
|
|
188
|
+
if cur is not None and (cur.type == "ERROR" or cur.is_missing):
|
|
189
|
+
return True
|
|
190
|
+
if not cursor.goto_first_child():
|
|
191
|
+
visited_children = True
|
|
192
|
+
elif cursor.goto_next_sibling():
|
|
193
|
+
visited_children = False
|
|
194
|
+
elif not cursor.goto_parent():
|
|
195
|
+
break
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
|
|
147
199
|
def severity_from_config(config: dict[str, Any], rule_id: str, default: Severity = Severity.WARNING) -> Severity | None:
|
|
148
200
|
"""Get severity for a rule from config. Returns None if rule is disabled."""
|
|
149
201
|
rules: dict[str, str] = config.get("rules", {})
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Exception handling rules: detect throw statements and catch-rethrow patterns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .base import (
|
|
8
|
+
IGNORED_CHILDREN,
|
|
9
|
+
Diagnostic,
|
|
10
|
+
DiagnosticData,
|
|
11
|
+
Severity,
|
|
12
|
+
collect_nodes_by_type,
|
|
13
|
+
has_sibling_annotation,
|
|
14
|
+
severity_from_config,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
_MESSAGES = {
|
|
18
|
+
"throw-statement": ("Avoid throwing exceptions. Use Either.left(error) or Try.of(() -> ...).toEither()."),
|
|
19
|
+
"catch-rethrow": (
|
|
20
|
+
"Avoid catching and rethrowing. Use Try.of(() -> ...).toEither() to convert exceptions to values."
|
|
21
|
+
),
|
|
22
|
+
"try-catch-to-monadic": ("Imperative try/catch: use Try.of(() -> ...) for monadic error handling."),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
_DATA = {
|
|
26
|
+
"throw-statement": DiagnosticData(
|
|
27
|
+
fix_type="USE_EITHER_OR_TRY",
|
|
28
|
+
target_library="io.vavr.control.Either",
|
|
29
|
+
rationale=(
|
|
30
|
+
"Throwing exceptions breaks referential transparency."
|
|
31
|
+
" Use Either.left(error) to represent failures as values."
|
|
32
|
+
),
|
|
33
|
+
),
|
|
34
|
+
"catch-rethrow": DiagnosticData(
|
|
35
|
+
fix_type="USE_TRY_TO_EITHER",
|
|
36
|
+
target_library="io.vavr.control.Try",
|
|
37
|
+
rationale=(
|
|
38
|
+
"Catching and rethrowing adds noise. Use Try.of(() -> ...).toEither() to convert exceptions to values."
|
|
39
|
+
),
|
|
40
|
+
),
|
|
41
|
+
"try-catch-to-monadic": DiagnosticData(
|
|
42
|
+
fix_type="WRAP_IN_TRY",
|
|
43
|
+
target_library="io.vavr.control.Try",
|
|
44
|
+
rationale=(
|
|
45
|
+
"try/catch mixes control flow with value handling."
|
|
46
|
+
" Use Try.of(...) for composable, value-based failure handling."
|
|
47
|
+
),
|
|
48
|
+
),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _is_in_bean_method(node: Any) -> bool:
|
|
53
|
+
"""Check if node is inside a method annotated with @Bean."""
|
|
54
|
+
parent = node.parent
|
|
55
|
+
while parent:
|
|
56
|
+
if parent.type == "method_declaration":
|
|
57
|
+
modifiers = next((c for c in parent.children if c.type == "modifiers"), None)
|
|
58
|
+
if modifiers and has_sibling_annotation(modifiers, b"Bean"):
|
|
59
|
+
return True
|
|
60
|
+
return False
|
|
61
|
+
parent = parent.parent
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _matches_try_catch_monadic_shape(try_node: Any) -> bool: # noqa: PLR0911, PLR0912
|
|
66
|
+
"""Verify a try_statement has a shape the try-catch-to-monadic fix can rewrite.
|
|
67
|
+
|
|
68
|
+
Requirements:
|
|
69
|
+
- No ``resource_specification`` child (try-with-resources would lose auto-close semantics)
|
|
70
|
+
- No ``finally_clause`` child
|
|
71
|
+
- Exactly one ``catch_clause`` child
|
|
72
|
+
- Catch clause must use a single exception type (no union-catch ``A | B e``)
|
|
73
|
+
- Try body is a block with a single ``return_statement`` that has an expression
|
|
74
|
+
- Catch body is a block whose last named child is a ``return_statement`` with an expression,
|
|
75
|
+
and any prior named children are ``expression_statement`` (for the Pattern 2 logging case)
|
|
76
|
+
|
|
77
|
+
Guard-clause returns are preferred for readability; noqa silences the
|
|
78
|
+
"too many returns" rule since each branch fails a distinct shape check.
|
|
79
|
+
|
|
80
|
+
Note: This analyzer-side check must stay in sync with
|
|
81
|
+
``_validate_and_extract_try_catch_parts`` in fixes.py — any shape the
|
|
82
|
+
analyzer accepts must also be rewritable by the fix, otherwise users
|
|
83
|
+
see a diagnostic with no working code action.
|
|
84
|
+
"""
|
|
85
|
+
# Single pass over try_node.children: detect finally, resource_specification, and catches
|
|
86
|
+
has_finally = False
|
|
87
|
+
has_resources = False
|
|
88
|
+
catches: list[Any] = []
|
|
89
|
+
for c in try_node.children:
|
|
90
|
+
if c.type == "finally_clause":
|
|
91
|
+
has_finally = True
|
|
92
|
+
elif c.type == "resource_specification":
|
|
93
|
+
has_resources = True
|
|
94
|
+
elif c.type == "catch_clause":
|
|
95
|
+
catches.append(c)
|
|
96
|
+
|
|
97
|
+
if has_finally or has_resources:
|
|
98
|
+
return False
|
|
99
|
+
if len(catches) != 1:
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
# Reject union-catch (A | B e). Tree-sitter encodes the type in catch_formal_parameter
|
|
103
|
+
# as either a single type node or a catch_type node containing multiple type_identifier children.
|
|
104
|
+
catch = catches[0]
|
|
105
|
+
param = next((c for c in catch.children if c.type == "catch_formal_parameter"), None)
|
|
106
|
+
if param is None:
|
|
107
|
+
return False
|
|
108
|
+
catch_type_node = next((c for c in param.children if c.type == "catch_type"), None)
|
|
109
|
+
if catch_type_node is not None and catch_type_node.text and b"|" in catch_type_node.text:
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
# Try body must be a block with a single return_statement with an expression
|
|
113
|
+
body = try_node.child_by_field_name("body")
|
|
114
|
+
if body is None or body.type != "block":
|
|
115
|
+
return False
|
|
116
|
+
body_stmts = [c for c in body.named_children if c.type not in IGNORED_CHILDREN]
|
|
117
|
+
if len(body_stmts) != 1 or body_stmts[0].type != "return_statement":
|
|
118
|
+
return False
|
|
119
|
+
# Return must have an expression (not bare `return;`)
|
|
120
|
+
ret_children = [c for c in body_stmts[0].named_children if c.type not in IGNORED_CHILDREN]
|
|
121
|
+
if not ret_children:
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
# Catch body must end with a return_statement; prior stmts must be expression_statements
|
|
125
|
+
catch_body = catch.child_by_field_name("body")
|
|
126
|
+
if catch_body is None or catch_body.type != "block":
|
|
127
|
+
return False
|
|
128
|
+
catch_stmts = [c for c in catch_body.named_children if c.type not in IGNORED_CHILDREN]
|
|
129
|
+
if not catch_stmts or catch_stmts[-1].type != "return_statement":
|
|
130
|
+
return False
|
|
131
|
+
if any(s.type != "expression_statement" for s in catch_stmts[:-1]):
|
|
132
|
+
return False
|
|
133
|
+
# The catch return must have an expression (not bare `return;`)
|
|
134
|
+
catch_ret_children = [c for c in catch_stmts[-1].named_children if c.type not in IGNORED_CHILDREN]
|
|
135
|
+
if not catch_ret_children:
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class ExceptionChecker:
|
|
142
|
+
"""Detects throw statements and catch-rethrow anti-patterns."""
|
|
143
|
+
|
|
144
|
+
def analyze(self, tree: Any, source: bytes, config: dict[str, Any]) -> list[Diagnostic]:
|
|
145
|
+
diagnostics: list[Diagnostic] = []
|
|
146
|
+
|
|
147
|
+
# Single tree walk collecting all three node types we care about.
|
|
148
|
+
# Previously this method did three separate find_nodes walks, which is 3x the traversal
|
|
149
|
+
# cost on every analysis pass (LSP re-runs on every document change).
|
|
150
|
+
buckets = collect_nodes_by_type(tree.root_node, {"throw_statement", "catch_clause", "try_statement"})
|
|
151
|
+
|
|
152
|
+
# Rule: throw-statement
|
|
153
|
+
severity = severity_from_config(config, "throw-statement")
|
|
154
|
+
if severity is not None:
|
|
155
|
+
for node in buckets["throw_statement"]:
|
|
156
|
+
if _is_in_bean_method(node):
|
|
157
|
+
continue
|
|
158
|
+
diagnostics.append(
|
|
159
|
+
Diagnostic(
|
|
160
|
+
line=node.start_point[0],
|
|
161
|
+
col=node.start_point[1],
|
|
162
|
+
end_line=node.end_point[0],
|
|
163
|
+
end_col=node.end_point[1],
|
|
164
|
+
severity=severity,
|
|
165
|
+
code="throw-statement",
|
|
166
|
+
message=_MESSAGES["throw-statement"],
|
|
167
|
+
data=_DATA["throw-statement"],
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Rule: catch-rethrow
|
|
172
|
+
severity = severity_from_config(config, "catch-rethrow")
|
|
173
|
+
if severity is not None:
|
|
174
|
+
for node in buckets["catch_clause"]:
|
|
175
|
+
if _is_in_bean_method(node):
|
|
176
|
+
continue
|
|
177
|
+
body = node.child_by_field_name("body")
|
|
178
|
+
if body is None:
|
|
179
|
+
continue
|
|
180
|
+
statements = [c for c in body.named_children if c.type not in IGNORED_CHILDREN]
|
|
181
|
+
if len(statements) == 1 and statements[0].type == "throw_statement":
|
|
182
|
+
diagnostics.append(
|
|
183
|
+
Diagnostic(
|
|
184
|
+
line=node.start_point[0],
|
|
185
|
+
col=node.start_point[1],
|
|
186
|
+
end_line=node.end_point[0],
|
|
187
|
+
end_col=node.end_point[1],
|
|
188
|
+
severity=severity,
|
|
189
|
+
code="catch-rethrow",
|
|
190
|
+
message=_MESSAGES["catch-rethrow"],
|
|
191
|
+
data=_DATA["catch-rethrow"],
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# Rule: try-catch-to-monadic
|
|
196
|
+
severity = severity_from_config(config, "try-catch-to-monadic", default=Severity.HINT)
|
|
197
|
+
if severity is not None:
|
|
198
|
+
for try_node in buckets["try_statement"]:
|
|
199
|
+
if _is_in_bean_method(try_node):
|
|
200
|
+
continue
|
|
201
|
+
if not _matches_try_catch_monadic_shape(try_node):
|
|
202
|
+
continue
|
|
203
|
+
# Position the diagnostic on the `try` keyword only (narrow range).
|
|
204
|
+
try_kw = next((c for c in try_node.children if c.type == "try"), try_node)
|
|
205
|
+
diagnostics.append(
|
|
206
|
+
Diagnostic(
|
|
207
|
+
line=try_kw.start_point[0],
|
|
208
|
+
col=try_kw.start_point[1],
|
|
209
|
+
end_line=try_kw.end_point[0],
|
|
210
|
+
end_col=try_kw.end_point[1],
|
|
211
|
+
severity=severity,
|
|
212
|
+
code="try-catch-to-monadic",
|
|
213
|
+
message=_MESSAGES["try-catch-to-monadic"],
|
|
214
|
+
data=_DATA["try-catch-to-monadic"],
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
return diagnostics
|
|
@@ -130,6 +130,37 @@ _SIDE_EFFECT_METHODS = {
|
|
|
130
130
|
_METHOD_SCOPES = {"method_declaration", "constructor_declaration", "lambda_expression"}
|
|
131
131
|
|
|
132
132
|
|
|
133
|
+
def is_side_effect_invocation(invocation: Node) -> bool:
|
|
134
|
+
"""Check if a method_invocation node is a side-effect call.
|
|
135
|
+
|
|
136
|
+
Recognizes patterns like:
|
|
137
|
+
- ``System.out.println(...)`` / ``System.err.println(...)``
|
|
138
|
+
- ``logger.info(...)`` / ``log.debug(...)`` / ``LOG.warn(...)``
|
|
139
|
+
- Bare side-effect method names (e.g. ``println(...)``)
|
|
140
|
+
"""
|
|
141
|
+
obj_node = invocation.child_by_field_name("object")
|
|
142
|
+
method_name = invocation.child_by_field_name("name")
|
|
143
|
+
if method_name is None:
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
if obj_node is not None:
|
|
147
|
+
# System.out.println / System.err.println
|
|
148
|
+
if obj_node.type == "field_access":
|
|
149
|
+
receiver = obj_node.child_by_field_name("object")
|
|
150
|
+
if receiver is not None and receiver.text in _SIDE_EFFECT_RECEIVERS:
|
|
151
|
+
return True
|
|
152
|
+
# logger.info, log.debug, etc.
|
|
153
|
+
if obj_node.type == "identifier" and obj_node.text in _SIDE_EFFECT_RECEIVERS:
|
|
154
|
+
if method_name.text in _SIDE_EFFECT_METHODS:
|
|
155
|
+
return True
|
|
156
|
+
|
|
157
|
+
# Standalone side-effect method names
|
|
158
|
+
if method_name.text in _SIDE_EFFECT_METHODS and obj_node is None:
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
|
|
133
164
|
class FunctionalChecker:
|
|
134
165
|
"""Detects frozen mutation traps, imperative null checks, and impure methods."""
|
|
135
166
|
|
|
@@ -357,25 +388,10 @@ class FunctionalChecker:
|
|
|
357
388
|
|
|
358
389
|
@staticmethod
|
|
359
390
|
def _is_side_effect_invocation(invocation: Node) -> bool:
|
|
360
|
-
"""Check if a method_invocation node is a side-effect call.
|
|
361
|
-
obj_node = invocation.child_by_field_name("object")
|
|
362
|
-
method_name = invocation.child_by_field_name("name")
|
|
363
|
-
if method_name is None:
|
|
364
|
-
return False
|
|
365
|
-
|
|
366
|
-
if obj_node is not None:
|
|
367
|
-
# System.out.println / System.err.println
|
|
368
|
-
if obj_node.type == "field_access":
|
|
369
|
-
receiver = obj_node.child_by_field_name("object")
|
|
370
|
-
if receiver is not None and receiver.text in _SIDE_EFFECT_RECEIVERS:
|
|
371
|
-
return True
|
|
372
|
-
# logger.info, log.debug, etc.
|
|
373
|
-
if obj_node.type == "identifier" and obj_node.text in _SIDE_EFFECT_RECEIVERS:
|
|
374
|
-
if method_name.text in _SIDE_EFFECT_METHODS:
|
|
375
|
-
return True
|
|
376
|
-
|
|
377
|
-
# Standalone side-effect method names
|
|
378
|
-
if method_name.text in _SIDE_EFFECT_METHODS and obj_node is None:
|
|
379
|
-
return True
|
|
391
|
+
"""Check if a method_invocation node is a side-effect call.
|
|
380
392
|
|
|
381
|
-
|
|
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)
|