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.
Files changed (57) hide show
  1. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.claude-plugin/plugin.json +1 -1
  2. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/PKG-INFO +28 -5
  3. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/README.md +27 -4
  4. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/SKILL.md +5 -1
  5. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/editors/vscode/package.json +1 -1
  6. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/pyproject.toml +1 -1
  7. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/src/java_functional_lsp/__init__.py +1 -1
  8. java_functional_lsp-0.4.0/src/java_functional_lsp/analyzers/base.py +173 -0
  9. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/src/java_functional_lsp/analyzers/exception_checker.py +19 -5
  10. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/src/java_functional_lsp/analyzers/mutation_checker.py +22 -6
  11. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/src/java_functional_lsp/cli.py +4 -1
  12. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/src/java_functional_lsp/server.py +16 -5
  13. java_functional_lsp-0.4.0/tests/test_base.py +157 -0
  14. java_functional_lsp-0.4.0/tests/test_exception_checker.py +104 -0
  15. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/tests/test_mutation_checker.py +52 -0
  16. java_functional_lsp-0.3.1/src/java_functional_lsp/analyzers/base.py +0 -112
  17. java_functional_lsp-0.3.1/tests/test_exception_checker.py +0 -46
  18. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.github/CODEOWNERS +0 -0
  19. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
  20. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.github/ISSUE_TEMPLATE/feature-request.md +0 -0
  21. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  22. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.github/SECURITY.md +0 -0
  23. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.github/dependabot.yml +0 -0
  24. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.github/release-drafter.yml +0 -0
  25. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.github/workflows/publish.yml +0 -0
  26. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.github/workflows/release-drafter.yml +0 -0
  27. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.github/workflows/stale.yml +0 -0
  28. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.github/workflows/test.yml +0 -0
  29. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.github/workflows/update-homebrew.yml +0 -0
  30. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.github/workflows/vscode-ext.yml +0 -0
  31. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/.gitignore +0 -0
  32. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/CONTRIBUTING.md +0 -0
  33. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/LICENSE +0 -0
  34. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/commands/lint-java.md +0 -0
  35. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/editors/intellij/README.md +0 -0
  36. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/editors/intellij/lsp4ij-template.json +0 -0
  37. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/editors/vscode/.vscodeignore +0 -0
  38. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/editors/vscode/README.md +0 -0
  39. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/editors/vscode/package-lock.json +0 -0
  40. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/editors/vscode/src/extension.ts +0 -0
  41. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/editors/vscode/tsconfig.json +0 -0
  42. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/hooks/hooks.json +0 -0
  43. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/hooks/java_linter_reminder.py +0 -0
  44. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/scripts/ensure-lsp.sh +0 -0
  45. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/scripts/generate-formula.py +0 -0
  46. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/src/java_functional_lsp/analyzers/__init__.py +0 -0
  47. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/src/java_functional_lsp/analyzers/null_checker.py +0 -0
  48. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/src/java_functional_lsp/analyzers/spring_checker.py +0 -0
  49. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/src/java_functional_lsp/proxy.py +0 -0
  50. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/tests/__init__.py +0 -0
  51. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/tests/conftest.py +0 -0
  52. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/tests/test_cli.py +0 -0
  53. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/tests/test_config.py +0 -0
  54. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/tests/test_null_checker.py +0 -0
  55. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/tests/test_proxy.py +0 -0
  56. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/tests/test_spring_checker.py +0 -0
  57. {java_functional_lsp-0.3.1 → java_functional_lsp-0.4.0}/uv.lock +0 -0
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "java-functional-lsp",
3
3
  "description": "Java LSP with functional programming rules enforcement — null safety, immutability, no exceptions, Spring best practices. Wraps jdtls for full Java language support.",
4
- "version": "0.3.1"
4
+ "version": "0.4.0"
5
5
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: java-functional-lsp
3
- Version: 0.3.1
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)) or build it:
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 launches the LSP server automatically for `.java` files. Configure the binary path in settings if needed (`javaFunctionalLsp.serverPath`). See [editors/vscode/README.md](editors/vscode/README.md) for details.
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
- Severity levels: `error`, `warning`, `info`, `hint`, `off`.
142
- All rules default to `warning` when not configured.
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)) or build it:
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 launches the LSP server automatically for `.java` files. Configure the binary path in settings if needed (`javaFunctionalLsp.serverPath`). See [editors/vscode/README.md](editors/vscode/README.md) for details.
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
- Severity levels: `error`, `warning`, `info`, `hint`, `off`.
114
- All rules default to `warning` when not configured.
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
- Severity levels: `error`, `warning` (default), `info`, `hint`, `off`.
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.3.1",
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.3.1"
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" }
@@ -1,3 +1,3 @@
1
1
  """java-functional-lsp: A Java LSP server enforcing functional programming best practices."""
2
2
 
3
- __version__ = "0.3.1"
3
+ __version__ = "0.4.0"
@@ -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
- # Check if the block has exactly one statement and it's a throw
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
- grandparent = node.parent.parent
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=_MESSAGES["mutable-dto"],
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
- body_text = if_node.text
117
- if obj_name is not None and body_text is not None and obj_name + b".get()" in body_text:
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
- tree = server._parser.parse(source_bytes)
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)