java-functional-lsp 0.3.1__tar.gz → 0.4.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.3.1 → java_functional_lsp-0.4.0}/.claude-plugin/plugin.json +1 -1
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/PKG-INFO +28 -5
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/README.md +27 -4
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/SKILL.md +5 -1
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/editors/vscode/package.json +1 -1
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/pyproject.toml +1 -1
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/src/java_functional_lsp/__init__.py +1 -1
- java_functional_lsp-0.4.0/src/java_functional_lsp/analyzers/base.py +173 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/src/java_functional_lsp/analyzers/exception_checker.py +19 -5
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/src/java_functional_lsp/analyzers/mutation_checker.py +22 -6
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/src/java_functional_lsp/cli.py +4 -1
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/src/java_functional_lsp/server.py +16 -5
- java_functional_lsp-0.4.0/tests/test_base.py +157 -0
- java_functional_lsp-0.4.0/tests/test_exception_checker.py +104 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/tests/test_mutation_checker.py +52 -0
- java_functional_lsp-0.3.1/src/java_functional_lsp/analyzers/base.py +0 -112
- java_functional_lsp-0.3.1/tests/test_exception_checker.py +0 -46
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.github/CODEOWNERS +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.github/ISSUE_TEMPLATE/feature-request.md +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.github/SECURITY.md +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.github/dependabot.yml +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.github/release-drafter.yml +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.github/workflows/publish.yml +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.github/workflows/release-drafter.yml +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.github/workflows/stale.yml +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.github/workflows/test.yml +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.github/workflows/update-homebrew.yml +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.github/workflows/vscode-ext.yml +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.gitignore +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/CONTRIBUTING.md +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/LICENSE +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/commands/lint-java.md +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/editors/intellij/README.md +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/editors/intellij/lsp4ij-template.json +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/editors/vscode/.vscodeignore +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/editors/vscode/README.md +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/editors/vscode/package-lock.json +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/editors/vscode/src/extension.ts +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/editors/vscode/tsconfig.json +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/hooks/hooks.json +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/hooks/java_linter_reminder.py +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/scripts/ensure-lsp.sh +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/scripts/generate-formula.py +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/src/java_functional_lsp/analyzers/__init__.py +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/src/java_functional_lsp/analyzers/null_checker.py +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/src/java_functional_lsp/analyzers/spring_checker.py +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/src/java_functional_lsp/proxy.py +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/tests/__init__.py +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/tests/conftest.py +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/tests/test_cli.py +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/tests/test_config.py +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/tests/test_null_checker.py +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/tests/test_proxy.py +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/tests/test_spring_checker.py +0 -0
- {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: java-functional-lsp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -63,13 +63,28 @@ pip install java-functional-lsp
|
|
|
63
63
|
|
|
64
64
|
# From source
|
|
65
65
|
pip install git+https://github.com/aviadshiber/java-functional-lsp.git
|
|
66
|
+
|
|
67
|
+
# Optional: install jdtls for full Java language support (completions, hover, go-to-def)
|
|
68
|
+
brew install jdtls
|
|
66
69
|
```
|
|
67
70
|
|
|
71
|
+
**Requirements:**
|
|
72
|
+
- Python 3.10+ (for the LSP server)
|
|
73
|
+
- JDK 21+ (only if using jdtls — jdtls 1.57+ requires JDK 21 as its runtime, but can analyze Java 8+ source code)
|
|
74
|
+
|
|
68
75
|
## IDE Setup
|
|
69
76
|
|
|
70
77
|
### VS Code
|
|
71
78
|
|
|
72
|
-
Install the extension from a `.vsix` file ([download from releases](https://github.com/aviadshiber/java-functional-lsp/releases))
|
|
79
|
+
Install the extension from a `.vsix` file ([download from releases](https://github.com/aviadshiber/java-functional-lsp/releases)):
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
# Download and install
|
|
83
|
+
gh release download --repo aviadshiber/java-functional-lsp --pattern "*.vsix" --dir /tmp
|
|
84
|
+
code --install-extension /tmp/java-functional-lsp-*.vsix
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Or build from source:
|
|
73
88
|
|
|
74
89
|
```bash
|
|
75
90
|
cd editors/vscode
|
|
@@ -78,7 +93,9 @@ npx vsce package
|
|
|
78
93
|
code --install-extension java-functional-lsp-*.vsix
|
|
79
94
|
```
|
|
80
95
|
|
|
81
|
-
The extension
|
|
96
|
+
The extension is a thin launcher — it just starts the `java-functional-lsp` binary for `.java` files. **Updating rules only requires upgrading the LSP binary** (`brew upgrade java-functional-lsp` or `pip install --upgrade java-functional-lsp`). The VSIX itself rarely needs updating.
|
|
97
|
+
|
|
98
|
+
Configure the binary path in settings if needed (`javaFunctionalLsp.serverPath`). See [editors/vscode/README.md](editors/vscode/README.md) for details.
|
|
82
99
|
|
|
83
100
|
### IntelliJ IDEA
|
|
84
101
|
|
|
@@ -129,6 +146,7 @@ Create `.java-functional-lsp.json` in your project root to customize rules:
|
|
|
129
146
|
|
|
130
147
|
```json
|
|
131
148
|
{
|
|
149
|
+
"excludes": ["**/generated/**", "**/vendor/**"],
|
|
132
150
|
"rules": {
|
|
133
151
|
"null-literal-arg": "warning",
|
|
134
152
|
"throw-statement": "info",
|
|
@@ -138,8 +156,13 @@ Create `.java-functional-lsp.json` in your project root to customize rules:
|
|
|
138
156
|
}
|
|
139
157
|
```
|
|
140
158
|
|
|
141
|
-
|
|
142
|
-
|
|
159
|
+
**Options:**
|
|
160
|
+
- `excludes` — glob patterns for files/directories to skip entirely (supports `**` for multi-segment wildcards)
|
|
161
|
+
- `rules` — per-rule severity: `error`, `warning` (default), `info`, `hint`, `off`
|
|
162
|
+
|
|
163
|
+
**Spring-aware behavior:**
|
|
164
|
+
- `throw-statement` and `catch-rethrow` are automatically suppressed inside `@Bean` methods
|
|
165
|
+
- `mutable-dto` suggests `@ConstructorBinding` instead of `@Value` when the class has `@ConfigurationProperties`
|
|
143
166
|
|
|
144
167
|
## How it works
|
|
145
168
|
|
|
@@ -35,13 +35,28 @@ pip install java-functional-lsp
|
|
|
35
35
|
|
|
36
36
|
# From source
|
|
37
37
|
pip install git+https://github.com/aviadshiber/java-functional-lsp.git
|
|
38
|
+
|
|
39
|
+
# Optional: install jdtls for full Java language support (completions, hover, go-to-def)
|
|
40
|
+
brew install jdtls
|
|
38
41
|
```
|
|
39
42
|
|
|
43
|
+
**Requirements:**
|
|
44
|
+
- Python 3.10+ (for the LSP server)
|
|
45
|
+
- JDK 21+ (only if using jdtls — jdtls 1.57+ requires JDK 21 as its runtime, but can analyze Java 8+ source code)
|
|
46
|
+
|
|
40
47
|
## IDE Setup
|
|
41
48
|
|
|
42
49
|
### VS Code
|
|
43
50
|
|
|
44
|
-
Install the extension from a `.vsix` file ([download from releases](https://github.com/aviadshiber/java-functional-lsp/releases))
|
|
51
|
+
Install the extension from a `.vsix` file ([download from releases](https://github.com/aviadshiber/java-functional-lsp/releases)):
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# Download and install
|
|
55
|
+
gh release download --repo aviadshiber/java-functional-lsp --pattern "*.vsix" --dir /tmp
|
|
56
|
+
code --install-extension /tmp/java-functional-lsp-*.vsix
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Or build from source:
|
|
45
60
|
|
|
46
61
|
```bash
|
|
47
62
|
cd editors/vscode
|
|
@@ -50,7 +65,9 @@ npx vsce package
|
|
|
50
65
|
code --install-extension java-functional-lsp-*.vsix
|
|
51
66
|
```
|
|
52
67
|
|
|
53
|
-
The extension
|
|
68
|
+
The extension is a thin launcher — it just starts the `java-functional-lsp` binary for `.java` files. **Updating rules only requires upgrading the LSP binary** (`brew upgrade java-functional-lsp` or `pip install --upgrade java-functional-lsp`). The VSIX itself rarely needs updating.
|
|
69
|
+
|
|
70
|
+
Configure the binary path in settings if needed (`javaFunctionalLsp.serverPath`). See [editors/vscode/README.md](editors/vscode/README.md) for details.
|
|
54
71
|
|
|
55
72
|
### IntelliJ IDEA
|
|
56
73
|
|
|
@@ -101,6 +118,7 @@ Create `.java-functional-lsp.json` in your project root to customize rules:
|
|
|
101
118
|
|
|
102
119
|
```json
|
|
103
120
|
{
|
|
121
|
+
"excludes": ["**/generated/**", "**/vendor/**"],
|
|
104
122
|
"rules": {
|
|
105
123
|
"null-literal-arg": "warning",
|
|
106
124
|
"throw-statement": "info",
|
|
@@ -110,8 +128,13 @@ Create `.java-functional-lsp.json` in your project root to customize rules:
|
|
|
110
128
|
}
|
|
111
129
|
```
|
|
112
130
|
|
|
113
|
-
|
|
114
|
-
|
|
131
|
+
**Options:**
|
|
132
|
+
- `excludes` — glob patterns for files/directories to skip entirely (supports `**` for multi-segment wildcards)
|
|
133
|
+
- `rules` — per-rule severity: `error`, `warning` (default), `info`, `hint`, `off`
|
|
134
|
+
|
|
135
|
+
**Spring-aware behavior:**
|
|
136
|
+
- `throw-statement` and `catch-rethrow` are automatically suppressed inside `@Bean` methods
|
|
137
|
+
- `mutable-dto` suggests `@ConstructorBinding` instead of `@Value` when the class has `@ConfigurationProperties`
|
|
115
138
|
|
|
116
139
|
## How it works
|
|
117
140
|
|
|
@@ -45,6 +45,7 @@ Create `.java-functional-lsp.json` in your project root:
|
|
|
45
45
|
|
|
46
46
|
```json
|
|
47
47
|
{
|
|
48
|
+
"excludes": ["**/generated/**", "**/vendor/**"],
|
|
48
49
|
"rules": {
|
|
49
50
|
"imperative-loop": "hint",
|
|
50
51
|
"mutable-variable": "info",
|
|
@@ -53,7 +54,10 @@ Create `.java-functional-lsp.json` in your project root:
|
|
|
53
54
|
}
|
|
54
55
|
```
|
|
55
56
|
|
|
56
|
-
|
|
57
|
+
- `excludes` — glob patterns to skip files/directories entirely
|
|
58
|
+
- `rules` — per-rule severity: `error`, `warning` (default), `info`, `hint`, `off`
|
|
59
|
+
- `throw-statement`/`catch-rethrow` auto-suppressed in `@Bean` methods
|
|
60
|
+
- `mutable-dto` suggests `@ConstructorBinding` for `@ConfigurationProperties` classes
|
|
57
61
|
|
|
58
62
|
## On-Demand Linting
|
|
59
63
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "java-functional-lsp",
|
|
3
3
|
"displayName": "Java Functional LSP",
|
|
4
4
|
"description": "Java LSP server enforcing functional programming best practices — null safety, immutability, no exceptions",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.4.0",
|
|
6
6
|
"publisher": "aviadshiber",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"engines": {
|
|
@@ -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.4.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,173 @@
|
|
|
1
|
+
"""Base analyzer class and diagnostic types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
from collections.abc import Generator
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import IntEnum
|
|
9
|
+
from typing import Any, Protocol, cast
|
|
10
|
+
|
|
11
|
+
import tree_sitter_java as tsjava
|
|
12
|
+
from tree_sitter import Language, Node, Parser
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Severity(IntEnum):
|
|
16
|
+
ERROR = 1
|
|
17
|
+
WARNING = 2
|
|
18
|
+
INFO = 3
|
|
19
|
+
HINT = 4
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Diagnostic:
|
|
24
|
+
line: int # 0-based
|
|
25
|
+
col: int
|
|
26
|
+
end_line: int
|
|
27
|
+
end_col: int
|
|
28
|
+
severity: Severity
|
|
29
|
+
code: str # rule ID
|
|
30
|
+
message: str
|
|
31
|
+
source: str = "java-functional-lsp"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Analyzer(Protocol):
|
|
35
|
+
"""Protocol for all analyzers."""
|
|
36
|
+
|
|
37
|
+
def analyze(self, tree: Any, source: bytes, config: dict[str, Any]) -> list[Diagnostic]:
|
|
38
|
+
"""Analyze a parsed tree and return diagnostics."""
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_parser: Parser | None = None
|
|
43
|
+
_language: Language | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_parser() -> Parser:
|
|
47
|
+
"""Get or create a reusable tree-sitter Java parser."""
|
|
48
|
+
global _parser, _language
|
|
49
|
+
if _parser is None:
|
|
50
|
+
_language = Language(tsjava.language())
|
|
51
|
+
_parser = Parser(_language)
|
|
52
|
+
return _parser
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_language() -> Language:
|
|
56
|
+
"""Get the Java language for queries."""
|
|
57
|
+
global _language
|
|
58
|
+
if _language is None:
|
|
59
|
+
get_parser()
|
|
60
|
+
assert _language is not None
|
|
61
|
+
return _language
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def find_nodes(root: Node, type_name: str) -> Generator[Node, None, None]:
|
|
65
|
+
"""Find all descendant nodes of a given type using TreeCursor for performance."""
|
|
66
|
+
cursor = root.walk()
|
|
67
|
+
visited_children = False
|
|
68
|
+
while True:
|
|
69
|
+
if not visited_children:
|
|
70
|
+
current = cast(Node, cursor.node)
|
|
71
|
+
if current.type == type_name:
|
|
72
|
+
yield current
|
|
73
|
+
if not cursor.goto_first_child():
|
|
74
|
+
visited_children = True
|
|
75
|
+
elif cursor.goto_next_sibling():
|
|
76
|
+
visited_children = False
|
|
77
|
+
elif not cursor.goto_parent():
|
|
78
|
+
break
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def find_nodes_multi(root: Node, type_names: set[str]) -> Generator[Node, None, None]:
|
|
82
|
+
"""Find all descendant nodes matching any of the given types using TreeCursor."""
|
|
83
|
+
cursor = root.walk()
|
|
84
|
+
visited_children = False
|
|
85
|
+
while True:
|
|
86
|
+
if not visited_children:
|
|
87
|
+
current = cast(Node, cursor.node)
|
|
88
|
+
if current.type in type_names:
|
|
89
|
+
yield current
|
|
90
|
+
if not cursor.goto_first_child():
|
|
91
|
+
visited_children = True
|
|
92
|
+
elif cursor.goto_next_sibling():
|
|
93
|
+
visited_children = False
|
|
94
|
+
elif not cursor.goto_parent():
|
|
95
|
+
break
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def collect_nodes_by_type(root: Node, type_names: set[str]) -> dict[str, list[Node]]:
|
|
99
|
+
"""Walk tree once, bucket nodes by type. Avoids multiple full traversals."""
|
|
100
|
+
buckets: dict[str, list[Node]] = {t: [] for t in type_names}
|
|
101
|
+
cursor = root.walk()
|
|
102
|
+
visited_children = False
|
|
103
|
+
while True:
|
|
104
|
+
if not visited_children:
|
|
105
|
+
current = cast(Node, cursor.node)
|
|
106
|
+
if current.type in buckets:
|
|
107
|
+
buckets[current.type].append(current)
|
|
108
|
+
if not cursor.goto_first_child():
|
|
109
|
+
visited_children = True
|
|
110
|
+
elif cursor.goto_next_sibling():
|
|
111
|
+
visited_children = False
|
|
112
|
+
elif not cursor.goto_parent():
|
|
113
|
+
break
|
|
114
|
+
return buckets
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def find_ancestor(node: Node, type_name: str) -> Node | None:
|
|
118
|
+
"""Walk up the tree to find the nearest ancestor of a given type."""
|
|
119
|
+
parent = node.parent
|
|
120
|
+
while parent:
|
|
121
|
+
if parent.type == type_name:
|
|
122
|
+
return parent
|
|
123
|
+
parent = parent.parent
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def has_ancestor(node: Node, type_names: set[str]) -> bool:
|
|
128
|
+
"""Check if any ancestor matches one of the given types."""
|
|
129
|
+
parent = node.parent
|
|
130
|
+
while parent:
|
|
131
|
+
if parent.type in type_names:
|
|
132
|
+
return True
|
|
133
|
+
parent = parent.parent
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def severity_from_config(config: dict[str, Any], rule_id: str, default: Severity = Severity.WARNING) -> Severity | None:
|
|
138
|
+
"""Get severity for a rule from config. Returns None if rule is disabled."""
|
|
139
|
+
rules: dict[str, str] = config.get("rules", {})
|
|
140
|
+
level = rules.get(rule_id)
|
|
141
|
+
if level is None:
|
|
142
|
+
return default
|
|
143
|
+
if level == "off":
|
|
144
|
+
return None
|
|
145
|
+
return {
|
|
146
|
+
"error": Severity.ERROR,
|
|
147
|
+
"warning": Severity.WARNING,
|
|
148
|
+
"info": Severity.INFO,
|
|
149
|
+
"hint": Severity.HINT,
|
|
150
|
+
}.get(level, default)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def is_excluded(path_str: str, patterns: list[str]) -> bool:
|
|
154
|
+
"""Return True if path matches any exclude glob pattern.
|
|
155
|
+
|
|
156
|
+
Patterns support ** for multi-segment wildcards (e.g. **/generated/**).
|
|
157
|
+
Uses fnmatch which handles ** correctly across Python 3.10+.
|
|
158
|
+
"""
|
|
159
|
+
normalized = path_str.replace("\\", "/")
|
|
160
|
+
return any(fnmatch.fnmatch(normalized, pattern) for pattern in patterns)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def has_sibling_annotation(modifiers_node: Node, annotation_name: bytes) -> bool:
|
|
164
|
+
"""Check if a modifiers node contains an annotation with the given name.
|
|
165
|
+
|
|
166
|
+
Checks both marker_annotation (@Foo) and annotation (@Foo(...)) forms.
|
|
167
|
+
"""
|
|
168
|
+
for child in modifiers_node.named_children:
|
|
169
|
+
if child.type in ("marker_annotation", "annotation"):
|
|
170
|
+
name_node = child.child_by_field_name("name")
|
|
171
|
+
if name_node is not None and name_node.text == annotation_name:
|
|
172
|
+
return True
|
|
173
|
+
return False
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
|
-
from .base import Diagnostic, find_nodes, severity_from_config
|
|
7
|
+
from .base import Diagnostic, find_nodes, has_sibling_annotation, severity_from_config
|
|
8
8
|
|
|
9
9
|
_MESSAGES = {
|
|
10
10
|
"throw-statement": ("Avoid throwing exceptions. Use Either.left(error) or Try.of(() -> ...).toEither()."),
|
|
@@ -14,6 +14,19 @@ _MESSAGES = {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
def _is_in_bean_method(node: Any) -> bool:
|
|
18
|
+
"""Check if node is inside a method annotated with @Bean."""
|
|
19
|
+
parent = node.parent
|
|
20
|
+
while parent:
|
|
21
|
+
if parent.type == "method_declaration":
|
|
22
|
+
modifiers = next((c for c in parent.children if c.type == "modifiers"), None)
|
|
23
|
+
if modifiers and has_sibling_annotation(modifiers, b"Bean"):
|
|
24
|
+
return True
|
|
25
|
+
return False
|
|
26
|
+
parent = parent.parent
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
|
|
17
30
|
class ExceptionChecker:
|
|
18
31
|
"""Detects throw statements and catch-rethrow anti-patterns."""
|
|
19
32
|
|
|
@@ -24,6 +37,8 @@ class ExceptionChecker:
|
|
|
24
37
|
severity = severity_from_config(config, "throw-statement")
|
|
25
38
|
if severity is not None:
|
|
26
39
|
for node in find_nodes(tree.root_node, "throw_statement"):
|
|
40
|
+
if _is_in_bean_method(node):
|
|
41
|
+
continue
|
|
27
42
|
diagnostics.append(
|
|
28
43
|
Diagnostic(
|
|
29
44
|
line=node.start_point[0],
|
|
@@ -40,13 +55,12 @@ class ExceptionChecker:
|
|
|
40
55
|
severity = severity_from_config(config, "catch-rethrow")
|
|
41
56
|
if severity is not None:
|
|
42
57
|
for node in find_nodes(tree.root_node, "catch_clause"):
|
|
58
|
+
if _is_in_bean_method(node):
|
|
59
|
+
continue
|
|
43
60
|
body = node.child_by_field_name("body")
|
|
44
61
|
if body is None:
|
|
45
62
|
continue
|
|
46
|
-
|
|
47
|
-
statements = [
|
|
48
|
-
c for c in body.children if c.type not in ("{", "}", "comment", "line_comment", "block_comment")
|
|
49
|
-
]
|
|
63
|
+
statements = [c for c in body.named_children if c.type not in ("line_comment", "block_comment")]
|
|
50
64
|
if len(statements) == 1 and statements[0].type == "throw_statement":
|
|
51
65
|
diagnostics.append(
|
|
52
66
|
Diagnostic(
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
|
-
from .base import Diagnostic, find_nodes, find_nodes_multi, has_ancestor, severity_from_config
|
|
7
|
+
from .base import Diagnostic, find_nodes, find_nodes_multi, has_ancestor, has_sibling_annotation, severity_from_config
|
|
8
8
|
|
|
9
9
|
_MESSAGES = {
|
|
10
10
|
"mutable-variable": "Avoid reassigning variables. Use final + functional transforms (map, flatMap, fold).",
|
|
@@ -45,8 +45,15 @@ class MutationChecker:
|
|
|
45
45
|
if ann_text in (b"Data", b"Setter"):
|
|
46
46
|
# Verify it's on a class declaration
|
|
47
47
|
if node.parent and node.parent.type == "modifiers":
|
|
48
|
-
|
|
48
|
+
modifiers = node.parent
|
|
49
|
+
grandparent = modifiers.parent
|
|
49
50
|
if grandparent and grandparent.type == "class_declaration":
|
|
51
|
+
if has_sibling_annotation(modifiers, b"ConfigurationProperties"):
|
|
52
|
+
message = (
|
|
53
|
+
"Use @ConstructorBinding instead of @Data/@Setter for @ConfigurationProperties classes."
|
|
54
|
+
)
|
|
55
|
+
else:
|
|
56
|
+
message = _MESSAGES["mutable-dto"]
|
|
50
57
|
diagnostics.append(
|
|
51
58
|
Diagnostic(
|
|
52
59
|
line=name_node.start_point[0],
|
|
@@ -55,7 +62,7 @@ class MutationChecker:
|
|
|
55
62
|
end_col=name_node.end_point[1],
|
|
56
63
|
severity=severity,
|
|
57
64
|
code="mutable-dto",
|
|
58
|
-
message=
|
|
65
|
+
message=message,
|
|
59
66
|
)
|
|
60
67
|
)
|
|
61
68
|
|
|
@@ -111,10 +118,19 @@ class MutationChecker:
|
|
|
111
118
|
if name_node.text not in _CHECK_METHODS:
|
|
112
119
|
continue
|
|
113
120
|
|
|
114
|
-
# Check if the body contains .get() on the same object
|
|
121
|
+
# Check if the if-body contains .get() on the same object (AST-based)
|
|
115
122
|
obj_name = obj_node.text
|
|
116
|
-
|
|
117
|
-
if
|
|
123
|
+
consequence = if_node.child_by_field_name("consequence")
|
|
124
|
+
if consequence is None or obj_name is None:
|
|
125
|
+
continue
|
|
126
|
+
found_get = False
|
|
127
|
+
for call in find_nodes(consequence, "method_invocation"):
|
|
128
|
+
call_name = call.child_by_field_name("name")
|
|
129
|
+
call_obj = call.child_by_field_name("object")
|
|
130
|
+
if call_name and call_name.text == b"get" and call_obj and call_obj.text == obj_name:
|
|
131
|
+
found_get = True
|
|
132
|
+
break
|
|
133
|
+
if found_get:
|
|
118
134
|
diagnostics.append(
|
|
119
135
|
Diagnostic(
|
|
120
136
|
line=if_node.start_point[0],
|
|
@@ -7,7 +7,7 @@ import sys
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
10
|
-
from .analyzers.base import Analyzer, Diagnostic, Severity, get_parser
|
|
10
|
+
from .analyzers.base import Analyzer, Diagnostic, Severity, get_parser, is_excluded
|
|
11
11
|
from .analyzers.exception_checker import ExceptionChecker
|
|
12
12
|
from .analyzers.mutation_checker import MutationChecker
|
|
13
13
|
from .analyzers.null_checker import NullChecker
|
|
@@ -107,7 +107,10 @@ def main() -> None:
|
|
|
107
107
|
config = load_config(files[0])
|
|
108
108
|
|
|
109
109
|
total_diags = 0
|
|
110
|
+
excludes: list[str] = config.get("excludes", [])
|
|
110
111
|
for path in files:
|
|
112
|
+
if excludes and is_excluded(path.as_posix(), excludes):
|
|
113
|
+
continue
|
|
111
114
|
diags = check_file(path, config)
|
|
112
115
|
for d in diags:
|
|
113
116
|
print(format_diagnostic(path, d))
|
|
@@ -16,7 +16,7 @@ import cattrs
|
|
|
16
16
|
from lsprotocol import types as lsp
|
|
17
17
|
from pygls.lsp.server import LanguageServer
|
|
18
18
|
|
|
19
|
-
from .analyzers.base import Analyzer, Severity, get_parser
|
|
19
|
+
from .analyzers.base import Analyzer, Severity, get_parser, is_excluded
|
|
20
20
|
from .analyzers.base import Diagnostic as LintDiagnostic
|
|
21
21
|
from .analyzers.exception_checker import ExceptionChecker
|
|
22
22
|
from .analyzers.mutation_checker import MutationChecker
|
|
@@ -46,6 +46,7 @@ class JavaFunctionalLspServer(LanguageServer):
|
|
|
46
46
|
self._parser = get_parser()
|
|
47
47
|
self._config: dict[str, Any] = {}
|
|
48
48
|
self._init_params: dict[str, Any] = {}
|
|
49
|
+
self._trees: dict[str, Any] = {} # URI -> last parsed tree for incremental parsing
|
|
49
50
|
self._proxy = JdtlsProxy(on_diagnostics=self._on_jdtls_diagnostics)
|
|
50
51
|
|
|
51
52
|
def _on_jdtls_diagnostics(self, uri: str, diagnostics: list[Any]) -> None:
|
|
@@ -93,10 +94,20 @@ def _to_lsp_diagnostic(diag: LintDiagnostic) -> lsp.Diagnostic:
|
|
|
93
94
|
)
|
|
94
95
|
|
|
95
96
|
|
|
96
|
-
def _analyze_document(source_text: str) -> list[lsp.Diagnostic]:
|
|
97
|
-
"""Run all custom analyzers on the given source text."""
|
|
97
|
+
def _analyze_document(source_text: str, uri: str = "") -> list[lsp.Diagnostic]:
|
|
98
|
+
"""Run all custom analyzers on the given source text. Uses incremental parsing when possible."""
|
|
99
|
+
# Check excludes before parsing
|
|
100
|
+
if uri:
|
|
101
|
+
excludes: list[str] = server._config.get("excludes", [])
|
|
102
|
+
if excludes:
|
|
103
|
+
path_str = _uri_to_path(uri)
|
|
104
|
+
if is_excluded(path_str, excludes):
|
|
105
|
+
return []
|
|
98
106
|
source_bytes = source_text.encode("utf-8")
|
|
99
|
-
|
|
107
|
+
old_tree = server._trees.get(uri) if uri else None
|
|
108
|
+
tree = server._parser.parse(source_bytes, old_tree) if old_tree else server._parser.parse(source_bytes)
|
|
109
|
+
if uri:
|
|
110
|
+
server._trees[uri] = tree
|
|
100
111
|
config = server._config
|
|
101
112
|
|
|
102
113
|
all_diagnostics: list[LintDiagnostic] = []
|
|
@@ -143,7 +154,7 @@ def _jdtls_raw_to_lsp_diagnostics(raw_diagnostics: list[Any]) -> list[lsp.Diagno
|
|
|
143
154
|
def _publish_diagnostics(uri: str) -> None:
|
|
144
155
|
"""Merge custom + jdtls diagnostics and publish to client."""
|
|
145
156
|
doc = server.workspace.get_text_document(uri)
|
|
146
|
-
custom_diags = _analyze_document(doc.source)
|
|
157
|
+
custom_diags = _analyze_document(doc.source, uri)
|
|
147
158
|
|
|
148
159
|
# Get cached jdtls diagnostics
|
|
149
160
|
jdtls_diags: list[lsp.Diagnostic] = []
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Tests for base.py tree traversal helper functions."""
|
|
2
|
+
|
|
3
|
+
from java_functional_lsp.analyzers.base import (
|
|
4
|
+
collect_nodes_by_type,
|
|
5
|
+
find_ancestor,
|
|
6
|
+
find_nodes,
|
|
7
|
+
find_nodes_multi,
|
|
8
|
+
get_parser,
|
|
9
|
+
has_ancestor,
|
|
10
|
+
has_sibling_annotation,
|
|
11
|
+
is_excluded,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _parse(source: str):
|
|
16
|
+
parser = get_parser()
|
|
17
|
+
return parser.parse(source.encode())
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestFindNodes:
|
|
21
|
+
def test_finds_null_literal(self):
|
|
22
|
+
tree = _parse("class T { void f() { return null; } }")
|
|
23
|
+
nodes = list(find_nodes(tree.root_node, "null_literal"))
|
|
24
|
+
assert len(nodes) == 1
|
|
25
|
+
assert nodes[0].text == b"null"
|
|
26
|
+
|
|
27
|
+
def test_finds_multiple_matches(self):
|
|
28
|
+
tree = _parse("class T { void f() { return null; } void g() { return null; } }")
|
|
29
|
+
nodes = list(find_nodes(tree.root_node, "null_literal"))
|
|
30
|
+
assert len(nodes) == 2
|
|
31
|
+
|
|
32
|
+
def test_finds_nested_nodes(self):
|
|
33
|
+
tree = _parse("""
|
|
34
|
+
class Outer {
|
|
35
|
+
class Inner {
|
|
36
|
+
void f() { return null; }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
""")
|
|
40
|
+
nodes = list(find_nodes(tree.root_node, "null_literal"))
|
|
41
|
+
assert len(nodes) == 1
|
|
42
|
+
|
|
43
|
+
def test_no_match_returns_empty(self):
|
|
44
|
+
tree = _parse("class T { void f() { return 42; } }")
|
|
45
|
+
nodes = list(find_nodes(tree.root_node, "null_literal"))
|
|
46
|
+
assert len(nodes) == 0
|
|
47
|
+
|
|
48
|
+
def test_empty_class(self):
|
|
49
|
+
tree = _parse("class T { }")
|
|
50
|
+
nodes = list(find_nodes(tree.root_node, "method_declaration"))
|
|
51
|
+
assert len(nodes) == 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TestFindNodesMulti:
|
|
55
|
+
def test_finds_multiple_types(self):
|
|
56
|
+
tree = _parse("""
|
|
57
|
+
class T {
|
|
58
|
+
void f() {
|
|
59
|
+
for (int i = 0; i < 10; i++) {}
|
|
60
|
+
while (true) {}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
""")
|
|
64
|
+
nodes = list(find_nodes_multi(tree.root_node, {"for_statement", "while_statement"}))
|
|
65
|
+
assert len(nodes) == 2
|
|
66
|
+
|
|
67
|
+
def test_empty_set_returns_nothing(self):
|
|
68
|
+
tree = _parse("class T { void f() { return null; } }")
|
|
69
|
+
nodes = list(find_nodes_multi(tree.root_node, set()))
|
|
70
|
+
assert len(nodes) == 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TestHasAncestor:
|
|
74
|
+
def test_has_method_ancestor(self):
|
|
75
|
+
tree = _parse("class T { void f() { return null; } }")
|
|
76
|
+
null_nodes = list(find_nodes(tree.root_node, "null_literal"))
|
|
77
|
+
assert len(null_nodes) == 1
|
|
78
|
+
assert has_ancestor(null_nodes[0], {"method_declaration"})
|
|
79
|
+
|
|
80
|
+
def test_no_matching_ancestor(self):
|
|
81
|
+
tree = _parse("class T { void f() { return null; } }")
|
|
82
|
+
null_nodes = list(find_nodes(tree.root_node, "null_literal"))
|
|
83
|
+
assert not has_ancestor(null_nodes[0], {"constructor_declaration"})
|
|
84
|
+
|
|
85
|
+
def test_multiple_ancestor_types(self):
|
|
86
|
+
tree = _parse("class T { void f() { return null; } }")
|
|
87
|
+
null_nodes = list(find_nodes(tree.root_node, "null_literal"))
|
|
88
|
+
assert has_ancestor(null_nodes[0], {"method_declaration", "constructor_declaration"})
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TestFindAncestor:
|
|
92
|
+
def test_finds_nearest_ancestor(self):
|
|
93
|
+
tree = _parse("class T { void f() { return null; } }")
|
|
94
|
+
null_nodes = list(find_nodes(tree.root_node, "null_literal"))
|
|
95
|
+
ancestor = find_ancestor(null_nodes[0], "method_declaration")
|
|
96
|
+
assert ancestor is not None
|
|
97
|
+
assert ancestor.type == "method_declaration"
|
|
98
|
+
|
|
99
|
+
def test_returns_none_when_not_found(self):
|
|
100
|
+
tree = _parse("class T { void f() { return null; } }")
|
|
101
|
+
null_nodes = list(find_nodes(tree.root_node, "null_literal"))
|
|
102
|
+
assert find_ancestor(null_nodes[0], "constructor_declaration") is None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class TestCollectNodesByType:
|
|
106
|
+
def test_collects_multiple_types_single_pass(self):
|
|
107
|
+
tree = _parse("""
|
|
108
|
+
class T {
|
|
109
|
+
void f() {
|
|
110
|
+
return null;
|
|
111
|
+
throw new Exception();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
""")
|
|
115
|
+
buckets = collect_nodes_by_type(tree.root_node, {"null_literal", "throw_statement", "method_declaration"})
|
|
116
|
+
assert len(buckets["null_literal"]) == 1
|
|
117
|
+
assert len(buckets["throw_statement"]) == 1
|
|
118
|
+
assert len(buckets["method_declaration"]) == 1
|
|
119
|
+
|
|
120
|
+
def test_empty_buckets_for_missing_types(self):
|
|
121
|
+
tree = _parse("class T { }")
|
|
122
|
+
buckets = collect_nodes_by_type(tree.root_node, {"null_literal", "throw_statement"})
|
|
123
|
+
assert len(buckets["null_literal"]) == 0
|
|
124
|
+
assert len(buckets["throw_statement"]) == 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class TestIsExcluded:
|
|
128
|
+
def test_matches_double_star_pattern(self):
|
|
129
|
+
assert is_excluded("/home/user/project/nlu-trs-client-shaded/Foo.java", ["**/nlu-trs-client-shaded/**"])
|
|
130
|
+
|
|
131
|
+
def test_no_match(self):
|
|
132
|
+
assert not is_excluded("/home/user/project/src/Foo.java", ["**/nlu-trs-client-shaded/**"])
|
|
133
|
+
|
|
134
|
+
def test_empty_patterns(self):
|
|
135
|
+
assert not is_excluded("/any/path/Foo.java", [])
|
|
136
|
+
|
|
137
|
+
def test_multiple_patterns(self):
|
|
138
|
+
assert is_excluded("/project/generated/Model.java", ["**/shaded/**", "**/generated/**"])
|
|
139
|
+
|
|
140
|
+
def test_windows_backslashes_normalized(self):
|
|
141
|
+
assert is_excluded("C:\\project\\generated\\Model.java", ["**/generated/**"])
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class TestHasSiblingAnnotation:
|
|
145
|
+
def test_finds_sibling_annotation(self):
|
|
146
|
+
tree = _parse("@ConfigurationProperties @Setter class Props { String name; }")
|
|
147
|
+
for node in find_nodes(tree.root_node, "marker_annotation"):
|
|
148
|
+
name = node.child_by_field_name("name")
|
|
149
|
+
if name and name.text == b"Setter" and node.parent:
|
|
150
|
+
assert has_sibling_annotation(node.parent, b"ConfigurationProperties")
|
|
151
|
+
|
|
152
|
+
def test_no_sibling(self):
|
|
153
|
+
tree = _parse("@Setter class Props { String name; }")
|
|
154
|
+
for node in find_nodes(tree.root_node, "marker_annotation"):
|
|
155
|
+
name = node.child_by_field_name("name")
|
|
156
|
+
if name and name.text == b"Setter" and node.parent:
|
|
157
|
+
assert not has_sibling_annotation(node.parent, b"ConfigurationProperties")
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Tests for exception handling rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from java_functional_lsp.analyzers.exception_checker import ExceptionChecker
|
|
6
|
+
from tests.conftest import parse_and_analyze
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestThrowStatement:
|
|
10
|
+
def test_detects_throw(self) -> None:
|
|
11
|
+
source = b"class T { void f() { throw new RuntimeException(); } }"
|
|
12
|
+
diags = parse_and_analyze(ExceptionChecker(), source)
|
|
13
|
+
codes = [d.code for d in diags]
|
|
14
|
+
assert "throw-statement" in codes
|
|
15
|
+
|
|
16
|
+
def test_ignores_no_throw(self) -> None:
|
|
17
|
+
source = b"class T { void f() { System.out.println(); } }"
|
|
18
|
+
diags = parse_and_analyze(ExceptionChecker(), source)
|
|
19
|
+
assert not any(d.code == "throw-statement" for d in diags)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestCatchRethrow:
|
|
23
|
+
def test_detects_catch_rethrow(self) -> None:
|
|
24
|
+
source = b"""
|
|
25
|
+
class T {
|
|
26
|
+
void f() {
|
|
27
|
+
try { foo(); }
|
|
28
|
+
catch (Exception e) { throw new RuntimeException(e); }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
"""
|
|
32
|
+
diags = parse_and_analyze(ExceptionChecker(), source)
|
|
33
|
+
codes = [d.code for d in diags]
|
|
34
|
+
assert "catch-rethrow" in codes
|
|
35
|
+
|
|
36
|
+
def test_catch_with_comment_and_throw_still_flagged(self) -> None:
|
|
37
|
+
"""A catch with only a comment + throw is still a rethrow — comments are ignored."""
|
|
38
|
+
source = b"""
|
|
39
|
+
class T {
|
|
40
|
+
void f() {
|
|
41
|
+
try { foo(); }
|
|
42
|
+
catch (Exception e) {
|
|
43
|
+
// log the error
|
|
44
|
+
throw new RuntimeException(e);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
"""
|
|
49
|
+
diags = parse_and_analyze(ExceptionChecker(), source)
|
|
50
|
+
assert any(d.code == "catch-rethrow" for d in diags)
|
|
51
|
+
|
|
52
|
+
def test_ignores_catch_with_logic(self) -> None:
|
|
53
|
+
source = b"""
|
|
54
|
+
class T {
|
|
55
|
+
void f() {
|
|
56
|
+
try { foo(); }
|
|
57
|
+
catch (Exception e) { log.error(e); return; }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
"""
|
|
61
|
+
diags = parse_and_analyze(ExceptionChecker(), source)
|
|
62
|
+
assert not any(d.code == "catch-rethrow" for d in diags)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TestBeanSuppression:
|
|
66
|
+
def test_ignores_throw_in_bean_method(self) -> None:
|
|
67
|
+
source = b"""
|
|
68
|
+
class Config {
|
|
69
|
+
@Bean
|
|
70
|
+
DataSource dataSource() {
|
|
71
|
+
if (url == null) {
|
|
72
|
+
throw new IllegalStateException("url required");
|
|
73
|
+
}
|
|
74
|
+
return new DataSource(url);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
"""
|
|
78
|
+
diags = parse_and_analyze(ExceptionChecker(), source)
|
|
79
|
+
assert not any(d.code == "throw-statement" for d in diags)
|
|
80
|
+
|
|
81
|
+
def test_flags_throw_in_regular_method(self) -> None:
|
|
82
|
+
source = b"""
|
|
83
|
+
class Service {
|
|
84
|
+
void process() {
|
|
85
|
+
throw new RuntimeException("error");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
"""
|
|
89
|
+
diags = parse_and_analyze(ExceptionChecker(), source)
|
|
90
|
+
assert any(d.code == "throw-statement" for d in diags)
|
|
91
|
+
|
|
92
|
+
def test_ignores_catch_rethrow_in_bean_method(self) -> None:
|
|
93
|
+
source = b"""
|
|
94
|
+
class Config {
|
|
95
|
+
@Bean
|
|
96
|
+
DataSource dataSource() {
|
|
97
|
+
try { return connect(); }
|
|
98
|
+
catch (Exception e) { throw new RuntimeException(e); }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
"""
|
|
102
|
+
diags = parse_and_analyze(ExceptionChecker(), source)
|
|
103
|
+
assert not any(d.code == "catch-rethrow" for d in diags)
|
|
104
|
+
assert not any(d.code == "throw-statement" for d in diags)
|
|
@@ -58,6 +58,20 @@ class TestMutableDto:
|
|
|
58
58
|
diags = parse_and_analyze(MutationChecker(), source)
|
|
59
59
|
assert not any(d.code == "mutable-dto" for d in diags)
|
|
60
60
|
|
|
61
|
+
def test_config_properties_suggests_constructor_binding(self) -> None:
|
|
62
|
+
source = b"@ConfigurationProperties @Setter class Props { String name; }"
|
|
63
|
+
diags = parse_and_analyze(MutationChecker(), source)
|
|
64
|
+
dto_diags = [d for d in diags if d.code == "mutable-dto"]
|
|
65
|
+
assert len(dto_diags) == 1
|
|
66
|
+
assert "@ConstructorBinding" in dto_diags[0].message
|
|
67
|
+
|
|
68
|
+
def test_regular_setter_suggests_value(self) -> None:
|
|
69
|
+
source = b"@Setter class Foo { String name; }"
|
|
70
|
+
diags = parse_and_analyze(MutationChecker(), source)
|
|
71
|
+
dto_diags = [d for d in diags if d.code == "mutable-dto"]
|
|
72
|
+
assert len(dto_diags) == 1
|
|
73
|
+
assert "@Value" in dto_diags[0].message
|
|
74
|
+
|
|
61
75
|
|
|
62
76
|
class TestImperativeOptionUnwrap:
|
|
63
77
|
def test_detects_is_defined_get(self) -> None:
|
|
@@ -94,3 +108,41 @@ class TestImperativeOptionUnwrap:
|
|
|
94
108
|
"""
|
|
95
109
|
diags = parse_and_analyze(MutationChecker(), source)
|
|
96
110
|
assert not any(d.code == "imperative-option-unwrap" for d in diags)
|
|
111
|
+
|
|
112
|
+
def test_ignores_unrelated_get(self) -> None:
|
|
113
|
+
"""Different object's .get() should not trigger the rule."""
|
|
114
|
+
source = b"""
|
|
115
|
+
class T {
|
|
116
|
+
void f() {
|
|
117
|
+
if (opt.isDefined()) { other.get(); }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
"""
|
|
121
|
+
diags = parse_and_analyze(MutationChecker(), source)
|
|
122
|
+
assert not any(d.code == "imperative-option-unwrap" for d in diags)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class TestConstructorAssignment:
|
|
126
|
+
def test_ignores_this_field_in_constructor(self) -> None:
|
|
127
|
+
source = b"class T { final int x; T(int x) { this.x = x; } }"
|
|
128
|
+
diags = parse_and_analyze(MutationChecker(), source)
|
|
129
|
+
assert not any(d.code == "mutable-variable" for d in diags)
|
|
130
|
+
|
|
131
|
+
def test_ignores_computed_field_in_constructor(self) -> None:
|
|
132
|
+
"""this.x = computeValue() in constructor should not be flagged."""
|
|
133
|
+
source = b"class T { final int x; T() { this.x = compute(); } }"
|
|
134
|
+
diags = parse_and_analyze(MutationChecker(), source)
|
|
135
|
+
assert not any(d.code == "mutable-variable" for d in diags)
|
|
136
|
+
|
|
137
|
+
def test_detects_other_object_field_in_constructor(self) -> None:
|
|
138
|
+
"""other.field = x in a constructor IS a mutation and should be flagged."""
|
|
139
|
+
source = b"class T { T() { other.field = 42; } }"
|
|
140
|
+
diags = parse_and_analyze(MutationChecker(), source)
|
|
141
|
+
assert any(d.code == "mutable-variable" for d in diags)
|
|
142
|
+
|
|
143
|
+
def test_detects_reassignment_in_method(self) -> None:
|
|
144
|
+
"""this.x = ... in a regular method IS a mutation."""
|
|
145
|
+
source = b"class T { int x; void f() { this.x = 42; } }"
|
|
146
|
+
diags = parse_and_analyze(MutationChecker(), source)
|
|
147
|
+
codes = [d.code for d in diags]
|
|
148
|
+
assert "mutable-variable" in codes
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
"""Base analyzer class and diagnostic types."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from collections.abc import Generator
|
|
6
|
-
from dataclasses import dataclass
|
|
7
|
-
from enum import IntEnum
|
|
8
|
-
from typing import Any, Protocol
|
|
9
|
-
|
|
10
|
-
import tree_sitter_java as tsjava
|
|
11
|
-
from tree_sitter import Language, Node, Parser
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
class Severity(IntEnum):
|
|
15
|
-
ERROR = 1
|
|
16
|
-
WARNING = 2
|
|
17
|
-
INFO = 3
|
|
18
|
-
HINT = 4
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@dataclass(frozen=True)
|
|
22
|
-
class Diagnostic:
|
|
23
|
-
line: int # 0-based
|
|
24
|
-
col: int
|
|
25
|
-
end_line: int
|
|
26
|
-
end_col: int
|
|
27
|
-
severity: Severity
|
|
28
|
-
code: str # rule ID
|
|
29
|
-
message: str
|
|
30
|
-
source: str = "java-functional-lsp"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
class Analyzer(Protocol):
|
|
34
|
-
"""Protocol for all analyzers."""
|
|
35
|
-
|
|
36
|
-
def analyze(self, tree: Any, source: bytes, config: dict[str, Any]) -> list[Diagnostic]:
|
|
37
|
-
"""Analyze a parsed tree and return diagnostics."""
|
|
38
|
-
...
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
_parser: Parser | None = None
|
|
42
|
-
_language: Language | None = None
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def get_parser() -> Parser:
|
|
46
|
-
"""Get or create a reusable tree-sitter Java parser."""
|
|
47
|
-
global _parser, _language
|
|
48
|
-
if _parser is None:
|
|
49
|
-
_language = Language(tsjava.language())
|
|
50
|
-
_parser = Parser(_language)
|
|
51
|
-
return _parser
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def get_language() -> Language:
|
|
55
|
-
"""Get the Java language for queries."""
|
|
56
|
-
global _language
|
|
57
|
-
if _language is None:
|
|
58
|
-
get_parser()
|
|
59
|
-
assert _language is not None
|
|
60
|
-
return _language
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def find_nodes(node: Node, type_name: str) -> Generator[Node, None, None]:
|
|
64
|
-
"""Recursively find all descendant nodes of a given type."""
|
|
65
|
-
if node.type == type_name:
|
|
66
|
-
yield node
|
|
67
|
-
for child in node.children:
|
|
68
|
-
yield from find_nodes(child, type_name)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def find_nodes_multi(node: Node, type_names: set[str]) -> Generator[Node, None, None]:
|
|
72
|
-
"""Recursively find all descendant nodes matching any of the given types."""
|
|
73
|
-
if node.type in type_names:
|
|
74
|
-
yield node
|
|
75
|
-
for child in node.children:
|
|
76
|
-
yield from find_nodes_multi(child, type_names)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def find_ancestor(node: Node, type_name: str) -> Node | None:
|
|
80
|
-
"""Walk up the tree to find the nearest ancestor of a given type."""
|
|
81
|
-
parent = node.parent
|
|
82
|
-
while parent:
|
|
83
|
-
if parent.type == type_name:
|
|
84
|
-
return parent
|
|
85
|
-
parent = parent.parent
|
|
86
|
-
return None
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def has_ancestor(node: Node, type_names: set[str]) -> bool:
|
|
90
|
-
"""Check if any ancestor matches one of the given types."""
|
|
91
|
-
parent = node.parent
|
|
92
|
-
while parent:
|
|
93
|
-
if parent.type in type_names:
|
|
94
|
-
return True
|
|
95
|
-
parent = parent.parent
|
|
96
|
-
return False
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def severity_from_config(config: dict[str, Any], rule_id: str, default: Severity = Severity.WARNING) -> Severity | None:
|
|
100
|
-
"""Get severity for a rule from config. Returns None if rule is disabled."""
|
|
101
|
-
rules: dict[str, str] = config.get("rules", {})
|
|
102
|
-
level = rules.get(rule_id)
|
|
103
|
-
if level is None:
|
|
104
|
-
return default
|
|
105
|
-
if level == "off":
|
|
106
|
-
return None
|
|
107
|
-
return {
|
|
108
|
-
"error": Severity.ERROR,
|
|
109
|
-
"warning": Severity.WARNING,
|
|
110
|
-
"info": Severity.INFO,
|
|
111
|
-
"hint": Severity.HINT,
|
|
112
|
-
}.get(level, default)
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
"""Tests for exception handling rules."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from java_functional_lsp.analyzers.exception_checker import ExceptionChecker
|
|
6
|
-
from tests.conftest import parse_and_analyze
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
class TestThrowStatement:
|
|
10
|
-
def test_detects_throw(self) -> None:
|
|
11
|
-
source = b"class T { void f() { throw new RuntimeException(); } }"
|
|
12
|
-
diags = parse_and_analyze(ExceptionChecker(), source)
|
|
13
|
-
codes = [d.code for d in diags]
|
|
14
|
-
assert "throw-statement" in codes
|
|
15
|
-
|
|
16
|
-
def test_ignores_no_throw(self) -> None:
|
|
17
|
-
source = b"class T { void f() { System.out.println(); } }"
|
|
18
|
-
diags = parse_and_analyze(ExceptionChecker(), source)
|
|
19
|
-
assert not any(d.code == "throw-statement" for d in diags)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
class TestCatchRethrow:
|
|
23
|
-
def test_detects_catch_rethrow(self) -> None:
|
|
24
|
-
source = b"""
|
|
25
|
-
class T {
|
|
26
|
-
void f() {
|
|
27
|
-
try { foo(); }
|
|
28
|
-
catch (Exception e) { throw new RuntimeException(e); }
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
"""
|
|
32
|
-
diags = parse_and_analyze(ExceptionChecker(), source)
|
|
33
|
-
codes = [d.code for d in diags]
|
|
34
|
-
assert "catch-rethrow" in codes
|
|
35
|
-
|
|
36
|
-
def test_ignores_catch_with_logic(self) -> None:
|
|
37
|
-
source = b"""
|
|
38
|
-
class T {
|
|
39
|
-
void f() {
|
|
40
|
-
try { foo(); }
|
|
41
|
-
catch (Exception e) { log.error(e); return; }
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
"""
|
|
45
|
-
diags = parse_and_analyze(ExceptionChecker(), source)
|
|
46
|
-
assert not any(d.code == "catch-rethrow" for d in diags)
|
|
File without changes
|
{java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.github/ISSUE_TEMPLATE/bug-report.md
RENAMED
|
File without changes
|
{java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.github/ISSUE_TEMPLATE/feature-request.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.github/workflows/release-drafter.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.github/workflows/update-homebrew.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/editors/intellij/lsp4ij-template.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|