java-functional-lsp 0.3.2__tar.gz → 0.4.1__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.2 → java_functional_lsp-0.4.1}/.claude-plugin/plugin.json +1 -1
  2. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/PKG-INFO +38 -4
  3. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/README.md +37 -3
  4. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/SKILL.md +22 -3
  5. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/editors/vscode/package.json +1 -1
  6. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/hooks/java_linter_reminder.py +3 -5
  7. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/pyproject.toml +1 -1
  8. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/scripts/generate-formula.py +6 -6
  9. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/src/java_functional_lsp/__init__.py +1 -1
  10. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/src/java_functional_lsp/analyzers/base.py +24 -0
  11. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/src/java_functional_lsp/analyzers/exception_checker.py +18 -2
  12. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/src/java_functional_lsp/analyzers/mutation_checker.py +10 -3
  13. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/src/java_functional_lsp/cli.py +4 -1
  14. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/src/java_functional_lsp/server.py +50 -13
  15. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/tests/test_base.py +35 -0
  16. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/tests/test_exception_checker.py +42 -0
  17. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/tests/test_mutation_checker.py +14 -0
  18. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/.github/CODEOWNERS +0 -0
  19. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/.github/ISSUE_TEMPLATE/bug-report.md +0 -0
  20. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/.github/ISSUE_TEMPLATE/feature-request.md +0 -0
  21. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  22. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/.github/SECURITY.md +0 -0
  23. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/.github/dependabot.yml +0 -0
  24. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/.github/release-drafter.yml +0 -0
  25. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/.github/workflows/publish.yml +0 -0
  26. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/.github/workflows/release-drafter.yml +0 -0
  27. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/.github/workflows/stale.yml +0 -0
  28. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/.github/workflows/test.yml +0 -0
  29. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/.github/workflows/update-homebrew.yml +0 -0
  30. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/.github/workflows/vscode-ext.yml +0 -0
  31. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/.gitignore +0 -0
  32. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/CONTRIBUTING.md +0 -0
  33. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/LICENSE +0 -0
  34. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/commands/lint-java.md +0 -0
  35. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/editors/intellij/README.md +0 -0
  36. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/editors/intellij/lsp4ij-template.json +0 -0
  37. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/editors/vscode/.vscodeignore +0 -0
  38. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/editors/vscode/README.md +0 -0
  39. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/editors/vscode/package-lock.json +0 -0
  40. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/editors/vscode/src/extension.ts +0 -0
  41. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/editors/vscode/tsconfig.json +0 -0
  42. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/hooks/hooks.json +0 -0
  43. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/scripts/ensure-lsp.sh +0 -0
  44. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/src/java_functional_lsp/analyzers/__init__.py +0 -0
  45. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/src/java_functional_lsp/analyzers/null_checker.py +0 -0
  46. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/src/java_functional_lsp/analyzers/spring_checker.py +0 -0
  47. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/src/java_functional_lsp/proxy.py +0 -0
  48. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/tests/__init__.py +0 -0
  49. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/tests/conftest.py +0 -0
  50. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/tests/test_cli.py +0 -0
  51. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/tests/test_config.py +0 -0
  52. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/tests/test_null_checker.py +0 -0
  53. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/tests/test_proxy.py +0 -0
  54. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/tests/test_spring_checker.py +0 -0
  55. {java_functional_lsp-0.3.2 → java_functional_lsp-0.4.1}/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.2"
4
+ "version": "0.4.1"
5
5
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: java-functional-lsp
3
- Version: 0.3.2
3
+ Version: 0.4.1
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
@@ -109,7 +109,18 @@ See [editors/intellij/README.md](editors/intellij/README.md) for detailed instru
109
109
 
110
110
  ### Claude Code
111
111
 
112
- Install as a plugin directly from GitHub:
112
+ **Step 1: Enable LSP support** (required, one-time):
113
+
114
+ Add to `~/.claude/settings.json`:
115
+ ```json
116
+ {
117
+ "env": {
118
+ "ENABLE_LSP_TOOL": "1"
119
+ }
120
+ }
121
+ ```
122
+
123
+ **Step 2: Install the plugin:**
113
124
 
114
125
  ```bash
115
126
  claude plugin add https://github.com/aviadshiber/java-functional-lsp.git
@@ -130,6 +141,23 @@ Or manually add to your Claude Code config:
130
141
  }
131
142
  ```
132
143
 
144
+ **Step 3: Nudge Claude to use diagnostics** (recommended):
145
+
146
+ Add to your project's `CLAUDE.md`:
147
+ ```markdown
148
+ After writing or editing Java code, check LSP diagnostics before moving on.
149
+ Fix any violations immediately — do not explain, just apply the fix.
150
+ ```
151
+
152
+ **Troubleshooting:**
153
+
154
+ | Issue | Fix |
155
+ |-------|-----|
156
+ | No diagnostics appear | Ensure `ENABLE_LSP_TOOL=1` is set, restart Claude Code |
157
+ | "java-functional-lsp not found" | Run `brew install aviadshiber/tap/java-functional-lsp` |
158
+ | Plugin not active | Run `claude plugin list` to verify, then `/reload-plugins` |
159
+ | Diagnostics slow on first open | Normal — tree-sitter parses on first load, then incremental |
160
+
133
161
  ### Other Editors
134
162
 
135
163
  Any LSP client that supports stdio transport can use this server. Point it to the `java-functional-lsp` command for `java` files.
@@ -146,6 +174,7 @@ Create `.java-functional-lsp.json` in your project root to customize rules:
146
174
 
147
175
  ```json
148
176
  {
177
+ "excludes": ["**/generated/**", "**/vendor/**"],
149
178
  "rules": {
150
179
  "null-literal-arg": "warning",
151
180
  "throw-statement": "info",
@@ -155,8 +184,13 @@ Create `.java-functional-lsp.json` in your project root to customize rules:
155
184
  }
156
185
  ```
157
186
 
158
- Severity levels: `error`, `warning`, `info`, `hint`, `off`.
159
- All rules default to `warning` when not configured.
187
+ **Options:**
188
+ - `excludes` glob patterns for files/directories to skip entirely (supports `**` for multi-segment wildcards)
189
+ - `rules` — per-rule severity: `error`, `warning` (default), `info`, `hint`, `off`
190
+
191
+ **Spring-aware behavior:**
192
+ - `throw-statement` and `catch-rethrow` are automatically suppressed inside `@Bean` methods
193
+ - `mutable-dto` suggests `@ConstructorBinding` instead of `@Value` when the class has `@ConfigurationProperties`
160
194
 
161
195
  ## How it works
162
196
 
@@ -81,7 +81,18 @@ See [editors/intellij/README.md](editors/intellij/README.md) for detailed instru
81
81
 
82
82
  ### Claude Code
83
83
 
84
- Install as a plugin directly from GitHub:
84
+ **Step 1: Enable LSP support** (required, one-time):
85
+
86
+ Add to `~/.claude/settings.json`:
87
+ ```json
88
+ {
89
+ "env": {
90
+ "ENABLE_LSP_TOOL": "1"
91
+ }
92
+ }
93
+ ```
94
+
95
+ **Step 2: Install the plugin:**
85
96
 
86
97
  ```bash
87
98
  claude plugin add https://github.com/aviadshiber/java-functional-lsp.git
@@ -102,6 +113,23 @@ Or manually add to your Claude Code config:
102
113
  }
103
114
  ```
104
115
 
116
+ **Step 3: Nudge Claude to use diagnostics** (recommended):
117
+
118
+ Add to your project's `CLAUDE.md`:
119
+ ```markdown
120
+ After writing or editing Java code, check LSP diagnostics before moving on.
121
+ Fix any violations immediately — do not explain, just apply the fix.
122
+ ```
123
+
124
+ **Troubleshooting:**
125
+
126
+ | Issue | Fix |
127
+ |-------|-----|
128
+ | No diagnostics appear | Ensure `ENABLE_LSP_TOOL=1` is set, restart Claude Code |
129
+ | "java-functional-lsp not found" | Run `brew install aviadshiber/tap/java-functional-lsp` |
130
+ | Plugin not active | Run `claude plugin list` to verify, then `/reload-plugins` |
131
+ | Diagnostics slow on first open | Normal — tree-sitter parses on first load, then incremental |
132
+
105
133
  ### Other Editors
106
134
 
107
135
  Any LSP client that supports stdio transport can use this server. Point it to the `java-functional-lsp` command for `java` files.
@@ -118,6 +146,7 @@ Create `.java-functional-lsp.json` in your project root to customize rules:
118
146
 
119
147
  ```json
120
148
  {
149
+ "excludes": ["**/generated/**", "**/vendor/**"],
121
150
  "rules": {
122
151
  "null-literal-arg": "warning",
123
152
  "throw-statement": "info",
@@ -127,8 +156,13 @@ Create `.java-functional-lsp.json` in your project root to customize rules:
127
156
  }
128
157
  ```
129
158
 
130
- Severity levels: `error`, `warning`, `info`, `hint`, `off`.
131
- 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`
132
166
 
133
167
  ## How it works
134
168
 
@@ -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
 
@@ -70,8 +74,23 @@ To release a new version:
70
74
  5. CI automatically publishes to PyPI and builds the VS Code extension `.vsix`
71
75
  6. Run `python3 scripts/generate-formula.py <version>` and update the Homebrew tap
72
76
 
77
+ ## Enabling LSP in Claude Code
78
+
79
+ LSP support requires `ENABLE_LSP_TOOL=1` in `~/.claude/settings.json`:
80
+ ```json
81
+ { "env": { "ENABLE_LSP_TOOL": "1" } }
82
+ ```
83
+
84
+ To nudge Claude to act on diagnostics, add to your project's `CLAUDE.md`:
85
+ ```
86
+ After writing or editing Java code, check LSP diagnostics before moving on.
87
+ Fix any violations immediately — do not explain, just apply the fix.
88
+ ```
89
+
73
90
  ## Troubleshooting
74
91
 
92
+ - **No diagnostics in Claude Code**: Ensure `ENABLE_LSP_TOOL=1` is set, restart Claude Code
75
93
  - **"java-functional-lsp not found"**: Run `brew install aviadshiber/tap/java-functional-lsp`
76
- - **No completions/hover**: Install jdtls: `brew install jdtls`
77
- - **Too many warnings**: Create `.java-functional-lsp.json` to tune severity or disable noisy rules
94
+ - **No completions/hover**: Install jdtls: `brew install jdtls` (requires JDK 21+)
95
+ - **Too many warnings**: Create `.java-functional-lsp.json` with `excludes` or per-rule severity
96
+ - **Plugin not active**: Run `claude plugin list` to verify, then `/reload-plugins`
@@ -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.2",
5
+ "version": "0.4.1",
6
6
  "publisher": "aviadshiber",
7
7
  "license": "MIT",
8
8
  "engines": {
@@ -21,11 +21,9 @@ def main() -> None:
21
21
  "hookSpecificOutput": {
22
22
  "hookEventName": "PostToolUse",
23
23
  "additionalContext": (
24
- "CHECK for <new-diagnostics> from java-functional-lsp above. "
25
- "If any appear, you MUST acknowledge them and suggest fixes: "
26
- "null Option/Either, throw Either.left()/Try.of(), "
27
- "mutable → final + functional transforms, loops → .map()/.filter()/.flatMap(), "
28
- "@Data → @Value, @Autowired → constructor injection, @Component → @Configuration+@Bean."
24
+ "If <new-diagnostics> from java-functional-lsp appear above, "
25
+ "fix each violation immediately. Do not explain or list them "
26
+ "just apply the fix in your next Edit."
29
27
  ),
30
28
  }
31
29
  }
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "java-functional-lsp"
7
- version = "0.3.2"
7
+ version = "0.4.1"
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" }
@@ -10,31 +10,31 @@ import sys
10
10
  import urllib.request
11
11
 
12
12
 
13
- def get_pypi_sha256(package: str, version: str) -> str:
14
- """Get the sdist sha256 for a package from PyPI."""
13
+ def get_pypi_sdist_info(package: str, version: str) -> tuple[str, str]:
14
+ """Get the sdist URL and sha256 for a package from PyPI."""
15
15
  url = f"https://pypi.org/pypi/{package}/{version}/json"
16
16
  with urllib.request.urlopen(url) as resp:
17
17
  data = json.loads(resp.read())
18
18
 
19
19
  for file_info in data.get("urls", []):
20
20
  if file_info["filename"].endswith(".tar.gz"):
21
- return file_info["digests"]["sha256"]
21
+ return file_info["url"], file_info["digests"]["sha256"]
22
22
 
23
23
  for file_info in data.get("urls", []):
24
24
  if file_info["packagetype"] == "sdist":
25
- return file_info["digests"]["sha256"]
25
+ return file_info["url"], file_info["digests"]["sha256"]
26
26
 
27
27
  raise ValueError(f"No sdist found for {package}=={version}")
28
28
 
29
29
 
30
30
  def generate_formula(version: str) -> str:
31
31
  """Generate the Homebrew formula."""
32
- sha256 = get_pypi_sha256("java-functional-lsp", version)
32
+ sdist_url, sha256 = get_pypi_sdist_info("java-functional-lsp", version)
33
33
 
34
34
  return f'''class JavaFunctionalLsp < Formula
35
35
  desc "Java LSP server enforcing functional programming best practices"
36
36
  homepage "https://github.com/aviadshiber/java-functional-lsp"
37
- url "https://files.pythonhosted.org/packages/source/j/java-functional-lsp/java_functional_lsp-{version}.tar.gz"
37
+ url "{sdist_url}"
38
38
  sha256 "{sha256}"
39
39
  license "MIT"
40
40
 
@@ -1,3 +1,3 @@
1
1
  """java-functional-lsp: A Java LSP server enforcing functional programming best practices."""
2
2
 
3
- __version__ = "0.3.2"
3
+ __version__ = "0.4.1"
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import fnmatch
5
6
  from collections.abc import Generator
6
7
  from dataclasses import dataclass
7
8
  from enum import IntEnum
@@ -147,3 +148,26 @@ def severity_from_config(config: dict[str, Any], rule_id: str, default: Severity
147
148
  "info": Severity.INFO,
148
149
  "hint": Severity.HINT,
149
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,10 +55,11 @@ 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 named statement and it's a throw
47
63
  statements = [c for c in body.named_children if c.type not in ("line_comment", "block_comment")]
48
64
  if len(statements) == 1 and statements[0].type == "throw_statement":
49
65
  diagnostics.append(
@@ -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
 
@@ -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))
@@ -6,17 +6,19 @@ Proxies to jdtls for full Java language features (completions, hover, go-to-def)
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import asyncio
9
10
  import json
10
11
  import logging
12
+ import sys
11
13
  from pathlib import Path
12
14
  from typing import Any
13
- from urllib.parse import unquote, urlparse
14
15
 
15
16
  import cattrs
16
17
  from lsprotocol import types as lsp
17
18
  from pygls.lsp.server import LanguageServer
19
+ from pygls.uris import to_fs_path
18
20
 
19
- from .analyzers.base import Analyzer, Severity, get_parser
21
+ from .analyzers.base import Analyzer, Severity, get_parser, is_excluded
20
22
  from .analyzers.base import Diagnostic as LintDiagnostic
21
23
  from .analyzers.exception_checker import ExceptionChecker
22
24
  from .analyzers.mutation_checker import MutationChecker
@@ -59,11 +61,17 @@ class JavaFunctionalLspServer(LanguageServer):
59
61
 
60
62
  server = JavaFunctionalLspServer()
61
63
 
64
+ # Debounce state for didChange events (only affects human typing in IDEs, not agents)
65
+ _pending: dict[str, asyncio.Task[None]] = {}
66
+ _DEBOUNCE_SECONDS = 0.15
62
67
 
63
- def _uri_to_path(uri: str) -> str:
64
- """Convert a file:// URI to a filesystem path."""
65
- parsed = urlparse(uri)
66
- return unquote(parsed.path)
68
+
69
+ def _handle_exception(exc_type: type[BaseException], exc_value: BaseException, exc_tb: Any) -> None:
70
+ """Log uncaught exceptions for crash debugging."""
71
+ logger.error("Uncaught exception", exc_info=(exc_type, exc_value, exc_tb))
72
+
73
+
74
+ sys.excepthook = _handle_exception
67
75
 
68
76
 
69
77
  def _load_config(workspace_root: str | None) -> dict[str, Any]:
@@ -96,6 +104,13 @@ def _to_lsp_diagnostic(diag: LintDiagnostic) -> lsp.Diagnostic:
96
104
 
97
105
  def _analyze_document(source_text: str, uri: str = "") -> list[lsp.Diagnostic]:
98
106
  """Run all custom analyzers on the given source text. Uses incremental parsing when possible."""
107
+ # Check excludes before parsing
108
+ if uri:
109
+ excludes: list[str] = server._config.get("excludes", [])
110
+ if excludes:
111
+ path_str = to_fs_path(uri) or uri
112
+ if is_excluded(path_str, excludes):
113
+ return []
99
114
  source_bytes = source_text.encode("utf-8")
100
115
  old_tree = server._trees.get(uri) if uri else None
101
116
  tree = server._parser.parse(source_bytes, old_tree) if old_tree else server._parser.parse(source_bytes)
@@ -177,7 +192,7 @@ def on_initialize(params: lsp.InitializeParams) -> lsp.InitializeResult:
177
192
 
178
193
  root = None
179
194
  if params.root_uri:
180
- root = _uri_to_path(params.root_uri)
195
+ root = to_fs_path(params.root_uri)
181
196
  elif params.root_path:
182
197
  root = params.root_path
183
198
 
@@ -216,28 +231,50 @@ async def on_initialized(params: lsp.InitializedParams) -> None:
216
231
  # --- Document sync (forward to jdtls + run custom analyzers) ---
217
232
 
218
233
 
234
+ async def _deferred_validate(uri: str) -> None:
235
+ """Debounced validation — waits before analyzing to batch rapid edits."""
236
+ await asyncio.sleep(_DEBOUNCE_SECONDS)
237
+ await asyncio.to_thread(_publish_diagnostics, uri)
238
+
239
+
219
240
  @server.feature(lsp.TEXT_DOCUMENT_DID_OPEN)
220
241
  async def on_did_open(params: lsp.DidOpenTextDocumentParams) -> None:
221
- """Forward to jdtls and analyze."""
242
+ """Forward to jdtls and analyze immediately."""
222
243
  if server._proxy.is_available:
223
244
  await server._proxy.send_notification("textDocument/didOpen", _serialize_params(params))
224
- _publish_diagnostics(params.text_document.uri)
245
+ await asyncio.to_thread(_publish_diagnostics, params.text_document.uri)
225
246
 
226
247
 
227
248
  @server.feature(lsp.TEXT_DOCUMENT_DID_CHANGE)
228
249
  async def on_did_change(params: lsp.DidChangeTextDocumentParams) -> None:
229
- """Forward to jdtls and re-analyze."""
250
+ """Forward to jdtls and schedule debounced re-analysis."""
251
+ uri = params.text_document.uri
230
252
  if server._proxy.is_available:
231
253
  await server._proxy.send_notification("textDocument/didChange", _serialize_params(params))
232
- _publish_diagnostics(params.text_document.uri)
254
+ # Cancel pending validation, schedule new one (150ms debounce for IDE typing)
255
+ if uri in _pending:
256
+ _pending[uri].cancel()
257
+ _pending[uri] = asyncio.create_task(_deferred_validate(uri))
233
258
 
234
259
 
235
260
  @server.feature(lsp.TEXT_DOCUMENT_DID_SAVE)
236
261
  async def on_did_save(params: lsp.DidSaveTextDocumentParams) -> None:
237
- """Forward to jdtls and re-analyze."""
262
+ """Forward to jdtls and re-analyze immediately (no debounce on save)."""
238
263
  if server._proxy.is_available:
239
264
  await server._proxy.send_notification("textDocument/didSave", _serialize_params(params))
240
- _publish_diagnostics(params.text_document.uri)
265
+ await asyncio.to_thread(_publish_diagnostics, params.text_document.uri)
266
+
267
+
268
+ @server.feature(lsp.TEXT_DOCUMENT_DID_CLOSE)
269
+ async def on_did_close(params: lsp.DidCloseTextDocumentParams) -> None:
270
+ """Clean up cached state and forward to jdtls."""
271
+ uri = params.text_document.uri
272
+ server._trees.pop(uri, None)
273
+ if uri in _pending:
274
+ _pending[uri].cancel()
275
+ del _pending[uri]
276
+ if server._proxy.is_available:
277
+ await server._proxy.send_notification("textDocument/didClose", _serialize_params(params))
241
278
 
242
279
 
243
280
  # --- Forwarded features (jdtls passthrough) ---
@@ -7,6 +7,8 @@ from java_functional_lsp.analyzers.base import (
7
7
  find_nodes_multi,
8
8
  get_parser,
9
9
  has_ancestor,
10
+ has_sibling_annotation,
11
+ is_excluded,
10
12
  )
11
13
 
12
14
 
@@ -120,3 +122,36 @@ class TestCollectNodesByType:
120
122
  buckets = collect_nodes_by_type(tree.root_node, {"null_literal", "throw_statement"})
121
123
  assert len(buckets["null_literal"]) == 0
122
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")
@@ -60,3 +60,45 @@ class TestCatchRethrow:
60
60
  """
61
61
  diags = parse_and_analyze(ExceptionChecker(), source)
62
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: