Skip to content

Commit 2235352

Browse files
committed
Support a bookmark suffix on @see FQSEN references
Allow '@see \\Foo::bar()#<bookmark>' so a docblock can point at a specific anchor inside the target element. The trailing '#<bookmark>' is extracted from the reference token before FQSEN resolution and stored on the Fqsen reference; an empty bookmark (trailing '#') is normalised to null. Fqsen::__toString() keeps returning the bare FQSEN so downstream consumers that feed it back into phpDocumentor\\Reflection\\Fqsen (which forbids '#') keep working. See::__toString() is aware of the bookmark on Fqsen references and re-emits it, so the full tag body round-trips through parse + render. Url references are untouched — their native fragment has always been preserved as-is.
1 parent 7bae675 commit 2235352

3 files changed

Lines changed: 136 additions & 3 deletions

File tree

src/DocBlock/Tags/Reference/Fqsen.php

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,25 @@ final class Fqsen implements Reference
2222
{
2323
private RealFqsen $fqsen;
2424

25-
public function __construct(RealFqsen $fqsen)
25+
private ?string $bookmark;
26+
27+
public function __construct(RealFqsen $fqsen, ?string $bookmark = null)
2628
{
2729
$this->fqsen = $fqsen;
30+
$this->bookmark = $bookmark;
31+
}
32+
33+
/**
34+
* Returns the bookmark suffix declared with `#<bookmark>` in the docblock, or null when none was given.
35+
*/
36+
public function getBookmark(): ?string
37+
{
38+
return $this->bookmark;
2839
}
2940

3041
/**
31-
* @return string string representation of the referenced fqsen
42+
* @return string string representation of the referenced fqsen, without the bookmark suffix so
43+
* callers can feed it back to {@see \phpDocumentor\Reflection\Fqsen} safely
3244
*/
3345
public function __toString(): string
3446
{

src/DocBlock/Tags/See.php

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,16 @@ public static function create(
6262
return new static(new Url($parts[0]), $description);
6363
}
6464

65-
return new static(new FqsenRef(self::resolveFqsen($parts[0], $typeResolver, $context)), $description);
65+
$fragments = explode('#', $parts[0], 2);
66+
$bookmark = $fragments[1] ?? null;
67+
68+
return new static(
69+
new FqsenRef(
70+
self::resolveFqsen($fragments[0], $typeResolver, $context),
71+
$bookmark === '' ? null : $bookmark
72+
),
73+
$description
74+
);
6675
}
6776

6877
private static function resolveFqsen(string $parts, ?FqsenResolver $fqsenResolver, ?TypeContext $context): Fqsen
@@ -98,6 +107,9 @@ public function __toString(): string
98107
}
99108

100109
$refers = (string) $this->refers;
110+
if ($this->refers instanceof FqsenRef && $this->refers->getBookmark() !== null) {
111+
$refers .= '#' . $this->refers->getBookmark();
112+
}
101113

102114
return $refers . ($description !== '' ? ($refers !== '' ? ' ' : '') . $description : '');
103115
}

tests/unit/DocBlock/Tags/SeeTest.php

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,115 @@ public function testFactoryMethodWithoutUrl(): void
282282
$this->assertSame('My Description ', $fixture->getDescription() . '');
283283
}
284284

285+
/**
286+
* @uses \phpDocumentor\Reflection\DocBlock\Tags\See::<public>
287+
* @uses \phpDocumentor\Reflection\DocBlock\DescriptionFactory
288+
* @uses \phpDocumentor\Reflection\FqsenResolver
289+
* @uses \phpDocumentor\Reflection\DocBlock\Description
290+
* @uses \phpDocumentor\Reflection\DocBlock\Tags\Reference\Fqsen
291+
* @uses \phpDocumentor\Reflection\Fqsen
292+
* @uses \phpDocumentor\Reflection\Types\Context
293+
*
294+
* @covers ::create
295+
*/
296+
public function testFactoryMethodWithBookmark(): void
297+
{
298+
$fqsenResolver = new FqsenResolver();
299+
$descriptionFactory = new DescriptionFactory($this->createMock(TagFactory::class));
300+
$context = new Context('');
301+
302+
$fixture = See::create(
303+
'\DateTime::format()#42 Jumps inside the formatter',
304+
$fqsenResolver,
305+
$descriptionFactory,
306+
$context
307+
);
308+
309+
$reference = $fixture->getReference();
310+
$this->assertInstanceOf(TagsFqsen::class, $reference);
311+
$this->assertSame('42', $reference->getBookmark());
312+
$this->assertSame('\DateTime::format()', (string) $reference);
313+
$this->assertSame(
314+
'\DateTime::format()#42 Jumps inside the formatter',
315+
(string) $fixture
316+
);
317+
}
318+
319+
/**
320+
* @uses \phpDocumentor\Reflection\DocBlock\Tags\See::<public>
321+
* @uses \phpDocumentor\Reflection\DocBlock\DescriptionFactory
322+
* @uses \phpDocumentor\Reflection\FqsenResolver
323+
* @uses \phpDocumentor\Reflection\DocBlock\Description
324+
* @uses \phpDocumentor\Reflection\DocBlock\Tags\Reference\Fqsen
325+
* @uses \phpDocumentor\Reflection\Fqsen
326+
* @uses \phpDocumentor\Reflection\Types\Context
327+
*
328+
* @covers ::create
329+
*/
330+
public function testFactoryMethodNormalizesTrailingHashToNullBookmark(): void
331+
{
332+
$fqsenResolver = new FqsenResolver();
333+
$descriptionFactory = new DescriptionFactory($this->createMock(TagFactory::class));
334+
$context = new Context('');
335+
336+
$fixture = See::create('\DateTime#', $fqsenResolver, $descriptionFactory, $context);
337+
338+
$reference = $fixture->getReference();
339+
$this->assertInstanceOf(TagsFqsen::class, $reference);
340+
$this->assertNull($reference->getBookmark());
341+
$this->assertSame('\DateTime', (string) $reference);
342+
}
343+
344+
/**
345+
* @uses \phpDocumentor\Reflection\DocBlock\Tags\See::<public>
346+
* @uses \phpDocumentor\Reflection\DocBlock\DescriptionFactory
347+
* @uses \phpDocumentor\Reflection\FqsenResolver
348+
* @uses \phpDocumentor\Reflection\DocBlock\Description
349+
* @uses \phpDocumentor\Reflection\DocBlock\Tags\Reference\Url
350+
* @uses \phpDocumentor\Reflection\Types\Context
351+
*
352+
* @covers ::create
353+
*/
354+
public function testFactoryMethodKeepsUrlFragmentWithinUrlReference(): void
355+
{
356+
$descriptionFactory = m::mock(DescriptionFactory::class);
357+
$resolver = m::mock(FqsenResolver::class);
358+
$context = new Context('');
359+
360+
$descriptionFactory->shouldReceive('create')->andReturn(new Description(''));
361+
$resolver->shouldNotReceive('resolve');
362+
363+
$fixture = See::create('https://example.org/page#section', $resolver, $descriptionFactory, $context);
364+
365+
$this->assertInstanceOf(UrlRef::class, $fixture->getReference());
366+
$this->assertSame('https://example.org/page#section', (string) $fixture->getReference());
367+
}
368+
369+
/**
370+
* @uses \phpDocumentor\Reflection\DocBlock\Tags\See::<public>
371+
* @uses \phpDocumentor\Reflection\DocBlock\DescriptionFactory
372+
* @uses \phpDocumentor\Reflection\FqsenResolver
373+
* @uses \phpDocumentor\Reflection\DocBlock\Description
374+
* @uses \phpDocumentor\Reflection\DocBlock\Tags\Reference\Fqsen
375+
* @uses \phpDocumentor\Reflection\Fqsen
376+
* @uses \phpDocumentor\Reflection\Types\Context
377+
*
378+
* @covers ::create
379+
*/
380+
public function testFactoryMethodLeavesBookmarkNullWhenAbsent(): void
381+
{
382+
$fqsenResolver = new FqsenResolver();
383+
$descriptionFactory = new DescriptionFactory($this->createMock(TagFactory::class));
384+
$context = new Context('');
385+
386+
$fixture = See::create('\DateTime::format()', $fqsenResolver, $descriptionFactory, $context);
387+
388+
$reference = $fixture->getReference();
389+
$this->assertInstanceOf(TagsFqsen::class, $reference);
390+
$this->assertNull($reference->getBookmark());
391+
$this->assertSame('\DateTime::format()', (string) $reference);
392+
}
393+
285394
/**
286395
* @covers ::create
287396
*/

0 commit comments

Comments
 (0)