Skip to content

Commit 249b443

Browse files
committed
Inline {@see} references nested inside other docblock text are now
found
1 parent 74961c8 commit 249b443

5 files changed

Lines changed: 174 additions & 56 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+
- **Inline `{@see}` references nested inside other docblock text are now found.** An inline `{@see Foo}` written in the description of another tag (`@param Type $x see {@see Foo}`), or nested inside another inline tag (`{@deprecated use {@see Bar} instead}`), was invisible to go-to-definition, find references, and rename: the previous scan located a reference by searching for the next `}` after `{@see `, which stopped at the first brace it met rather than the one that actually closed the tag. Inline `{@see}` references are now read from the same PHPDoc parse tree as everything else in the docblock.
6364
- **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.
6465
- **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.
6566
- **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.

docs/todo.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,6 @@ unlikely to move the needle for most users.
197197
| E7 | [Stub-based framework patches](todo/external-stubs.md#e7-stub-based-framework-patches) | Medium | Medium |
198198
| | **[Performance](todo/performance.md)** | | |
199199
| 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 |
201200
| 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 |
202201
| 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 |
203202
| 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: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -678,28 +678,6 @@ body-return inference, since fixed by memoization).
678678

679679
---
680680

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.
700-
701-
---
702-
703681
## P46. `mago-phpdoc-syntax` cannot parse `@method static (…) name()`
704682

705683
**Impact: Low · Effort: Low (upstream)**

src/symbol_map/docblock.rs

Lines changed: 116 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use mago_database::file::FileId;
1717
use mago_phpdoc_syntax::cst::r#type as type_ast;
1818
use mago_phpdoc_syntax::cst::{
1919
AssertPattern, Element, MethodTagValue, PropertyTagValue, Tag, TagValue,
20-
TemplateTagValue as CstTemplateTagValue, TemplateTagValueVariance, Text, Variable,
20+
TemplateTagValue as CstTemplateTagValue, TemplateTagValueVariance, Text, TextSegment, Variable,
2121
};
2222
use mago_span::{HasSpan, Position, Span};
2323
use mago_syntax::cst::*;
@@ -175,10 +175,6 @@ pub(super) fn extract_docblock_symbols(
175175
base_offset: u32,
176176
spans: &mut Vec<SymbolSpan>,
177177
) -> DocblockSymbols {
178-
// Inline `{@see ...}` references sit in free text rather than in a tag
179-
// value of their own, so they are found by scanning the raw docblock.
180-
extract_inline_see_symbols(docblock, base_offset, spans);
181-
182178
let mut found = DocblockSymbols::default();
183179
let mut sink = DocblockSink {
184180
spans,
@@ -191,9 +187,17 @@ pub(super) fn extract_docblock_symbols(
191187

192188
with_docblock_cst(docblock, docblock_span(docblock, base_offset), |document| {
193189
for (index, element) in document.elements.iter().enumerate() {
194-
let Element::Tag(tag) = element else { continue };
195-
if let Some(keyword) = emit_tag_symbols(tag, docblock, base_offset, &mut sink) {
196-
recoveries.push((index, keyword));
190+
match element {
191+
Element::Tag(tag) => {
192+
if let Some(keyword) = emit_tag_symbols(tag, docblock, base_offset, &mut sink) {
193+
recoveries.push((index, keyword));
194+
}
195+
}
196+
// Free text ahead of the first tag, e.g. `Wraps {@see Foo}.`
197+
Element::Text(text) => {
198+
scan_text_for_inline_see(text, docblock, base_offset, sink.spans);
199+
}
200+
Element::Code(_) => {}
197201
}
198202
}
199203
});
@@ -281,6 +285,13 @@ fn emit_tag_symbols(
281285
_ => {}
282286
}
283287

288+
// A `{@see ...}` can turn up in the free-text description of any tag
289+
// shape that has one (`@param Type $x see {@see Foo}`, `@deprecated in
290+
// favour of {@see Bar}`, ...), not just in a `@see` tag's own value.
291+
if let Some(description) = tag_description(&tag.value) {
292+
scan_text_for_inline_see(&description, docblock, base_offset, sink.spans);
293+
}
294+
284295
None
285296
}
286297

@@ -334,6 +345,9 @@ fn recover_static_method_tags(
334345
let Element::Tag(tag) = element else { continue };
335346
if let TagValue::Method(value) = &tag.value {
336347
emit_method_tag_symbols(value, true, sink);
348+
if let Some(description) = value.description {
349+
scan_text_for_inline_see(&description, docblock, base_offset, sink.spans);
350+
}
337351
}
338352
}
339353
});
@@ -785,33 +799,102 @@ fn emit_generic_params(params: &type_ast::GenericParameters<'_>, sink: &mut Docb
785799

786800
// ─── @see tag symbol extraction ─────────────────────────────────────────────
787801

788-
/// Scan raw docblock text for inline `{@see ...}` references.
802+
/// Walk a `Text` node's segments for inline `{@see ...}` references.
789803
///
790-
/// The CST does model these (as a `TextSegment::InlineTag`), but they can turn
791-
/// up in any prose: the free text before the first tag, and the description of
792-
/// every tag that has one. Scanning the raw string reaches all of them in one
793-
/// pass, without a `Text` visitor threaded through each tag shape.
794-
fn extract_inline_see_symbols(docblock: &str, base_offset: u32, spans: &mut Vec<SymbolSpan>) {
795-
let mut search_from = 0;
796-
while let Some(open) = docblock[search_from..].find("{@see ") {
797-
let abs_open = search_from + open;
798-
let after_tag = abs_open + 6; // length of "{@see "
799-
if let Some(close) = docblock[after_tag..].find('}') {
800-
let reference = docblock[after_tag..after_tag + close].trim();
801-
if !reference.is_empty() {
802-
// The reference token starts after `{@see `.
803-
let ref_start = after_tag
804-
+ (docblock[after_tag..after_tag + close].len()
805-
- docblock[after_tag..after_tag + close].trim_start().len());
806-
let first_token = reference.split_whitespace().next().unwrap_or("");
807-
if !first_token.is_empty() {
808-
emit_see_reference(first_token, base_offset + ref_start as u32, spans);
809-
}
810-
}
811-
search_from = after_tag + close + 1;
812-
} else {
813-
break;
804+
/// Inline tags can turn up in any prose: the free text before the first tag,
805+
/// and the description of every tag shape that has one. Every one of those
806+
/// is a [`Text`] node, so a single recursive walk over its segments reaches
807+
/// all of them without re-deriving tag structure from raw bytes; a nested
808+
/// `{@see}` (e.g. inside a `{@deprecated ...}` aside) is found by recursing
809+
/// into the inline tag's own description in turn.
810+
fn scan_text_for_inline_see(
811+
text: &Text<'_>,
812+
docblock: &str,
813+
base_offset: u32,
814+
spans: &mut Vec<SymbolSpan>,
815+
) {
816+
for segment in text.segments {
817+
let TextSegment::InlineTag(inline) = segment else {
818+
continue;
819+
};
820+
821+
if tag_kind(inline.tag) == TagKind::See
822+
&& let TagValue::Generic(value) = &inline.tag.value
823+
{
824+
emit_see_tag_symbol(&value.value, docblock, base_offset, spans);
825+
}
826+
827+
if let Some(nested) = tag_description(&inline.tag.value) {
828+
scan_text_for_inline_see(&nested, docblock, base_offset, spans);
829+
}
830+
}
831+
}
832+
833+
/// The free-text `description` a tag carries, if any; for `@see`/`@link`/and
834+
/// other tags the grammar keeps as raw prose (`TagValue::Generic`/`Invalid`),
835+
/// their whole value stands in for a description.
836+
///
837+
/// Every tag shape that has one keeps it as a `Text` (directly, or behind an
838+
/// `Option` for tags where a description is optional); this is the shared
839+
/// accessor [`scan_text_for_inline_see`] walks to find a `{@see ...}` nested
840+
/// in another tag's prose.
841+
fn tag_description<'a>(value: &TagValue<'a>) -> Option<Text<'a>> {
842+
match value {
843+
TagValue::Param(v) => v.description,
844+
TagValue::TypelessParam(v) => v.description,
845+
TagValue::ParamOut(v) => v.description,
846+
TagValue::ParamClosureThis(v) => v.description,
847+
TagValue::ParamImmediatelyInvokedCallable(v) => v.description,
848+
TagValue::ParamLaterInvokedCallable(v) => v.description,
849+
TagValue::Return(v) | TagValue::RealReturn(v) => v.description,
850+
TagValue::Var(v) => v.description,
851+
TagValue::Throws(v) => v.description,
852+
TagValue::Mixin(v) => v.description,
853+
TagValue::SelfOut(v) => v.description,
854+
TagValue::Template(v) => v.description,
855+
TagValue::Extends(v) => v.description,
856+
TagValue::Implements(v) => v.description,
857+
TagValue::Use(v) => v.description,
858+
TagValue::RequireExtends(v) => v.description,
859+
TagValue::RequireImplements(v) => v.description,
860+
TagValue::Sealed(v) => v.description,
861+
TagValue::Inheritors(v) => v.description,
862+
TagValue::Method(v) => v.description,
863+
TagValue::Property(v) | TagValue::PropertyRead(v) | TagValue::PropertyWrite(v) => {
864+
v.description
865+
}
866+
TagValue::Assert(v) | TagValue::AssertIfTrue(v) | TagValue::AssertIfFalse(v) => {
867+
v.description
814868
}
869+
TagValue::PureUnlessCallableIsImpure(v) => v.description,
870+
TagValue::Deprecated(v) => Some(v.description),
871+
TagValue::Final(v) => Some(v.description),
872+
TagValue::Internal(v) => Some(v.description),
873+
TagValue::Api(v) => Some(v.description),
874+
TagValue::Experimental(v) => Some(v.description),
875+
TagValue::Pure(v) => Some(v.description),
876+
TagValue::Impure(v) => Some(v.description),
877+
TagValue::Readonly(v) => Some(v.description),
878+
TagValue::MustUse(v) => Some(v.description),
879+
TagValue::NoNamedArguments(v) => Some(v.description),
880+
TagValue::NotDeprecated(v) => Some(v.description),
881+
TagValue::EnumInterface(v) => Some(v.description),
882+
TagValue::ConsistentConstructor(v) => Some(v.description),
883+
TagValue::ConsistentTemplates(v) => Some(v.description),
884+
TagValue::SealProperties(v) => Some(v.description),
885+
TagValue::NoSealProperties(v) => Some(v.description),
886+
TagValue::SealMethods(v) => Some(v.description),
887+
TagValue::NoSealMethods(v) => Some(v.description),
888+
TagValue::MutationFree(v) => Some(v.description),
889+
TagValue::ExternalMutationFree(v) => Some(v.description),
890+
TagValue::SuspendsFiber(v) => Some(v.description),
891+
TagValue::IgnoreNullableReturn(v) => Some(v.description),
892+
TagValue::IgnoreFalsableReturn(v) => Some(v.description),
893+
TagValue::InheritDoc(v) => Some(v.description),
894+
TagValue::Trace(v) => Some(v.description),
895+
TagValue::Generic(v) => Some(v.value),
896+
TagValue::Invalid(v) => Some(v.value),
897+
TagValue::Where(_) | TagValue::TypeAlias(_) | TagValue::TypeAliasImport(_) => None,
815898
}
816899
}
817900

src/symbol_map/tests.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3586,6 +3586,63 @@ fn see_tag_inline_member_reference() {
35863586
}
35873587
}
35883588

3589+
#[test]
3590+
fn see_tag_nested_inline_form() {
3591+
// A `{@see ...}` nested inside another inline tag's own text. A raw
3592+
// string scan for the next `}` after `{@see ` would stop at the inner
3593+
// tag's closing brace and never discover `Bar` as its own reference; the
3594+
// CST walk recurses into the outer tag's description and finds it.
3595+
let php = concat!(
3596+
"<?php\n",
3597+
"/**\n",
3598+
" * See {@see Baz, also {@see Bar}} for details.\n",
3599+
" */\n",
3600+
"class Foo {}\n",
3601+
);
3602+
let map = parse_and_extract(php);
3603+
3604+
let bar_offset = php.find("Bar").unwrap() as u32;
3605+
let hit = map.lookup(bar_offset);
3606+
assert!(hit.is_some(), "Should find Bar from the nested inline @see");
3607+
if let SymbolKind::ClassReference { ref name, .. } = hit.unwrap().kind {
3608+
assert_eq!(name, "Bar");
3609+
} else {
3610+
panic!(
3611+
"Expected ClassReference for nested inline @see Bar, got {:?}",
3612+
hit.unwrap().kind
3613+
);
3614+
}
3615+
}
3616+
3617+
#[test]
3618+
fn see_tag_inline_in_tag_description() {
3619+
// A `{@see ...}` in the free-text description of an unrelated tag (not
3620+
// in the free text before the first tag, and not a `@see` tag itself).
3621+
let php = concat!(
3622+
"<?php\n",
3623+
"/**\n",
3624+
" * @throws RuntimeException When it fails, {@see Recovery} may help.\n",
3625+
" */\n",
3626+
"class Foo {}\n",
3627+
);
3628+
let map = parse_and_extract(php);
3629+
3630+
let offset = php.find("Recovery").unwrap() as u32;
3631+
let hit = map.lookup(offset);
3632+
assert!(
3633+
hit.is_some(),
3634+
"Should find Recovery from inline @see in a @throws description"
3635+
);
3636+
if let SymbolKind::ClassReference { ref name, .. } = hit.unwrap().kind {
3637+
assert_eq!(name, "Recovery");
3638+
} else {
3639+
panic!(
3640+
"Expected ClassReference for Recovery, got {:?}",
3641+
hit.unwrap().kind
3642+
);
3643+
}
3644+
}
3645+
35893646
#[test]
35903647
fn see_tag_scalar_type_not_navigable() {
35913648
let php = concat!(

0 commit comments

Comments
 (0)