Skip to content

Commit d27071a

Browse files
committed
Bug fixes
1 parent f7a9963 commit d27071a

16 files changed

Lines changed: 835 additions & 305 deletions

File tree

docs/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- **Change visibility.** The code action no longer appears when the cursor is inside a method body. It now only triggers on the method signature (modifiers, name, parameters, return type).
1414
- **Update docblock.** The code action no longer appears when the cursor is inside a function or method body. It now only triggers on the signature or the preceding docblock.
1515
- **Update docblock.** No longer suggests adding redundant `@param` tags when the docblock has no `@param` tags and all parameters already have sufficient native type hints. This matches the generate-docblock behaviour, which intentionally omits `@param` for fully-typed non-templated parameters.
16+
- **PHPStan diagnostics.** PHPStan cache pruning after deduplication now unconditionally writes the pruned set back, fixing a theoretically possible stale-entry reappearance when pruning changed diagnostics without changing the count.
1617

1718
### Added
1819

@@ -54,6 +55,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5455

5556
### Fixed
5657

58+
- **Hover.** Hovering over unresolved function calls, unknown constants, or unresolvable `self`/`static`/`parent`/`$this` keywords no longer shows a bare placeholder. If the symbol cannot be found, no hover is shown.
59+
- **Add @throws.** The code action no longer double-indents the closing `*/` when inserting a `@throws` tag into an existing multi-line docblock.
60+
- **PHPStan stale-diagnostic clearing.** The `@throws`-based staleness check now scopes to the enclosing function's docblock instead of searching the entire file. A `@throws` tag on a different function no longer causes an unrelated diagnostic to be incorrectly cleared.
5761
- **Closure and arrow function variable scope.** Variable name completion now correctly respects PHP scoping rules for anonymous functions and arrow functions. Parameters of a closure are visible inside its body, `use`-captured variables appear alongside them, and `$this` is available when the closure is defined in an instance method. Outer method locals that were not captured do not leak in. Arrow function parameters are now visible inside the arrow body while the enclosing scope's variables remain accessible, matching PHP's implicit capture behaviour.
5862
- **Namespace alias completion.** Typing a class name through a namespace alias (e.g. `OA\Re` with `use OpenApi\Attributes as OA`) now correctly suggests classes under the aliased namespace such as `OA\Response` and `OA\RequestBody`. Previously only unrelated classes matched because the alias was not expanded before prefix matching.
5963
- **Virtual property merging.** Native type hints are now considered when determining virtual property specificity. Previously only docblock types were compared, causing properties with native PHP type declarations (e.g., `public string $name`) to be incorrectly overridden by less specific virtual properties.

docs/todo.md

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,6 @@ within the same impact tier.
2323

2424
| # | Item | Impact | Effort |
2525
| --- | ----------------------------------------------------------------------------------------------------------------- | ------ | ------ |
26-
| B13 | [Stop showing dummy symbols in hover](todo/bugs.md#b13--hover-shows-dummy-symbols) | Medium | Low |
27-
| B14 | [Add @throws action inserts misaligned docblock](todo/bugs.md#b14--add-throws-action-inserts-misaligned-docblock) | Medium | Low |
28-
| B15 | [Completion after `->()` should not insert parentheses](todo/bugs.md#b15--completion-after---should-not-insert-parentheses) | Medium | Low |
29-
| B16 | [PHPStan stale-diagnostic clearing is overly aggressive](todo/bugs.md#b16--phpstan-stale-diagnostic-clearing-is-overly-aggressive) | Medium | Medium |
3026
| | **Release 0.6.0** | | |
3127

3228
## Sprint 4 — Refactoring toolkit
@@ -163,7 +159,6 @@ unlikely to move the needle for most users.
163159
| X6 | Disk cache (evaluate later) | Medium | High |
164160
| | **[Bug Fixes](todo/bugs.md)** | | |
165161
| B11 | [Diagnostic deduplication drops distinct diagnostics on same range](todo/bugs.md#b11--diagnostic-deduplication-drops-distinct-diagnostics-on-the-same-range) | Medium | Low |
166-
| B12 | [PHPStan cache pruning uses length-only comparison](todo/bugs.md#b12--phpstan-cache-pruning-uses-length-only-comparison) | Low | Low |
167162
| B13 | [Argument count diagnostic flags too many arguments by default](todo/bugs.md#b13-argument-count-diagnostic-flags-too-many-arguments-by-default) | High | Low |
168163
| | **[Inline Completion](todo/inline-completion.md)** | | |
169164
| N1 | Template engine (type-aware snippets) | Medium | High |

docs/todo/bugs.md

Lines changed: 1 addition & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -15,123 +15,4 @@ within the same impact tier.
1515

1616
---
1717

18-
## B11 — Diagnostic deduplication drops distinct diagnostics on the same range
19-
20-
| Impact | Effort |
21-
| ------ | ------ |
22-
| Medium | Low |
23-
24-
`deduplicate_diagnostics` in `src/diagnostics/mod.rs` calls
25-
`dedup_by(|a, b| a.range == b.range)` after sorting by range. This
26-
removes **all** diagnostics that share the exact same span, regardless
27-
of their diagnostic code, message, or severity. If two genuinely
28-
different native diagnostics land on the same range (e.g. an
29-
`argument_count` error and an `unknown_member` warning on the same
30-
expression), the second one is silently dropped.
31-
32-
**Fix:** Change the dedup key from `a.range == b.range` to
33-
`a.range == b.range && a.code == b.code`. This preserves distinct
34-
diagnostic codes on the same span while still collapsing true
35-
duplicates produced by different analysis phases.
36-
37-
---
38-
39-
## B12 — PHPStan cache pruning uses length-only comparison
40-
41-
| Impact | Effort |
42-
| ------ | ------ |
43-
| Low | Low |
44-
45-
In `publish_diagnostics_for_file` (`src/diagnostics/mod.rs`), the
46-
PHPStan cache pruning step only updates the cache when
47-
`pruned.len() != cached.len()`. If deduplication replaces one PHPStan
48-
diagnostic with a different one at the same count (same number of
49-
entries but different content), the cache is not updated. On the next
50-
Phase 1 merge the stale entry would reappear.
51-
52-
In practice this is unlikely because pruning only ever removes entries
53-
(never replaces them), but the check is technically incorrect.
54-
55-
**Fix:** Replace the length comparison with a content comparison, or
56-
unconditionally write the pruned set back into the cache (the extra
57-
write is negligible).
58-
59-
---
60-
61-
## B13 — Hover shows dummy symbols
62-
63-
| Impact | Effort |
64-
| ------ | ------ |
65-
| Medium | Low |
66-
67-
When hovering over certain constructs the hover popup displays
68-
internal dummy/placeholder symbols instead of filtering them out.
69-
These symbols are not meaningful to the user and clutter the hover
70-
output.
71-
72-
**Fix:** Filter out dummy symbols before building the hover response
73-
so only real, user-relevant information is shown.
74-
75-
---
76-
77-
## B14 — Add `@throws` action inserts misaligned docblock
78-
79-
| Impact | Effort |
80-
| ------ | ------ |
81-
| Medium | Low |
82-
83-
When the "Add `@throws`" code action creates a new docblock (no
84-
existing docblock is present), the inserted block is not aligned with
85-
the function/method it annotates. The opening `/**` and closing `*/`
86-
use incorrect indentation, producing code that is visually broken and
87-
fails style checks.
88-
89-
**Fix:** Detect the indentation of the target function/method line and
90-
use it as the base indentation for every line of the generated
91-
docblock (`/**`, ` * @throws …`, ` */`).
92-
93-
---
94-
95-
## B15 — Completion after `->|()` should not insert parentheses
96-
97-
| Impact | Effort |
98-
| ------ | ------ |
99-
| Medium | Low |
100-
101-
When completing a method call where the cursor is immediately before
102-
existing parentheses (e.g. `$obj->|()` with the cursor at `|`), the
103-
completion item still inserts its own parentheses, producing
104-
`$obj->method()()`. The completion engine should detect that
105-
parentheses already follow the cursor and suppress the snippet
106-
suffix in that case.
107-
108-
**Fix:** Before attaching the `()` (or `($1)`) snippet suffix to a
109-
callable completion item, check whether the character immediately
110-
after the completion range is `(`. If so, emit a plain text insert
111-
without parentheses.
112-
113-
---
114-
115-
## B16 — PHPStan stale-diagnostic clearing is overly aggressive
116-
117-
| Impact | Effort |
118-
| ------ | ------ |
119-
| Medium | Medium |
120-
121-
`is_stale_phpstan_diagnostic()` in `src/diagnostics/mod.rs` sometimes
122-
clears diagnostics that are still valid. For example, adding a
123-
`@throws` tag for one exception may cause an unrelated diagnostic on
124-
a nearby line to be treated as stale if the simple substring check
125-
matches text that happens to appear elsewhere in the file. The
126-
heuristic-based approach (checking whether a short name appears after
127-
`@throws` anywhere in the file content) has false positives.
128-
129-
**Fix:** Audit each branch of `is_stale_phpstan_diagnostic()` and
130-
tighten the checks:
131-
132-
1. Scope `content_has_throws_tag` to the docblock enclosing the
133-
diagnostic line rather than searching the entire file.
134-
2. For `@phpstan-ignore` coverage, verify that the ignore comment is
135-
on the exact diagnostic line (or the line immediately before it),
136-
not just any line in the file.
137-
3. Add regression tests for the false-positive scenarios.
18+
No outstanding items.

src/code_actions/phpstan/add_throws.rs

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,6 @@ fn insert_throws_into_existing_docblock(
439439
insert_text.push_str(&format!("{} *\n", indent));
440440
}
441441
insert_text.push_str(&format!("{} * @throws {}\n", indent, short_name));
442-
insert_text.push_str(indent);
443442

444443
// Find where the `*/` line starts (including leading whitespace).
445444
let close_line_start = doc[..close_pos].rfind('\n').map(|p| p + 1).unwrap_or(0);
@@ -666,6 +665,54 @@ mod tests {
666665
);
667666
}
668667

668+
#[test]
669+
fn multiline_insert_does_not_double_indent_closing_tag() {
670+
// Simulate applying the edit: insert text at the `*/` line start.
671+
// The insert must NOT include trailing indent that would double up
672+
// with the existing ` */` line.
673+
let php = "<?php\nclass Foo {\n /**\n * Summary.\n */\n public function bar(): void {\n throw new \\RuntimeException();\n }\n}\n";
674+
let info = find_enclosing_docblock(php, 6).unwrap();
675+
let edit = build_throws_edit(php, &info, "RuntimeException");
676+
677+
// Apply the edit to the source text.
678+
let start = byte_offset_to_lsp(php, 0); // unused, we apply manually
679+
let _ = start;
680+
let insert_offset = {
681+
let mut off = 0usize;
682+
for (i, line) in php.lines().enumerate() {
683+
if i == edit.range.start.line as usize {
684+
off += edit.range.start.character as usize;
685+
break;
686+
}
687+
off += line.len() + 1;
688+
}
689+
off
690+
};
691+
let end_offset = {
692+
let mut off = 0usize;
693+
for (i, line) in php.lines().enumerate() {
694+
if i == edit.range.end.line as usize {
695+
off += edit.range.end.character as usize;
696+
break;
697+
}
698+
off += line.len() + 1;
699+
}
700+
off
701+
};
702+
let mut result = String::new();
703+
result.push_str(&php[..insert_offset]);
704+
result.push_str(&edit.new_text);
705+
result.push_str(&php[end_offset..]);
706+
707+
// The `*/` line should have exactly 4 spaces of indent (matching `/**`).
708+
let close_line = result.lines().find(|l| l.trim() == "*/").unwrap();
709+
assert_eq!(
710+
close_line, " */",
711+
"closing */ should be aligned with the docblock (5 chars: 4 spaces + space before */).\nFull result:\n{}",
712+
result
713+
);
714+
}
715+
669716
#[test]
670717
fn inserts_throws_into_single_line_docblock() {
671718
let php = "<?php\nclass Foo {\n /** Summary. */\n public function bar(): void {\n throw new \\RuntimeException();\n }\n}\n";
@@ -698,6 +745,12 @@ mod tests {
698745
"should contain @throws: {:?}",
699746
edit.new_text
700747
);
748+
// Every line of the new docblock should start with the same indent
749+
// as the method signature (4 spaces).
750+
assert_eq!(
751+
edit.new_text, " /**\n * @throws RuntimeException\n */\n",
752+
"new docblock should be aligned with the method"
753+
);
701754
}
702755

703756
// ── Docblock with existing @throws ──────────────────────────────
@@ -729,6 +782,70 @@ mod tests {
729782
);
730783
}
731784

785+
#[test]
786+
fn inserts_throws_after_return_aligned() {
787+
// Reproduce the exact scenario: existing docblock with @return,
788+
// insert @throws. The `*/` must not get double-indented.
789+
let php = concat!(
790+
"<?php\nclass Foo {\n",
791+
" /**\n",
792+
" * @return Response\n",
793+
" */\n",
794+
" public function clientside(): Response {\n",
795+
" throw new \\RuntimeException();\n",
796+
" }\n",
797+
"}\n",
798+
);
799+
let info = find_enclosing_docblock(php, 6).unwrap();
800+
let edit = build_throws_edit(php, &info, "RuntimeException");
801+
802+
// Apply the edit to the source text.
803+
let insert_offset = {
804+
let mut off = 0usize;
805+
for (i, line) in php.lines().enumerate() {
806+
if i == edit.range.start.line as usize {
807+
off += edit.range.start.character as usize;
808+
break;
809+
}
810+
off += line.len() + 1;
811+
}
812+
off
813+
};
814+
let end_offset = {
815+
let mut off = 0usize;
816+
for (i, line) in php.lines().enumerate() {
817+
if i == edit.range.end.line as usize {
818+
off += edit.range.end.character as usize;
819+
break;
820+
}
821+
off += line.len() + 1;
822+
}
823+
off
824+
};
825+
let mut result = String::new();
826+
result.push_str(&php[..insert_offset]);
827+
result.push_str(&edit.new_text);
828+
result.push_str(&php[end_offset..]);
829+
830+
let expected = concat!(
831+
"<?php\nclass Foo {\n",
832+
" /**\n",
833+
" * @return Response\n",
834+
" *\n",
835+
" * @throws RuntimeException\n",
836+
" */\n",
837+
" public function clientside(): Response {\n",
838+
" throw new \\RuntimeException();\n",
839+
" }\n",
840+
"}\n",
841+
);
842+
assert_eq!(
843+
result, expected,
844+
"inserted @throws must not double-indent the closing */.\nGot:\n{}",
845+
result
846+
);
847+
}
848+
732849
// ── Standalone function ─────────────────────────────────────────
733850

734851
#[test]

src/code_actions/phpstan/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,4 @@ impl Backend {
4646
// ── Remove invalid/unused @throws ───────────────────────────
4747
self.collect_remove_throws_actions(uri, content, params, out);
4848
}
49-
}
49+
}

src/code_actions/update_docblock.rs

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -123,13 +123,15 @@ impl Backend {
123123
let function_loader = self.function_loader(&ctx);
124124

125125
// Determine if anything needs updating.
126-
let needs_update = check_needs_update(&info, content, &class_loader, Some(&function_loader));
126+
let needs_update =
127+
check_needs_update(&info, content, &class_loader, Some(&function_loader));
127128
if !needs_update {
128129
return;
129130
}
130131

131132
// Build the replacement docblock.
132-
let new_docblock = build_updated_docblock(&info, content, &class_loader, Some(&function_loader));
133+
let new_docblock =
134+
build_updated_docblock(&info, content, &class_loader, Some(&function_loader));
133135
if new_docblock == info.docblock_text {
134136
return;
135137
}
@@ -712,7 +714,14 @@ fn check_needs_update(
712714
}
713715

714716
// Check for missing @throws tags.
715-
let uncaught = throws_analysis::find_uncaught_throw_types_with_context(content, info.docblock_position, Some(&ThrowsContext { class_loader, function_loader }));
717+
let uncaught = throws_analysis::find_uncaught_throw_types_with_context(
718+
content,
719+
info.docblock_position,
720+
Some(&ThrowsContext {
721+
class_loader,
722+
function_loader,
723+
}),
724+
);
716725
let existing_lower: Vec<String> = info
717726
.doc_throws
718727
.iter()
@@ -944,7 +953,14 @@ fn build_updated_docblock(
944953
}
945954

946955
// Add missing @throws tags.
947-
let uncaught = throws_analysis::find_uncaught_throw_types_with_context(content, info.docblock_position, Some(&ThrowsContext { class_loader, function_loader }));
956+
let uncaught = throws_analysis::find_uncaught_throw_types_with_context(
957+
content,
958+
info.docblock_position,
959+
Some(&ThrowsContext {
960+
class_loader,
961+
function_loader,
962+
}),
963+
);
948964
let existing_throws_lower: Vec<String> = info
949965
.doc_throws
950966
.iter()
@@ -1884,7 +1900,12 @@ class Foo {
18841900
"Should find function info when cursor is inside the docblock"
18851901
);
18861902
let cl = no_class_loader();
1887-
assert!(check_needs_update(&info.unwrap(), php, &cl, no_function_loader()));
1903+
assert!(check_needs_update(
1904+
&info.unwrap(),
1905+
php,
1906+
&cl,
1907+
no_function_loader()
1908+
));
18881909
}
18891910

18901911
#[test]

0 commit comments

Comments
 (0)