java-functional-lsp 0.3.0__tar.gz → 0.3.2__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 (55) hide show
  1. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/.claude-plugin/plugin.json +1 -1
  2. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/.github/workflows/vscode-ext.yml +1 -1
  3. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/PKG-INFO +20 -3
  4. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/README.md +19 -2
  5. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/editors/vscode/package.json +5 -3
  6. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/pyproject.toml +1 -1
  7. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/src/java_functional_lsp/__init__.py +1 -1
  8. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/src/java_functional_lsp/analyzers/base.py +52 -15
  9. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/src/java_functional_lsp/analyzers/exception_checker.py +2 -4
  10. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/src/java_functional_lsp/analyzers/mutation_checker.py +19 -3
  11. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/src/java_functional_lsp/server.py +8 -4
  12. java_functional_lsp-0.3.2/tests/test_base.py +122 -0
  13. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/tests/test_exception_checker.py +16 -0
  14. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/tests/test_mutation_checker.py +38 -0
  15. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/.github/CODEOWNERS +0 -0
  16. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
  17. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/.github/ISSUE_TEMPLATE/feature-request.md +0 -0
  18. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  19. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/.github/SECURITY.md +0 -0
  20. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/.github/dependabot.yml +0 -0
  21. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/.github/release-drafter.yml +0 -0
  22. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/.github/workflows/publish.yml +0 -0
  23. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/.github/workflows/release-drafter.yml +0 -0
  24. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/.github/workflows/stale.yml +0 -0
  25. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/.github/workflows/test.yml +0 -0
  26. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/.github/workflows/update-homebrew.yml +0 -0
  27. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/.gitignore +0 -0
  28. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/CONTRIBUTING.md +0 -0
  29. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/LICENSE +0 -0
  30. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/SKILL.md +0 -0
  31. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/commands/lint-java.md +0 -0
  32. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/editors/intellij/README.md +0 -0
  33. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/editors/intellij/lsp4ij-template.json +0 -0
  34. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/editors/vscode/.vscodeignore +0 -0
  35. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/editors/vscode/README.md +0 -0
  36. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/editors/vscode/package-lock.json +0 -0
  37. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/editors/vscode/src/extension.ts +0 -0
  38. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/editors/vscode/tsconfig.json +0 -0
  39. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/hooks/hooks.json +0 -0
  40. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/hooks/java_linter_reminder.py +0 -0
  41. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/scripts/ensure-lsp.sh +0 -0
  42. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/scripts/generate-formula.py +0 -0
  43. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/src/java_functional_lsp/analyzers/__init__.py +0 -0
  44. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/src/java_functional_lsp/analyzers/null_checker.py +0 -0
  45. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/src/java_functional_lsp/analyzers/spring_checker.py +0 -0
  46. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/src/java_functional_lsp/cli.py +0 -0
  47. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/src/java_functional_lsp/proxy.py +0 -0
  48. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/tests/__init__.py +0 -0
  49. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/tests/conftest.py +0 -0
  50. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/tests/test_cli.py +0 -0
  51. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/tests/test_config.py +0 -0
  52. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/tests/test_null_checker.py +0 -0
  53. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/tests/test_proxy.py +0 -0
  54. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/tests/test_spring_checker.py +0 -0
  55. {java_functional_lsp-0.3.0 → java_functional_lsp-0.3.2}/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.0"
4
+ "version": "0.3.2"
5
5
  }
@@ -32,7 +32,7 @@ jobs:
32
32
  run: npm run compile
33
33
 
34
34
  - name: Package VSIX
35
- run: npx vsce package --no-dependencies
35
+ run: npx vsce package
36
36
 
37
37
  - name: Upload VSIX artifact
38
38
  uses: actions/upload-artifact@v4
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: java-functional-lsp
3
- Version: 0.3.0
3
+ Version: 0.3.2
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
 
@@ -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
 
@@ -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.0",
5
+ "version": "0.3.2",
6
6
  "publisher": "aviadshiber",
7
7
  "license": "MIT",
8
8
  "engines": {
@@ -50,8 +50,9 @@
50
50
  "url": "https://github.com/aviadshiber/java-functional-lsp/issues"
51
51
  },
52
52
  "scripts": {
53
- "compile": "tsc -p ./",
54
- "watch": "tsc -watch -p ./",
53
+ "compile": "npm run esbuild",
54
+ "esbuild": "esbuild src/extension.ts --bundle --outfile=out/extension.js --external:vscode --format=cjs --platform=node",
55
+ "watch": "npm run esbuild -- --watch",
55
56
  "package": "vsce package"
56
57
  },
57
58
  "dependencies": {
@@ -61,6 +62,7 @@
61
62
  "@types/node": "^18.19.130",
62
63
  "@types/vscode": "^1.75.0",
63
64
  "@vscode/vsce": "^3.0.0",
65
+ "esbuild": "^0.27.4",
64
66
  "typescript": "^5.0.0"
65
67
  }
66
68
  }
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "java-functional-lsp"
7
- version = "0.3.0"
7
+ version = "0.3.2"
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.0"
3
+ __version__ = "0.3.2"
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  from collections.abc import Generator
6
6
  from dataclasses import dataclass
7
7
  from enum import IntEnum
8
- from typing import Any, Protocol
8
+ from typing import Any, Protocol, cast
9
9
 
10
10
  import tree_sitter_java as tsjava
11
11
  from tree_sitter import Language, Node, Parser
@@ -60,20 +60,57 @@ def get_language() -> Language:
60
60
  return _language
61
61
 
62
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)
63
+ def find_nodes(root: Node, type_name: str) -> Generator[Node, None, None]:
64
+ """Find all descendant nodes of a given type using TreeCursor for performance."""
65
+ cursor = root.walk()
66
+ visited_children = False
67
+ while True:
68
+ if not visited_children:
69
+ current = cast(Node, cursor.node)
70
+ if current.type == type_name:
71
+ yield current
72
+ if not cursor.goto_first_child():
73
+ visited_children = True
74
+ elif cursor.goto_next_sibling():
75
+ visited_children = False
76
+ elif not cursor.goto_parent():
77
+ break
78
+
79
+
80
+ def find_nodes_multi(root: Node, type_names: set[str]) -> Generator[Node, None, None]:
81
+ """Find all descendant nodes matching any of the given types using TreeCursor."""
82
+ cursor = root.walk()
83
+ visited_children = False
84
+ while True:
85
+ if not visited_children:
86
+ current = cast(Node, cursor.node)
87
+ if current.type in type_names:
88
+ yield current
89
+ if not cursor.goto_first_child():
90
+ visited_children = True
91
+ elif cursor.goto_next_sibling():
92
+ visited_children = False
93
+ elif not cursor.goto_parent():
94
+ break
95
+
96
+
97
+ def collect_nodes_by_type(root: Node, type_names: set[str]) -> dict[str, list[Node]]:
98
+ """Walk tree once, bucket nodes by type. Avoids multiple full traversals."""
99
+ buckets: dict[str, list[Node]] = {t: [] for t in type_names}
100
+ cursor = root.walk()
101
+ visited_children = False
102
+ while True:
103
+ if not visited_children:
104
+ current = cast(Node, cursor.node)
105
+ if current.type in buckets:
106
+ buckets[current.type].append(current)
107
+ if not cursor.goto_first_child():
108
+ visited_children = True
109
+ elif cursor.goto_next_sibling():
110
+ visited_children = False
111
+ elif not cursor.goto_parent():
112
+ break
113
+ return buckets
77
114
 
78
115
 
79
116
  def find_ancestor(node: Node, type_name: str) -> Node | None:
@@ -43,10 +43,8 @@ class ExceptionChecker:
43
43
  body = node.child_by_field_name("body")
44
44
  if body is None:
45
45
  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
- ]
46
+ # Check if the block has exactly one named statement and it's a throw
47
+ statements = [c for c in body.named_children if c.type not in ("line_comment", "block_comment")]
50
48
  if len(statements) == 1 and statements[0].type == "throw_statement":
51
49
  diagnostics.append(
52
50
  Diagnostic(
@@ -111,10 +111,19 @@ class MutationChecker:
111
111
  if name_node.text not in _CHECK_METHODS:
112
112
  continue
113
113
 
114
- # Check if the body contains .get() on the same object
114
+ # Check if the if-body contains .get() on the same object (AST-based)
115
115
  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:
116
+ consequence = if_node.child_by_field_name("consequence")
117
+ if consequence is None or obj_name is None:
118
+ continue
119
+ found_get = False
120
+ for call in find_nodes(consequence, "method_invocation"):
121
+ call_name = call.child_by_field_name("name")
122
+ call_obj = call.child_by_field_name("object")
123
+ if call_name and call_name.text == b"get" and call_obj and call_obj.text == obj_name:
124
+ found_get = True
125
+ break
126
+ if found_get:
118
127
  diagnostics.append(
119
128
  Diagnostic(
120
129
  line=if_node.start_point[0],
@@ -139,6 +148,13 @@ class MutationChecker:
139
148
  if not has_ancestor(node, _METHOD_TYPES):
140
149
  continue
141
150
 
151
+ # Skip this.field = ... in constructors (field initialization, not reassignment)
152
+ left = node.child_by_field_name("left")
153
+ if left and left.type == "field_access" and has_ancestor(node, {"constructor_declaration"}):
154
+ receiver = left.child_by_field_name("object")
155
+ if receiver and receiver.type == "this":
156
+ continue
157
+
142
158
  diagnostics.append(
143
159
  Diagnostic(
144
160
  line=node.start_point[0],
@@ -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,13 @@ 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."""
98
99
  source_bytes = source_text.encode("utf-8")
99
- tree = server._parser.parse(source_bytes)
100
+ old_tree = server._trees.get(uri) if uri else None
101
+ tree = server._parser.parse(source_bytes, old_tree) if old_tree else server._parser.parse(source_bytes)
102
+ if uri:
103
+ server._trees[uri] = tree
100
104
  config = server._config
101
105
 
102
106
  all_diagnostics: list[LintDiagnostic] = []
@@ -143,7 +147,7 @@ def _jdtls_raw_to_lsp_diagnostics(raw_diagnostics: list[Any]) -> list[lsp.Diagno
143
147
  def _publish_diagnostics(uri: str) -> None:
144
148
  """Merge custom + jdtls diagnostics and publish to client."""
145
149
  doc = server.workspace.get_text_document(uri)
146
- custom_diags = _analyze_document(doc.source)
150
+ custom_diags = _analyze_document(doc.source, uri)
147
151
 
148
152
  # Get cached jdtls diagnostics
149
153
  jdtls_diags: list[lsp.Diagnostic] = []
@@ -0,0 +1,122 @@
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
+ )
11
+
12
+
13
+ def _parse(source: str):
14
+ parser = get_parser()
15
+ return parser.parse(source.encode())
16
+
17
+
18
+ class TestFindNodes:
19
+ def test_finds_null_literal(self):
20
+ tree = _parse("class T { void f() { return null; } }")
21
+ nodes = list(find_nodes(tree.root_node, "null_literal"))
22
+ assert len(nodes) == 1
23
+ assert nodes[0].text == b"null"
24
+
25
+ def test_finds_multiple_matches(self):
26
+ tree = _parse("class T { void f() { return null; } void g() { return null; } }")
27
+ nodes = list(find_nodes(tree.root_node, "null_literal"))
28
+ assert len(nodes) == 2
29
+
30
+ def test_finds_nested_nodes(self):
31
+ tree = _parse("""
32
+ class Outer {
33
+ class Inner {
34
+ void f() { return null; }
35
+ }
36
+ }
37
+ """)
38
+ nodes = list(find_nodes(tree.root_node, "null_literal"))
39
+ assert len(nodes) == 1
40
+
41
+ def test_no_match_returns_empty(self):
42
+ tree = _parse("class T { void f() { return 42; } }")
43
+ nodes = list(find_nodes(tree.root_node, "null_literal"))
44
+ assert len(nodes) == 0
45
+
46
+ def test_empty_class(self):
47
+ tree = _parse("class T { }")
48
+ nodes = list(find_nodes(tree.root_node, "method_declaration"))
49
+ assert len(nodes) == 0
50
+
51
+
52
+ class TestFindNodesMulti:
53
+ def test_finds_multiple_types(self):
54
+ tree = _parse("""
55
+ class T {
56
+ void f() {
57
+ for (int i = 0; i < 10; i++) {}
58
+ while (true) {}
59
+ }
60
+ }
61
+ """)
62
+ nodes = list(find_nodes_multi(tree.root_node, {"for_statement", "while_statement"}))
63
+ assert len(nodes) == 2
64
+
65
+ def test_empty_set_returns_nothing(self):
66
+ tree = _parse("class T { void f() { return null; } }")
67
+ nodes = list(find_nodes_multi(tree.root_node, set()))
68
+ assert len(nodes) == 0
69
+
70
+
71
+ class TestHasAncestor:
72
+ def test_has_method_ancestor(self):
73
+ tree = _parse("class T { void f() { return null; } }")
74
+ null_nodes = list(find_nodes(tree.root_node, "null_literal"))
75
+ assert len(null_nodes) == 1
76
+ assert has_ancestor(null_nodes[0], {"method_declaration"})
77
+
78
+ def test_no_matching_ancestor(self):
79
+ tree = _parse("class T { void f() { return null; } }")
80
+ null_nodes = list(find_nodes(tree.root_node, "null_literal"))
81
+ assert not has_ancestor(null_nodes[0], {"constructor_declaration"})
82
+
83
+ def test_multiple_ancestor_types(self):
84
+ tree = _parse("class T { void f() { return null; } }")
85
+ null_nodes = list(find_nodes(tree.root_node, "null_literal"))
86
+ assert has_ancestor(null_nodes[0], {"method_declaration", "constructor_declaration"})
87
+
88
+
89
+ class TestFindAncestor:
90
+ def test_finds_nearest_ancestor(self):
91
+ tree = _parse("class T { void f() { return null; } }")
92
+ null_nodes = list(find_nodes(tree.root_node, "null_literal"))
93
+ ancestor = find_ancestor(null_nodes[0], "method_declaration")
94
+ assert ancestor is not None
95
+ assert ancestor.type == "method_declaration"
96
+
97
+ def test_returns_none_when_not_found(self):
98
+ tree = _parse("class T { void f() { return null; } }")
99
+ null_nodes = list(find_nodes(tree.root_node, "null_literal"))
100
+ assert find_ancestor(null_nodes[0], "constructor_declaration") is None
101
+
102
+
103
+ class TestCollectNodesByType:
104
+ def test_collects_multiple_types_single_pass(self):
105
+ tree = _parse("""
106
+ class T {
107
+ void f() {
108
+ return null;
109
+ throw new Exception();
110
+ }
111
+ }
112
+ """)
113
+ buckets = collect_nodes_by_type(tree.root_node, {"null_literal", "throw_statement", "method_declaration"})
114
+ assert len(buckets["null_literal"]) == 1
115
+ assert len(buckets["throw_statement"]) == 1
116
+ assert len(buckets["method_declaration"]) == 1
117
+
118
+ def test_empty_buckets_for_missing_types(self):
119
+ tree = _parse("class T { }")
120
+ buckets = collect_nodes_by_type(tree.root_node, {"null_literal", "throw_statement"})
121
+ assert len(buckets["null_literal"]) == 0
122
+ assert len(buckets["throw_statement"]) == 0
@@ -33,6 +33,22 @@ class TestCatchRethrow:
33
33
  codes = [d.code for d in diags]
34
34
  assert "catch-rethrow" in codes
35
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
+
36
52
  def test_ignores_catch_with_logic(self) -> None:
37
53
  source = b"""
38
54
  class T {
@@ -94,3 +94,41 @@ class TestImperativeOptionUnwrap:
94
94
  """
95
95
  diags = parse_and_analyze(MutationChecker(), source)
96
96
  assert not any(d.code == "imperative-option-unwrap" for d in diags)
97
+
98
+ def test_ignores_unrelated_get(self) -> None:
99
+ """Different object's .get() should not trigger the rule."""
100
+ source = b"""
101
+ class T {
102
+ void f() {
103
+ if (opt.isDefined()) { other.get(); }
104
+ }
105
+ }
106
+ """
107
+ diags = parse_and_analyze(MutationChecker(), source)
108
+ assert not any(d.code == "imperative-option-unwrap" for d in diags)
109
+
110
+
111
+ class TestConstructorAssignment:
112
+ def test_ignores_this_field_in_constructor(self) -> None:
113
+ source = b"class T { final int x; T(int x) { this.x = x; } }"
114
+ diags = parse_and_analyze(MutationChecker(), source)
115
+ assert not any(d.code == "mutable-variable" for d in diags)
116
+
117
+ def test_ignores_computed_field_in_constructor(self) -> None:
118
+ """this.x = computeValue() in constructor should not be flagged."""
119
+ source = b"class T { final int x; T() { this.x = compute(); } }"
120
+ diags = parse_and_analyze(MutationChecker(), source)
121
+ assert not any(d.code == "mutable-variable" for d in diags)
122
+
123
+ def test_detects_other_object_field_in_constructor(self) -> None:
124
+ """other.field = x in a constructor IS a mutation and should be flagged."""
125
+ source = b"class T { T() { other.field = 42; } }"
126
+ diags = parse_and_analyze(MutationChecker(), source)
127
+ assert any(d.code == "mutable-variable" for d in diags)
128
+
129
+ def test_detects_reassignment_in_method(self) -> None:
130
+ """this.x = ... in a regular method IS a mutation."""
131
+ source = b"class T { int x; void f() { this.x = 42; } }"
132
+ diags = parse_and_analyze(MutationChecker(), source)
133
+ codes = [d.code for d in diags]
134
+ assert "mutable-variable" in codes