Repository navigation
Expand file tree
/
Copy pathHookDocBlock.php
More file actions
850 lines (744 loc) · 28.2 KB
/
Copy pathHookDocBlock.php
File metadata and controls
850 lines (744 loc) · 28.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
<?php
/**
* Resolves the docblock that immediately precedes a hook function call (e.g.
* `apply_filters()`) into a PHPStan `ResolvedPhpDocBlock`.
*
* Also resolves WordPress core's "documented elsewhere" convention, where a hook
* is invoked under a reference comment such as:
*
* /** This filter is documented in wp-includes/media.php *\/
* $output_formats = apply_filters( 'image_editor_output_format', ... );
*
* In that case the canonical docblock is looked up from the referenced file by
* matching the hook name, and used as if it had been written at the call site.
* Dynamic canonical hook names (e.g. `"{$type}_template_hierarchy"`) are matched
* against the literal name used at the referencing site, provided they are
* anchored on enough literal text to identify a hook, and the most specific match
* wins.
*
* @link https://github.com/szepeviktor/phpstan-wordpress/blob/20f0406fcb96f8e1b8369d8c0df6f5c525a761aa/src/HookDocBlock.php Adapted from szepeviktor/phpstan-wordpress, MIT license.
*
* @see HookDocBlock::isAnchorableLiteral()
*
* @package WordPress
*/
declare(strict_types=1);
namespace WordPress\PHPStan;
use PhpParser\Comment\Doc;
use PhpParser\Error as PhpParserError;
use PhpParser\ErrorHandler\Collecting;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\BinaryOp\Concat;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\InterpolatedStringPart;
use PhpParser\Node\Name;
use PhpParser\Node\Scalar\InterpolatedString;
use PhpParser\Node\Scalar\String_;
use PhpParser\NodeFinder;
use PhpParser\NodeTraverser;
use PhpParser\ParserFactory;
use PHPStan\Analyser\DependencyTracker;
use PHPStan\Analyser\Scope;
use PHPStan\PhpDoc\ResolvedPhpDocBlock;
use PHPStan\ShouldNotHappenException;
use PHPStan\Type\FileTypeMapper;
/**
* Bridges the docblock attached to a hook call by HookDocsVisitor to PHPStan's
* docblock resolution machinery.
*
* @see HookDocsVisitor
*
* @phpstan-type HookDocs array{
* exact: array<string, string>,
* patterns: list<array{
* regex: non-falsy-string,
* literal: non-empty-string,
* text: string,
* }>,
* }
* @phpstan-type HookNameMatcher array{
* kind: 'literal'|'pattern',
* value: string,
* literal: string,
* }
* @phpstan-type HookDocumentationProblem array{
* path: non-empty-string,
* hook: string,
* problem: self::PROBLEM_FILE_MISSING|self::PROBLEM_HOOK_MISSING,
* }
* @phpstan-type HookDocumentation array{
* kind: 'inline'|'reference',
* resolved: ResolvedPhpDocBlock|null,
* paramCount: int<0, max>|null,
* problem: HookDocumentationProblem|null,
* }
*/
class HookDocBlock {
/**
* Hook functions that carry a documenting docblock for their first argument.
*/
public const HOOK_FUNCTIONS = array(
'apply_filters',
'apply_filters_deprecated',
'apply_filters_ref_array',
'do_action',
'do_action_deprecated',
'do_action_ref_array',
);
/**
* Hook functions that filter a value, and so always pass at least one argument.
*/
public const FILTER_FUNCTIONS = array(
'apply_filters',
'apply_filters_deprecated',
'apply_filters_ref_array',
);
/**
* Problem code: the referenced file does not exist.
*/
public const PROBLEM_FILE_MISSING = 'fileMissing';
/**
* Problem code: the hook is not documented in the referenced file.
*/
public const PROBLEM_HOOK_MISSING = 'hookMissing';
/**
* Pattern matching WordPress core's "documented elsewhere" reference comment.
* Captures the referenced root-relative file path.
*/
private const REFERENCE_PATTERN = '#This (?:filter|action) is documented in (\S+)#';
/**
* File type mapper used to resolve docblocks in scope.
*
* @var FileTypeMapper
*/
protected FileTypeMapper $fileTypeMapper;
/**
* In-memory cache of parsed hook documentation, keyed by absolute file path.
*
* @var array<string, HookDocs>
*/
private array $fileHookDocs = array();
/**
* Absolute path to the WordPress root that reference comment paths resolve against.
*
* @var string
*/
private string $wordpressRoot;
/**
* Canonical form of the WordPress root, with symlinks and dot segments resolved,
* against which a candidate path is tested for being inside the tree.
*
* @var string
*/
private string $canonicalWordpressRoot;
/**
* Constructor.
*
* @param FileTypeMapper $file_type_mapper File type mapper.
* @param string|null $wordpress_root Absolute path to the WordPress root that
* "documented in <file>" paths are relative to.
* Defaults to the `src` directory of this checkout.
*/
public function __construct( FileTypeMapper $file_type_mapper, ?string $wordpress_root = null ) {
$this->fileTypeMapper = $file_type_mapper;
$this->wordpressRoot = rtrim( $wordpress_root ?? dirname( __DIR__, 2 ) . '/src', '/' );
$canonical_root = realpath( $this->wordpressRoot );
$this->canonicalWordpressRoot = false === $canonical_root ? $this->wordpressRoot : $canonical_root;
$this->canonicalWordpressRoot = str_replace( '\\', '/', $this->canonicalWordpressRoot );
}
/**
* Resolves the documentation for a hook call: the docblock written above it, or
* the canonical docblock a "documented elsewhere" comment points at.
*
* This is the one entry point the rules and the return type extension share, so
* all three necessarily agree on what documents a given call.
*
* A reference that cannot be resolved to a canonical docblock leaves `resolved`
* and `paramCount` null rather than falling back to the reference comment itself,
* so an unresolved reference is never mistaken for a hook documented with no
* parameters. `problem` says why it could not be resolved, when the reason is one
* worth reporting.
*
* A canonical docblock is read with plain file I/O, so PHPStan's dependency graph
* cannot see that the referencing file depends on it. Resolving a reference
* therefore declares that dependency on the scope, and the result cache
* re-analyzes the referencing file when the file it resolved to changes, or when
* a file it would resolve to first is created.
*
* @param FuncCall $function_call Hook function call node.
* @param Scope&DependencyTracker $scope Analysis scope.
* @return HookDocumentation|null Null when no docblock precedes the call.
* @throws ShouldNotHappenException
*/
public function getHookDoc( FuncCall $function_call, Scope $scope ): ?array {
$comment = self::getNullableNodeComment( $function_call );
if ( null === $comment ) {
return null;
}
$text = $comment->getText();
// A docblock written at the call site documents the hook in place.
if ( ! preg_match( self::REFERENCE_PATTERN, $text, $matches ) ) {
$resolved = $this->resolveInlineDocBlock( $text, $scope );
return array(
'kind' => 'inline',
'resolved' => $resolved,
'paramCount' => self::countParamTags( $resolved ),
'problem' => null,
);
}
$hook_doc = array(
'kind' => 'reference',
'resolved' => null,
'paramCount' => null,
'problem' => null,
);
// Without an identifiable hook name there is nothing to look up in the
// referenced file, and so nothing to report either.
$matcher = self::getHookNameMatcher( $function_call );
if ( null === $matcher ) {
return $hook_doc;
}
$reference_path = $matches[1];
$target_file = $this->resolveReferencePath( $scope->getFile(), $reference_path, $scope );
// The referenced file could not be located up the directory tree.
if ( null === $target_file ) {
$hook_doc['problem'] = array(
'path' => $reference_path,
'hook' => self::getHookNameDisplay( $function_call ),
'problem' => self::PROBLEM_FILE_MISSING,
);
return $hook_doc;
}
$doc_text = $this->findHookDoc( $target_file, $matcher );
if ( null === $doc_text ) {
$hook_doc['problem'] = array(
'path' => $reference_path,
'hook' => self::getHookNameDisplay( $function_call ),
'problem' => self::PROBLEM_HOOK_MISSING,
);
return $hook_doc;
}
// Resolve the canonical docblock in the global namespace, with no file
// context. Hook docblocks describe global/plain types (e.g. string[],
// WP_REST_Response), so the referenced file's `use` imports are not needed.
// Passing the referenced file here would also re-enter PHPStan's name-scope
// builder while that file is itself being analyzed, which makes
// getResolvedPhpDoc return an empty docblock (NameScopeAlreadyBeingCreated).
$resolved = $this->fileTypeMapper->getResolvedPhpDoc( null, null, null, null, $doc_text );
$hook_doc['resolved'] = $resolved;
$hook_doc['paramCount'] = self::countParamTags( $resolved );
return $hook_doc;
}
/**
* Resolves the docblock preceding the given function call, if any.
*
* @param FuncCall $function_call Hook function call node.
* @param Scope&DependencyTracker $scope Analysis scope.
* @return ResolvedPhpDocBlock|null Resolved docblock, or null when none precedes
* the call or a reference cannot be resolved.
* @throws ShouldNotHappenException
*/
public function getNullableHookDocBlock( FuncCall $function_call, Scope $scope ): ?ResolvedPhpDocBlock {
$hook_doc = $this->getHookDoc( $function_call, $scope );
return null === $hook_doc ? null : $hook_doc['resolved'];
}
/**
* Resolves a docblock written at a call site, in the scope of that site.
*
* @param string $text Docblock text.
* @param Scope $scope Analysis scope.
* @return ResolvedPhpDocBlock
* @throws ShouldNotHappenException
*/
private function resolveInlineDocBlock( string $text, Scope $scope ): ResolvedPhpDocBlock {
$class_reflection = $scope->getClassReflection();
$trait_reflection = $scope->getTraitReflection();
return $this->fileTypeMapper->getResolvedPhpDoc(
$scope->getFile(),
( $scope->isInClass() && null !== $class_reflection ) ? $class_reflection->getName() : null,
( $scope->isInTrait() && null !== $trait_reflection ) ? $trait_reflection->getName() : null,
$scope->getFunctionName(),
$text
);
}
/**
* Counts the `@param` tags a resolved docblock declares.
*
* ResolvedPhpDocBlock::getParamTags() is keyed by parameter name, so two tags
* documenting the same name — a copy-and-paste slip — collapse into a single
* entry. That undercounts, which both reports a hook passing the documented
* number of arguments as a mismatch and hides a hook that genuinely passes too
* few. The parsed docblock nodes list every tag, so they are counted instead.
*
* Tags PHPStan cannot parse as a `@param` — one missing its variable name, say —
* are still left out, so a malformed tag continues to surface rather than passing
* for documentation of a parameter.
*
* @param ResolvedPhpDocBlock $resolved_php_doc Resolved docblock.
* @return int<0, max>
*/
private static function countParamTags( ResolvedPhpDocBlock $resolved_php_doc ): int {
$count = 0;
foreach ( $resolved_php_doc->getPhpDocNodes() as $php_doc_node ) {
$count += count( $php_doc_node->getParamTagValues() );
}
return $count;
}
/**
* Determines whether a filter call resolves to a docblock that documents no
* parameters.
*
* A filter always passes at least the value being filtered, so such a docblock
* does not document the hook. It is either hook documentation with its `@param`
* tags missing, or an unrelated annotation — typically a `@var` block — that
* happens to sit immediately above the call.
*
* This holds wherever the docblock was found, so no argument count is worth
* comparing against it. HookDocumentationRule reports it only for a docblock
* written at the call itself: a hook documented elsewhere is fixed where its
* canonical docblock lives, rather than once per site inheriting it.
*
* @param FuncCall $function_call Hook function call node.
* @param HookDocumentation $hook_doc Documentation resolved for the call.
* @return bool
*/
public static function isFilterMissingParamDocs( FuncCall $function_call, array $hook_doc ): bool {
if ( 0 !== $hook_doc['paramCount'] ) {
return false;
}
return $function_call->name instanceof Name
&& in_array( $function_call->name->toString(), self::FILTER_FUNCTIONS, true );
}
/**
* Determines whether a hook call's name can be identified well enough to
* require or locate documentation.
*
* Calls whose hook name carries no literal text (e.g. the generic
* `apply_filters_ref_array( $hook_name, $args )` forwarders in plugin.php)
* cannot be meaningfully documented at the call site and are excluded.
*
* @param FuncCall $function_call Hook function call node.
* @return bool
*/
public static function hasIdentifiableHookName( FuncCall $function_call ): bool {
$args = $function_call->getArgs();
if ( ! isset( $args[0] ) ) {
return false;
}
$value = $args[0]->value;
if ( $value instanceof String_ ) {
return true;
}
return null !== self::buildHookNamePattern( $value );
}
/**
* Returns the canonical docblock text for a hook documented in the given file.
*
* @param string $file Absolute path to the file declaring the hook.
* @param HookNameMatcher $matcher Hook name matcher from getHookNameMatcher().
* @return string|null Docblock text, or null when no documented invocation is found.
*/
private function findHookDoc( string $file, array $matcher ): ?string {
$docs = $this->getHookDocs( $file );
if ( 'literal' === $matcher['kind'] ) {
$name = $matcher['value'];
if ( isset( $docs['exact'][ $name ] ) ) {
return $docs['exact'][ $name ];
}
// A literal name may be an instance of a dynamic canonical hook
// (e.g. "index_template_hierarchy" matching "{$type}_template_hierarchy").
// The most specifically anchored match wins, so a name is not attributed
// to a loosely anchored hook that merely happens to match it as well.
$best = null;
$anchor_len = -1;
foreach ( $docs['patterns'] as $pattern ) {
$literal_len = strlen( $pattern['literal'] );
if ( $literal_len <= $anchor_len || ! self::isAnchorableLiteral( $pattern['literal'] ) ) {
continue;
}
if ( preg_match( $pattern['regex'], $name ) ) {
$best = $pattern['text'];
$anchor_len = $literal_len;
}
}
return $best;
}
// A dynamic referencing name matches the same dynamic canonical (identical
// regex), or a literal canonical the pattern covers.
$regex = $matcher['value'];
foreach ( $docs['patterns'] as $pattern ) {
if ( $pattern['regex'] === $regex ) {
return $pattern['text'];
}
}
// Covering a literal canonical is only meaningful for a pattern anchored
// specifically enough to identify a hook.
if ( ! self::isAnchorableLiteral( $matcher['literal'] ) ) {
return null;
}
foreach ( $docs['exact'] as $name => $text ) {
if ( preg_match( $regex, $name ) ) {
return $text;
}
}
return null;
}
/**
* Returns the hook documentation declared by a file, parsing each file at most
* once per process.
*
* @param string $file Absolute path to the file.
* @return HookDocs
*/
private function getHookDocs( string $file ): array {
$this->fileHookDocs[ $file ] ??= self::loadHookDocs( $file );
return $this->fileHookDocs[ $file ];
}
/**
* Reads and parses the hook documentation declared by a file.
*
* @param string $file Absolute path to the file.
* @return HookDocs
*/
private static function loadHookDocs( string $file ): array {
$empty = array(
'exact' => array(),
'patterns' => array(),
);
if ( ! is_file( $file ) || ! is_readable( $file ) ) {
return $empty;
}
$code = file_get_contents( $file );
if ( false === $code ) {
return $empty;
}
return self::parseHookDocs( $code );
}
/**
* Collects the canonical docblock text for each hook invocation documented in
* the given PHP source.
*
* A docblock is treated as canonical when it is not itself a "documented
* elsewhere" reference, so referencing call sites do not count as the source
* of documentation. Hooks with a literal name are indexed exactly; hooks with
* a dynamic name that contains literal text are indexed as a regex, alongside
* that literal text, which findHookDoc() uses to rank how specifically a pattern
* identifies a hook.
*
* @see HookDocBlock::findHookDoc()
*
* @param string $code PHP source code.
* @return HookDocs
*/
private static function parseHookDocs( string $code ): array {
$docs = array(
'exact' => array(),
'patterns' => array(),
);
// Source that cannot be parsed documents nothing this can read, and must not stop
// the analysis: a file is temporarily incomplete while it is being edited, and may
// use syntax the host PHP version does not know. Collecting the parse errors
// rather than throwing keeps the hooks documented ahead of the error wherever the
// parser can recover, and yields none where it cannot.
$parser = ( new ParserFactory() )->createForHostVersion();
try {
$stmts = $parser->parse( $code, new Collecting() );
} catch ( PhpParserError $parse_error ) {
return $docs;
}
if ( null === $stmts ) {
return $docs;
}
/*
* Translate hash notation before the docblocks are collected, and propagate each
* docblock down to the nested hook-call node. The file is parsed here rather than
* through PHPStan's parser, so the visitors configured in base.neon have not run
* over it: without the first one, a hook documented with a hash is typed from the
* shape where its docblock is written at the call and from the bare `array` where
* the same docblock is inherited through a reference comment.
*/
$traverser = new NodeTraverser();
$traverser->addVisitor( new HashNotationVisitor() );
$traverser->addVisitor( new HookDocsVisitor() );
$stmts = $traverser->traverse( $stmts );
$seen = array();
$calls = ( new NodeFinder() )->findInstanceOf( $stmts, FuncCall::class );
foreach ( $calls as $call ) {
if ( ! $call instanceof FuncCall || ! $call->name instanceof Name ) {
continue;
}
if ( ! in_array( $call->name->toString(), self::HOOK_FUNCTIONS, true ) ) {
continue;
}
$args = $call->getArgs();
if ( ! isset( $args[0] ) ) {
continue;
}
$doc = $call->getAttribute( 'latestDocComment' );
// Skip reference comments so only the canonical documentation counts.
if ( ! $doc instanceof Doc || preg_match( self::REFERENCE_PATTERN, $doc->getText() ) ) {
continue;
}
$name_expr = $args[0]->value;
if ( $name_expr instanceof String_ ) {
$docs['exact'][ $name_expr->value ] ??= $doc->getText();
continue;
}
$pattern = self::buildHookNamePattern( $name_expr );
if ( null !== $pattern && ! isset( $seen[ $pattern['regex'] ] ) ) {
$seen[ $pattern['regex'] ] = true;
$docs['patterns'][] = array(
'regex' => $pattern['regex'],
'literal' => $pattern['literal'],
'text' => $doc->getText(),
);
}
}
return $docs;
}
/**
* Builds an anchored regex matching a dynamic hook name expression, together
* with the literal text it is anchored on, or null when the expression carries
* no literal text at all.
*
* @param Expr $expr Hook name expression.
* @return array{
* regex: non-falsy-string,
* literal: non-empty-string,
* }|null
*/
private static function buildHookNamePattern( Expr $expr ): ?array {
$parts = self::hookNameRegexParts( $expr );
if ( null === $parts || '' === $parts[1] ) {
return null;
}
return array(
'regex' => '#^' . $parts[0] . '$#',
'literal' => $parts[1],
);
}
/**
* Determines whether the literal text of a dynamic hook name identifies a hook
* specifically enough to resolve documentation through it.
*
* A name whose literal text is nothing but separators — e.g. taxonomy.php's
* `"{$taxonomy}_{$field}"`, which becomes `#^.+_.+$#` — matches almost any hook
* name, so honoring it would attribute a call to an unrelated hook's
* documentation and hide a genuinely broken reference comment. The hook is still
* required to be documented at its own call site; only its use as the
* documentation *source* for a differently named hook is refused.
*
* @param string $literal Concatenated literal text of a hook name expression.
* @return bool
*/
private static function isAnchorableLiteral( string $literal ): bool {
return '' !== trim( $literal, "-_ \t\n\r\0\x0B" );
}
/**
* Recursively converts a hook name expression into a regex fragment and the
* literal text that fragment is anchored on.
*
* @param Expr $expr Hook name expression.
* @return array{
* 0: string,
* 1: string,
* }|null Fragment and its literal text, or null if unsupported.
*/
private static function hookNameRegexParts( Expr $expr ): ?array {
if ( $expr instanceof String_ ) {
return array( preg_quote( $expr->value, '#' ), $expr->value );
}
if ( $expr instanceof Concat ) {
$left = self::hookNameRegexParts( $expr->left );
$right = self::hookNameRegexParts( $expr->right );
if ( null === $left || null === $right ) {
return null;
}
return array( $left[0] . $right[0], $left[1] . $right[1] );
}
if ( $expr instanceof InterpolatedString ) {
$fragment = '';
$literal = '';
foreach ( $expr->parts as $part ) {
if ( $part instanceof InterpolatedStringPart ) {
$fragment .= preg_quote( $part->value, '#' );
$literal .= $part->value;
} else {
$fragment .= '.+';
}
}
return array( $fragment, $literal );
}
// Variables, property fetches, etc.: a wildcard with no literal anchor.
return array( '.+', '' );
}
/**
* Resolves a WordPress-root-relative reference path against the file
* containing the reference comment.
*
* The reference comment names the exact file (e.g. "wp-includes/media.php"), so
* resolution proceeds in two steps:
*
* 1. Walk up from the current file's directory, as far as the WordPress root,
* until the relative path resolves to a real file inside the tree. This works
* regardless of where in the tree the referencing file lives (core, a bundled
* theme, the install root, ...), and also resolves the sibling references used
* by the bundled themes (e.g. "author.php"). The walk stops at the root because
* a path that only resolves above the tree under analysis is a coincidence
* rather than the file the comment names.
* 2. Fall back to the WordPress root. Step 1 assumes the analysed file sits in
* its real location, which does not hold when an IDE runs PHPStan against a
* temporary copy of the editor buffer. Without this fallback, every
* reference comment in such a copy is reported as naming a missing file.
*
* Only the single named file is ever tested; no directory is enumerated.
*
* Every candidate tested is declared as a dependency of the file being analyzed,
* not only the one that resolves. A candidate tested before the hit decides the
* result by being absent, so creating it changes which file the reference names.
*
* @param string $current_file Absolute path to the file with the reference comment.
* @param string $reference_path Root-relative path (e.g. "wp-includes/media.php").
* @param DependencyTracker $tracker Tracker for the file being analyzed.
* @return string|null Absolute path to the referenced file, or null when it cannot be located.
*/
private function resolveReferencePath( string $current_file, string $reference_path, DependencyTracker $tracker ): ?string {
$reference_path = ltrim( $reference_path, '/' );
$dir = dirname( $current_file );
while ( $dir === $this->wordpressRoot || str_starts_with( $dir, $this->wordpressRoot . '/' ) ) {
$target = $this->resolveWithinRoot( $dir . '/' . $reference_path, $tracker );
if ( null !== $target ) {
return $target;
}
// The root has just been tested, so the walk is done.
if ( $dir === $this->wordpressRoot ) {
return null;
}
$dir = dirname( $dir );
}
// The file holding the comment is not in the tree, so resolve against the root.
return $this->resolveWithinRoot( $this->wordpressRoot . '/' . $reference_path, $tracker );
}
/**
* Canonicalizes a candidate path, accepting it only when it is a file inside the
* WordPress tree.
*
* A reference path is usually a plain relative path, but the convention is also
* written with dot segments relative to the file holding the comment: WooCommerce
* references `../wc-user-functions.php` and MainWP `../widgets/…`, so those have to
* keep resolving. WordPress core has never used that form, which is exactly why
* rejecting dot segments outright would look harmless here and break those plugins.
*
* Canonicalizing the candidate and requiring the result to be inside the tree keeps
* them working while ensuring a reference cannot reach a file outside it. That
* matters because whatever resolution finds is then read and parsed.
*
* The candidate is tracked as given rather than canonicalized. It need not exist,
* and when it does, its contents are hashed through any symlink or dot segment,
* which is the file that is read.
*
* @param string $candidate Absolute candidate path, possibly containing dot segments.
* @param DependencyTracker $tracker Tracker for the file being analyzed.
* @return string|null Canonical path, or null when it is not a file inside the tree.
*/
private function resolveWithinRoot( string $candidate, DependencyTracker $tracker ): ?string {
$tracker->trackFileDependency( $candidate );
if ( ! is_file( $candidate ) ) {
return null;
}
$canonical = realpath( $candidate );
if ( false === $canonical ) {
return null;
}
$canonical = str_replace( '\\', '/', $canonical );
return str_starts_with( $canonical, $this->canonicalWordpressRoot . '/' ) ? $canonical : null;
}
/**
* Returns a matcher describing a hook call's name: a literal string to look up
* exactly, or a regex for a dynamic name (e.g. "{$type}_template_hierarchy").
*
* @param FuncCall $call Hook function call node.
* @return HookNameMatcher|null Null when the name carries no identifiable text
* (e.g. a bare variable).
*/
private static function getHookNameMatcher( FuncCall $call ): ?array {
$args = $call->getArgs();
if ( ! isset( $args[0] ) ) {
return null;
}
$expr = $args[0]->value;
if ( $expr instanceof String_ ) {
return array(
'kind' => 'literal',
'value' => $expr->value,
'literal' => $expr->value,
);
}
$pattern = self::buildHookNamePattern( $expr );
if ( null !== $pattern ) {
return array(
'kind' => 'pattern',
'value' => $pattern['regex'],
'literal' => $pattern['literal'],
);
}
return null;
}
/**
* Renders a hook name expression to a readable string for diagnostics, e.g.
* "default_option_{$option}".
*
* @param FuncCall $call Hook function call node.
* @return string
*/
public static function getHookNameDisplay( FuncCall $call ): string {
$args = $call->getArgs();
if ( ! isset( $args[0] ) ) {
return '';
}
return self::renderHookName( $args[0]->value );
}
/**
* Recursively renders a hook name expression to a readable string.
*
* @param Expr $expr Hook name expression.
* @return string
*/
private static function renderHookName( Expr $expr ): string {
if ( $expr instanceof String_ ) {
return $expr->value;
}
if ( $expr instanceof Concat ) {
return self::renderHookName( $expr->left ) . self::renderHookName( $expr->right );
}
if ( $expr instanceof InterpolatedString ) {
$out = '';
foreach ( $expr->parts as $part ) {
if ( $part instanceof InterpolatedStringPart ) {
$out .= $part->value;
} elseif ( $part instanceof Variable && is_string( $part->name ) ) {
$out .= '{$' . $part->name . '}';
} else {
$out .= '{...}';
}
}
return $out;
}
if ( $expr instanceof Variable && is_string( $expr->name ) ) {
return '$' . $expr->name;
}
return '...';
}
/**
* Returns the docblock attached to the node by HookDocsVisitor, if present.
*
* @param FuncCall $node Function call node.
* @return Doc|null
*/
private static function getNullableNodeComment( FuncCall $node ): ?Doc {
/** @var Doc|null $doc */
$doc = $node->getAttribute( 'latestDocComment' );
return $doc;
}
}