Skip to content

Commit 600f8eb

Browse files
committed
Docblock navigation works in @method and @property tags written
across several lines
1 parent 06f8eb9 commit 600f8eb

11 files changed

Lines changed: 599 additions & 873 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6060

6161
### Fixed
6262

63+
- **Docblock navigation works in `@method` and `@property` tags written across several lines.** A tag whose type wrapped onto a continuation line, such as a `@method Collection<int, Item> fetchAll(Filter $filter)` broken after the `<`, only ever had its first line read. Everything after it was invisible: go-to-definition, find references, and rename did nothing on the method or property name, on the type arguments, or on the parameter types, and the truncated first line was reported as a class named `Collection<` that resolved to nothing. Docblock positions now come from the PHPDoc grammar itself, so every name in such a tag is navigable wherever it sits.
6364
- **Docblock navigation lands on the right name in types that mix a `*` wildcard with a non-ASCII name.** In a type such as `@return Map<Café, *, User>`, go-to-definition, find references, and rename measured every name after the accented one against the wrong bytes, so clicking `User` resolved nothing (or the wrong symbol). The PHPStan `*` wildcard is now read directly by the type grammar rather than rewritten to `mixed` beforehand, which removes the byte-offset bookkeeping that was corrupting the positions.
6465
- **Formatting a short method chain starting with `new X(...)` no longer breaks it across lines unnecessarily.** When the constructor call's own arguments were long enough to wrap, the formatter also forced a short trailing chain like `(new Foo(...))->bar()` onto separate lines even though it would have fit on one. Upstream fix from mago 1.44.0.
6566
- **`@method` and `@property` tags on an implemented interface are now always applied.** A class that declared no docblock of its own missed the magic methods and properties its interfaces declared, so they did not complete, hover, or resolve, and calls to them were reported as unknown members. Tags on an interface (and on the interfaces it extends) are now picked up regardless of what the implementing class documents.

docs/todo.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,8 @@ unlikely to move the needle for most users.
196196
| E6 | Stub install prompt for non-Composer projects | Low | Low |
197197
| E7 | [Stub-based framework patches](todo/external-stubs.md#e7-stub-based-framework-patches) | Medium | Medium |
198198
| | **[Performance](todo/performance.md)** | | |
199-
| P45 | [Emit docblock symbol spans from the PHPDoc CST](todo/performance.md#p45-emit-docblock-symbol-spans-from-the-phpdoc-cst) | Low-Medium | Medium-High |
200199
| P46 | [`mago-phpdoc-syntax` cannot parse `@method static (…) name()`](todo/performance.md#p46-mago-phpdoc-syntax-cannot-parse-method-static--name) | Low | Low |
200+
| P47 | [Inline `{@see}` references are still found by scanning raw text](todo/performance.md#p47-inline-see-references-are-still-found-by-scanning-raw-text) | Low | Low-Medium |
201201
| P30 | [Evaluate migrating parse/resolve/docblock pipeline to `mago-hir`](todo/performance.md#p30-evaluate-migrating-parseresolvedocblock-pipeline-to-mago-hir) (parked — re-evaluated at mago 1.45.0, still no `mago-hir` consumers upstream) | Medium-High | High |
202202
| P43 | [`init_single_project` is the longest single-threaded stretch of a run](todo/performance.md#p43-init_single_project-is-the-longest-single-threaded-stretch-of-a-run) | Medium-High | Medium |
203203
| P16 | [Pre-parsed stub format (eliminate raw PHP embedding)](todo/performance.md#p16-pre-parsed-stub-format-eliminate-raw-php-embedding) | High | Medium-High |

docs/todo/performance.md

Lines changed: 28 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -678,36 +678,25 @@ body-return inference, since fixed by memoization).
678678

679679
---
680680

681-
## P45. Emit docblock symbol spans from the PHPDoc CST
682-
683-
**Impact: Low-Medium · Effort: Medium-High**
684-
685-
`src/symbol_map/docblock.rs` is the last consumer that re-derives tag
686-
structure from raw text. The extractors in `docblock/` now read the
687-
parsed grammar (see the `TagValueInfo` snapshot on `TagInfo`), but the
688-
symbol map needs a *byte offset in the file* for every identifier inside
689-
a type, not just the type's text, so it still finds the type itself by
690-
hand: `emit_type_first_tag` strips `@assert` modifiers and calls
691-
`split_type_token`, `extract_method_tag_symbols` re-implements the
692-
`[static] Return name(params)` split with its own paren-depth scan and a
693-
heuristic for telling a return type from a method name,
694-
`extract_template_tag_symbols` re-finds the `of` bound, and
695-
`extract_property_tag_symbols` re-splits type from variable.
696-
697-
The CST has a span on every type, identifier and variable, which would
698-
replace all of that guessing. The obstacle is multi-line types:
699-
`join_multiline_type` builds an offset map so that a position inside the
700-
joined type string can be mapped back to the file, and the folded text
701-
kept in `TagValueInfo` cannot be mapped that way. Recording each type's
702-
span alongside its text is enough to anchor the *start* of a type
703-
precisely; the per-identifier offsets inside a folded multi-line type
704-
still need the offset map, or a walk over the CST type tree in place of
705-
`emit_type_spans`.
706-
707-
Walking the CST directly is the endgame: it deletes `join_multiline_type`,
708-
`emit_type_spans_from_ast` and the offset-map machinery outright, since
709-
every identifier already carries its own span. That is the larger of the
710-
two options and is what makes this Medium-High rather than Medium.
681+
## P47. Inline `{@see}` references are still found by scanning raw text
682+
683+
**Impact: Low · Effort: Low-Medium**
684+
685+
`extract_inline_see_symbols` in `src/symbol_map/docblock.rs` is the last
686+
place the symbol map reads a docblock as a string: it searches for the
687+
literal `{@see ` and takes everything up to the next `}`. The rest of
688+
the module now walks the PHPDoc CST, where an inline tag is a
689+
`TextSegment::InlineTag` carrying a full `Tag` with its own spans.
690+
691+
The reason the scan survived is reach rather than capability. Inline
692+
tags turn up in any prose, so a CST-based version has to visit the free
693+
text before the first tag *and* the description of every tag shape that
694+
has one, which means a `Text` visitor threaded through the tag match in
695+
`emit_tag_symbols`. The string scan reaches all of them in one pass.
696+
697+
Doing it properly would pick up `{@link}` and the other inline tags for
698+
free, and stop the scan tripping over a `}` that belongs to a nested
699+
type rather than the inline tag.
711700

712701
---
713702

@@ -729,11 +718,15 @@ whole tag is dropped:
729718
*/
730719
```
731720

732-
Both forms appear in Psalm's own magic-method test suite.
733-
`recover_static_method_tag` in `src/docblock/virtual_members.rs` works
734-
around it by re-parsing the value without the modifier and reapplying
735-
`static` afterwards. Report it upstream; remove the workaround once the
736-
grammar disambiguates on the whitespace before the `(`.
721+
Both forms appear in Psalm's own magic-method test suite. Two places
722+
work around it: `recover_static_method_tag` in
723+
`src/docblock/virtual_members.rs` re-parses the value without the
724+
modifier and reapplies `static` afterwards, and
725+
`recover_static_method_tags` in `src/symbol_map/docblock.rs` blanks the
726+
keyword out with spaces and re-parses the whole docblock, because it
727+
needs the tag's original byte offsets as well as its signature. Report
728+
it upstream; remove both workarounds once the grammar disambiguates on
729+
the whitespace before the `(`.
737730

738731
---
739732

examples/demo.php

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4212,6 +4212,25 @@ public function demo(): void
42124212
}
42134213
}
42144214

4215+
4216+
// ── Multi-Line @method / @property Tags ─────────────────────────────────────
4217+
4218+
class MultiLineDocblockTagDemo
4219+
{
4220+
public function demo(): void
4221+
{
4222+
// A @method or @property tag whose type wraps onto continuation lines
4223+
// resolves like its single-line form, and every name written inside the
4224+
// tag stays navigable: try go-to-definition on `FluentCollection`,
4225+
// `Pen`, `fetchAll` or `$penHolder` in the docblock of
4226+
// ScaffoldingMultiLineTags.
4227+
$shed = new ScaffoldingMultiLineTags();
4228+
4229+
$shed->fetchAll('black')->first()->write(); // TValue resolves to Pen
4230+
$shed->penHolder->first()->write(); // same through @property
4231+
}
4232+
}
4233+
42154234
/**
42164235
* Convert to arrow function — place cursor on a single-expression closure
42174236
* and trigger code actions to see "Convert to arrow function".
@@ -4369,6 +4388,31 @@ class ScaffoldingMethodTagTemplate
43694388
public function __call(string $name, array $args): mixed { return $args[0] ?? null; }
43704389
}
43714390

4391+
// ── Multi-line @method / @property scaffolding ─────────────────────────────────
4392+
4393+
/**
4394+
* @method FluentCollection<
4395+
* int,
4396+
* Pen
4397+
* > fetchAll(string $ink)
4398+
* @property FluentCollection<
4399+
* int,
4400+
* Pen
4401+
* > $penHolder
4402+
*/
4403+
class ScaffoldingMultiLineTags
4404+
{
4405+
public function __call(string $name, array $args): mixed
4406+
{
4407+
return collect([new Pen(is_string($args[0] ?? null) ? $args[0] : 'black')]);
4408+
}
4409+
4410+
public function __get(string $name): mixed
4411+
{
4412+
return collect([new Pen()]);
4413+
}
4414+
}
4415+
43724416
// ── Template-param @mixin scaffolding ─────────────────────────────────────────
43734417
interface ScaffoldingAstNodeInterface {
43744418
public function getStartColumn(): int;
@@ -7273,6 +7317,17 @@ function runDemoAssertions(): void
72737317
$firstPen = $collected->first();
72747318
assert($firstPen instanceof Pen, 'collect(Pen[])->first() must return Pen');
72757319

7320+
// ── Multi-line @method / @property tags ──────────────────────────────
7321+
$multiLine = new ScaffoldingMultiLineTags();
7322+
assert(
7323+
$multiLine->fetchAll('black')->first() instanceof Pen,
7324+
'multi-line @method tag must yield FluentCollection<int, Pen>',
7325+
);
7326+
assert(
7327+
$multiLine->penHolder->first() instanceof Pen,
7328+
'multi-line @property tag must yield FluentCollection<int, Pen>',
7329+
);
7330+
72767331
// ── Generic @phpstan-assert narrowing ────────────────────────────────
72777332
$assertObj = new Pen();
72787333
ScaffoldingAssert::assertInstanceOf(Pen::class, $assertObj);

src/docblock/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ pub(crate) mod type_strings;
3838

3939
// Parsed docblock representation
4040
pub use parser::{DocblockInfo, TagInfo, parse_docblock_for_tags};
41+
pub(crate) use tag_kind::tag_kind;
4142
pub use tag_kind::{TagKind, TagVendor};
4243

4344
// Core tags

src/docblock/parser.rs

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,27 @@ fn vendor_rank(tag: &TagInfo) -> u8 {
368368
/// When the caller does not have span information (e.g. unit tests that
369369
/// work with standalone strings), pass a zero-offset span.
370370
pub fn parse_docblock(docblock: &str, base_span: Span) -> Option<DocblockInfo> {
371+
with_docblock_cst(docblock, base_span, |document| {
372+
collect_tags(document, docblock, base_span.start.offset)
373+
})
374+
}
375+
376+
/// Parse a docblock and hand the CST to `f`.
377+
///
378+
/// Every span in the CST is a *file* offset, because the parser is anchored
379+
/// at `base_span`: the lexer treats the `*` continuation prefixes as trivia,
380+
/// so an identifier nested inside a type written across several lines still
381+
/// reports where it really sits in the file. That makes this the entry point
382+
/// for anything that needs source positions rather than text, and it is why
383+
/// the symbol map walks the CST instead of re-scanning tag values.
384+
///
385+
/// The CST borrows from an arena that dies with this call, so it cannot
386+
/// escape the closure. Returns `None` if the string is not a docblock.
387+
pub fn with_docblock_cst<R>(
388+
docblock: &str,
389+
base_span: Span,
390+
f: impl FnOnce(&Document<'_>) -> R,
391+
) -> Option<R> {
371392
if !docblock.trim_start().starts_with("/**") {
372393
return None;
373394
}
@@ -378,7 +399,7 @@ pub fn parse_docblock(docblock: &str, base_span: Span) -> Option<DocblockInfo> {
378399
let arena = LocalArena::new();
379400
let document = PHPDocParser::parse_with_span(&arena, docblock.as_bytes(), base_span);
380401

381-
Some(collect_tags(&document, docblock, base_span.start.offset))
402+
Some(f(&document))
382403
}
383404

384405
/// Walk a parsed `Document` and collect all tags into owned [`TagInfo`]
@@ -678,7 +699,7 @@ fn variable_text(value: &[u8]) -> String {
678699
/// A type written across several lines carries the `*` continuation prefix
679700
/// and the source indentation; both have to go before the string can be
680701
/// handed to the type parser.
681-
fn type_text(docblock: &str, base_offset: u32, span: Span) -> String {
702+
pub(crate) fn type_text(docblock: &str, base_offset: u32, span: Span) -> String {
682703
let raw = source_text(docblock, base_offset, span);
683704
if raw.contains('\n') {
684705
collapse_newlines(&raw)
@@ -696,7 +717,11 @@ fn type_text(docblock: &str, base_offset: u32, span: Span) -> String {
696717
/// declined to model at all would drop out of the text. Taking the region
697718
/// from the tag name instead yields the value exactly as written, which is
698719
/// what the extractors re-parse.
699-
fn value_span(tag: &mago_phpdoc_syntax::cst::Tag<'_>, docblock: &str, base_offset: u32) -> Span {
720+
pub(crate) fn value_span(
721+
tag: &mago_phpdoc_syntax::cst::Tag<'_>,
722+
docblock: &str,
723+
base_offset: u32,
724+
) -> Span {
700725
let name_end = tag.name.span.end;
701726
let end = tag.value.span().end;
702727
let start = if end.offset > name_end.offset {

0 commit comments

Comments
 (0)