java-functional-lsp 0.9.18__tar.gz → 0.11.0__tar.gz

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