<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
*
* LICENSE:
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* END LICENSE
*
* @author Chris Pollett chris@pollett.org
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop\library;
/**
* Walks PHP and JS source looking for missing or incomplete docblocks.
* State (per-issue-type tally) lives on the instance so no globals are
* needed. Used by CodeTool's needsdocs subcommand. See Coding.pdf
* rules 12-16 for the underlying conventions.
*
* @author Chris Pollett
*/
class DocblockChecker
{
/**
* Per-issue-type counters and totals, accumulated during run().
* @var array
*/
private $summary = [
'files_scanned' => 0,
'issues_total' => 0,
'by_type' => [],
];
/**
* Optional callback invoked instead of echo for each finding.
* Receives (filename, line, type, detail). When null, findings
* are printed to stdout. Used by tests to capture output.
* @var callable|null
*/
private $report_callback = null;
/**
* Substrings whose presence in a path causes the file to be
* skipped. Same default list CodeTool itself uses; can be
* replaced via setExcludedPaths() for testing.
* @var array
*/
private $excluded_paths = ["/.", "/extensions/"];
/**
* Replaces the path-exclusion list. Useful for tests that need
* to scan a temp directory without picking up unrelated files.
*
* @param array $excluded list of substrings
*/
public function setExcludedPaths($excluded)
{
$this->excluded_paths = $excluded;
}
/**
* Sets a callback to receive findings instead of printing them.
* Used by unit tests to capture output for assertions.
*
* @param callable|null $callback receives (filename, line, type, detail)
*/
public function setReportCallback($callback)
{
$this->report_callback = $callback;
}
/**
* Returns the accumulated summary. Used by tests after run().
*
* @return array summary structure with files_scanned, issues_total, by_type
*/
public function getSummary()
{
return $this->summary;
}
/**
* Recursively walks $path and dispatches each file to checkFile().
* Accepts either a file or a directory.
*
* @param string $path file or directory to scan
*/
public function run($path)
{
if (is_dir($path)) {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($path,
\FilesystemIterator::SKIP_DOTS));
foreach ($iterator as $file) {
if ($file->isFile()) {
$this->checkFile($file->getPathname());
}
}
} else {
$this->checkFile($path);
}
}
/**
* Prints a per-issue-type tally to stdout. Called once after run().
*/
public function printSummary()
{
echo "\n--- Summary ---\n";
echo "Files scanned: {$this->summary['files_scanned']}\n";
echo "Total issues: {$this->summary['issues_total']}\n";
if (!empty($this->summary['by_type'])) {
ksort($this->summary['by_type']);
foreach ($this->summary['by_type'] as $type => $count) {
echo " $type: $count\n";
}
}
}
/**
* Dispatches a single file to the PHP or JS checker based on its
* extension. Skips paths matching the exclusion list and anything
* that isn't .php or .js.
*
* @param string $filename absolute path of the file
*/
private function checkFile($filename)
{
if ($this->isExcluded($filename)) {
return;
}
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if ($ext === 'php') {
$this->summary['files_scanned']++;
$this->checkPhpFile($filename);
} else if ($ext === 'js') {
$this->summary['files_scanned']++;
$this->checkJsFile($filename);
}
}
/**
* True if the path contains any of the configured exclusion
* substrings.
*
* @param string $path the file path
* @return bool true if any exclusion substring matches
*/
private function isExcluded($path)
{
foreach ($this->excluded_paths as $exclude) {
if (strstr($path, $exclude)) {
return true;
}
}
return false;
}
/**
* Records one issue: invokes the report callback if one is set,
* otherwise prints "file:line: type (detail)" to stdout. Always
* bumps the per-type and total counters.
*
* @param string $filename file the issue was found in
* @param int $line line of the offending declaration
* @param string $type short issue category
* @param string $detail optional extra context
*/
private function reportIssue($filename, $line, $type, $detail = '')
{
if ($this->report_callback !== null) {
($this->report_callback)($filename, $line, $type, $detail);
} else {
$msg = $detail !== '' ? " ($detail)" : '';
echo "$filename:$line: $type$msg\n";
}
$this->summary['issues_total']++;
$this->summary['by_type'][$type] =
($this->summary['by_type'][$type] ?? 0) + 1;
}
/**
* Lexer-based check for one PHP file. The token loop maintains
* cursor state (last seen docblock, brace/paren depth, class
* stack) and dispatches to a per-declaration helper when it hits
* an interesting token.
*
* @param string $filename path to the PHP source file
*/
private function checkPhpFile($filename)
{
if (!is_readable($filename)) {
return;
}
$source = file_get_contents($filename);
if (!$source || !str_contains($source, '<?')) {
return;
}
try {
$tokens = token_get_all($source);
} catch (\Throwable $e) {
return;
}
if (!$tokens) {
return;
}
$state = [
'last_doc' => null,
'last_doc_used' => false,
'last_group_doc' => null,
'group_doc_active' => false,
'first_decl_seen' => false,
'brace_depth' => 0,
'paren_depth' => 0,
'class_brace_stack' => [],
];
$skip_token_ids = [T_WHITESPACE, T_OPEN_TAG,
T_OPEN_TAG_WITH_ECHO, T_CLOSE_TAG, T_INLINE_HTML];
$n = count($tokens);
for ($i = 0; $i < $n; $i++) {
$tok = $tokens[$i];
if (is_array($tok)) {
$id = $tok[0];
if ($id === T_DOC_COMMENT) {
$state['last_doc'] = $tok[1];
$state['last_doc_used'] = false;
/*
* A docblock between consts breaks any active
* shared group: the next const has its own doc.
*/
$state['group_doc_active'] = false;
continue;
}
if ($id === T_COMMENT) {
/*
* A C-style /* ... * / (not /** ... */ /* nor
* a line comment) immediately preceding a run
* of consts can serve as a shared group
* docblock for that run.
*/
if (str_starts_with($tok[1], '/*')) {
$state['last_group_doc'] = $tok[1];
}
continue;
}
if (in_array($id, $skip_token_ids, true)) {
continue;
}
$this->dispatchPhpToken($tokens, $i, $filename, $state);
} else {
$this->updateDepthState($tok, $state);
}
}
}
/**
* Updates brace/paren depth counters for a single-character token
* and pops the class-brace stack when a class body closes.
*
* @param string $tok the single-character token
* @param array $state by-reference cursor state
*/
private function updateDepthState($tok, &$state)
{
if ($tok === '{') {
$state['brace_depth']++;
} else if ($tok === '}') {
$state['brace_depth']--;
if (!empty($state['class_brace_stack']) &&
$state['brace_depth'] === end($state['class_brace_stack'])) {
array_pop($state['class_brace_stack']);
}
} else if ($tok === '(') {
$state['paren_depth']++;
} else if ($tok === ')') {
$state['paren_depth']--;
}
}
/**
* Dispatches a non-trivial PHP token to the matching declaration
* checker. Called once per token by checkPhpFile().
*
* @param array $tokens token stream
* @param int $i index of the current token
* @param string $filename file being scanned
* @param array $state by-reference cursor state
*/
private function dispatchPhpToken($tokens, $i, $filename, &$state)
{
$tok = $tokens[$i];
$id = $tok[0];
$line = $tok[2];
$declaration_breakers = [T_NAMESPACE, T_DECLARE, T_USE, T_CLASS,
T_INTERFACE, T_TRAIT, T_FUNCTION, T_PUBLIC, T_PROTECTED,
T_PRIVATE, T_VAR];
if (defined('T_ENUM')) {
$declaration_breakers[] = T_ENUM;
}
if (in_array($id, $declaration_breakers, true)) {
/*
* Any non-const declaration breaks an active shared
* group and discards any pending C-style group-comment.
*/
$state['group_doc_active'] = false;
$state['last_group_doc'] = null;
}
$declaration_ids = [T_NAMESPACE, T_DECLARE, T_USE, T_CLASS,
T_INTERFACE, T_TRAIT, T_FUNCTION];
if (defined('T_ENUM')) {
$declaration_ids[] = T_ENUM;
}
if (!$state['first_decl_seen'] &&
in_array($id, $declaration_ids, true)) {
$state['first_decl_seen'] = true;
$this->checkPhpFileDocblock($filename, $state['last_doc']);
/*
* The file-level docblock must not float forward and
* silently satisfy a missing class-level one.
*/
$state['last_doc_used'] = true;
}
$type_ids = [T_CLASS, T_INTERFACE, T_TRAIT];
if (defined('T_ENUM')) {
$type_ids[] = T_ENUM;
}
if (in_array($id, $type_ids, true)) {
$this->checkPhpType($tokens, $i, $filename, $line, $state);
return;
}
if ($id === T_FUNCTION) {
$this->checkPhpFunction($tokens, $i, $filename, $line, $state);
return;
}
$in_class_body = !empty($state['class_brace_stack']) &&
$state['paren_depth'] === 0 &&
$state['brace_depth'] === end($state['class_brace_stack']) + 1;
$visibility_ids = [T_PUBLIC, T_PROTECTED, T_PRIVATE, T_VAR];
if ($in_class_body && in_array($id, $visibility_ids, true)) {
$this->checkPhpProperty($tokens, $i, $filename, $line, $state);
return;
}
if ($in_class_body && $id === T_CONST) {
$this->checkPhpConst($filename, $line, $state);
return;
}
}
/**
* Reports a missing-file-docblock if the first real declaration
* was reached without a preceding docblock containing both
* @license and @author tags.
*
* @param string $filename file being scanned
* @param string|null $last_doc most recent T_DOC_COMMENT seen
*/
private function checkPhpFileDocblock($filename, $last_doc)
{
$has_doc = $last_doc !== null &&
preg_match('/@license\b/', $last_doc) &&
preg_match('/@author\b/', $last_doc);
if (!$has_doc) {
$this->reportIssue($filename, 1, 'missing-file-docblock');
}
}
/**
* Checks a class/interface/trait/enum declaration. Skips
* anonymous classes (preceded by T_NEW) and the ::class magic
* constant. Pushes the brace-depth onto the class stack so the
* class body is recognisable later.
*
* @param array $tokens token stream
* @param int $i index of the type-keyword token
* @param string $filename file being scanned
* @param int $line line number of the declaration
* @param array $state by-reference cursor state
*/
private function checkPhpType($tokens, $i, $filename, $line, &$state)
{
if (self::isPrecededByNew($tokens, $i)) {
return;
}
if ($tokens[$i][0] === T_CLASS &&
self::isPrecededByDoubleColon($tokens, $i)) {
return;
}
if ($state['last_doc'] === null || $state['last_doc_used']) {
$this->reportIssue($filename, $line, 'missing-class-docblock');
} else if (self::isEmptyDocblock($state['last_doc'])) {
$this->reportIssue($filename, $line, 'empty-class-docblock');
}
$state['last_doc_used'] = true;
$state['class_brace_stack'][] = $state['brace_depth'];
}
/**
* Checks a function or method declaration. Skips closures and
* arrow functions. Applies @param parity and @return-presence
* checks unless the docblock has @ignore or @inheritDoc.
*
* @param array $tokens token stream
* @param int $i index of the T_FUNCTION token
* @param string $filename file being scanned
* @param int $line line number of the declaration
* @param array $state by-reference cursor state
*/
private function checkPhpFunction($tokens, $i, $filename, $line, &$state)
{
$name_idx = self::nextNonTrivialIndex($tokens, $i + 1);
if ($name_idx === -1) {
return;
}
if (!is_array($tokens[$name_idx])) {
/* function ( ... ) → closure */
if ($tokens[$name_idx] === '(' || $tokens[$name_idx] === '&') {
return;
}
}
$func_name = '';
if (is_array($tokens[$name_idx]) &&
$tokens[$name_idx][0] === T_STRING) {
$func_name = $tokens[$name_idx][1];
}
$paren = -1;
$n = count($tokens);
for ($k = $name_idx; $k < $n; $k++) {
if (!is_array($tokens[$k]) && $tokens[$k] === '(') {
$paren = $k;
break;
}
}
if ($state['last_doc'] === null || $state['last_doc_used']) {
$this->reportIssue($filename, $line,
'missing-function-docblock', $func_name);
$state['last_doc_used'] = true;
return;
}
if (self::isEmptyDocblock($state['last_doc'])) {
$this->reportIssue($filename, $line,
'empty-function-docblock', $func_name);
$state['last_doc_used'] = true;
return;
}
$exempt = preg_match('/@ignore\b/', $state['last_doc']) ||
preg_match('/@inheritDoc\b/i', $state['last_doc']) ||
preg_match('/{@inheritDoc}/i', $state['last_doc']);
if (!$exempt) {
if ($paren !== -1 &&
!self::isVariadicMarker($tokens, $paren)) {
$this->checkParamTags($tokens, $paren, $filename, $line,
$func_name, $state['last_doc']);
}
if (self::functionReturnsValue($tokens, $i)) {
$return_state =
self::classifyReturnTag($state['last_doc']);
if ($return_state === false) {
$this->reportIssue($filename, $line,
'missing-return-tag', $func_name);
} else if ($return_state === 'bare') {
$this->reportIssue($filename, $line,
'bare-return-tag', $func_name);
}
}
}
$state['last_doc_used'] = true;
}
/**
* True if the parameter list at $paren_idx is empty but contains a
* varargs comment marker like /* varargs * / or /* args... * /.
* Such functions read their inputs via func_get_args() and document
* them with PHPDoc's "$name,..." variadic notation; they're not
* subject to @param-parity checks.
*
* @param array $tokens token stream
* @param int $paren_idx index of the '(' opening the param list
* @return bool true if the parameter list is empty but contains a varargs marker comment
*/
private static function isVariadicMarker($tokens, $paren_idx)
{
$n = count($tokens);
$depth = 0;
$has_marker = false;
$has_param = false;
for ($i = $paren_idx; $i < $n; $i++) {
$t = $tokens[$i];
if (!is_array($t)) {
if ($t === '(') {
$depth++;
} else if ($t === ')') {
$depth--;
if ($depth === 0) {
break;
}
}
continue;
}
if ($depth !== 1) {
continue;
}
if ($t[0] === T_VARIABLE) {
$has_param = true;
} else if ($t[0] === T_COMMENT &&
preg_match('/varargs|args\.\.\./', $t[1])) {
$has_marker = true;
}
}
return $has_marker && !$has_param;
}
/**
* Reports any missing or stale @param tags relative to the
* function's actual parameter list at $paren_idx.
*
* @param array $tokens token stream
* @param int $paren_idx index of the '(' that opens the param list
* @param string $filename file being scanned
* @param int $line line of the function declaration
* @param string $func_name function name for the issue detail
* @param string $doc the docblock text
*/
private function checkParamTags($tokens, $paren_idx, $filename, $line,
$func_name, $doc)
{
$params = self::extractFunctionParams($tokens, $paren_idx);
$documented = [];
if (preg_match_all('/@param\b[^\n]*?\$(\w+)/', $doc, $matches)) {
$documented = $matches[1];
}
foreach ($params as $param) {
if (!in_array($param, $documented)) {
$this->reportIssue($filename, $line,
'missing-param-tag', "$func_name(\$$param)");
}
}
foreach ($documented as $dp) {
if (!in_array($dp, $params)) {
$this->reportIssue($filename, $line,
'stale-param-tag', "$func_name(\$$dp)");
}
}
$this->checkBareParamTags($filename, $line, $func_name, $doc);
}
/**
* Reports @param tags that have only a type and a parameter name
* but no descriptive text after them. A @param entry runs from
* the @param keyword up to the next @-tag or end of comment; if
* everything after $name on the same line is whitespace and the
* following lines (until the next tag) are also empty
* continuation, it's bare.
*
* @param string $filename file being scanned
* @param int $line line of the function declaration
* @param string $func_name function name for the issue detail
* @param string $doc the docblock text
*/
private function checkBareParamTags($filename, $line, $func_name, $doc)
{
$pattern = '/@param\b[ \t]*\S*[ \t]*\$(\w+)([\s\S]*?)' .
'(?=@\w|\*\/)/';
if (!preg_match_all($pattern, $doc, $matches, PREG_SET_ORDER)) {
return;
}
foreach ($matches as $m) {
$name = $m[1];
$tail = $m[2];
/* Strip leading-asterisk continuation markers and surrounding
* whitespace; if nothing meaningful remains, the tag is bare. */
$stripped = preg_replace('/^[ \t]*\*[ \t]?/m', '', $tail);
if (trim($stripped) === '') {
$this->reportIssue($filename, $line,
'bare-param-tag', "$func_name(\$$name)");
}
}
}
/**
* Reports a @return tag that has only a type but no description
* text. Returns true if a non-bare @return tag is present, false
* if absent or bare.
*
* @param string $doc the docblock text
* @return string|false 'present' if a complete @return is found,
* 'bare' if found but description-less, false if no @return at all
*/
private static function classifyReturnTag($doc)
{
if (!preg_match('/@return\b([\s\S]*?)(?=@\w|\*\/)/', $doc, $m)) {
return false;
}
/* Drop the type token and any whitespace; what remains should
* be a description. */
$tail = preg_replace('/^[ \t]*\S+/', '', $m[1]);
$stripped = preg_replace('/^[ \t]*\*[ \t]?/m', '', $tail);
return trim($stripped) === '' ? 'bare' : 'present';
}
/**
* Checks a class-body property declaration introduced by a
* visibility modifier. Confirms it really is a property (not a
* method or constant) before checking for a docblock with @var.
*
* @param array $tokens token stream
* @param int $i index of the visibility-modifier token
* @param string $filename file being scanned
* @param int $line line of the declaration
* @param array $state by-reference cursor state
*/
private function checkPhpProperty($tokens, $i, $filename, $line, &$state)
{
if (!self::isPropertyDeclaration($tokens, $i)) {
return;
}
if ($state['last_doc'] === null || $state['last_doc_used']) {
$this->reportIssue($filename, $line,
'missing-property-docblock');
} else if (self::isEmptyDocblock($state['last_doc'])) {
$this->reportIssue($filename, $line, 'empty-property-docblock');
} else if (!preg_match('/@var\b/', $state['last_doc'])) {
$this->reportIssue($filename, $line, 'missing-var-tag');
}
$state['last_doc_used'] = true;
}
/**
* Checks a class-body T_CONST declaration for a non-empty
* docblock. A C-style /* ... * / comment immediately preceding
* a run of consts is accepted as a shared group docblock — the
* first const in the run consumes it, subsequent consts at the
* same brace depth inherit it until any non-const declaration
* or another comment breaks the group.
*
* @param string $filename file being scanned
* @param int $line line of the declaration
* @param array $state by-reference cursor state
*/
private function checkPhpConst($filename, $line, &$state)
{
if ($state['group_doc_active']) {
/* Inside an active shared group — already documented. */
return;
}
if ($state['last_group_doc'] !== null &&
!self::isEmptyDocblock($state['last_group_doc'])) {
/*
* Start a new shared group: this const owns the
* preceding C-style comment, and subsequent consts at
* the same depth inherit it via group_doc_active.
*/
$state['group_doc_active'] = true;
$state['last_group_doc'] = null;
return;
}
if ($state['last_doc'] === null || $state['last_doc_used']) {
$this->reportIssue($filename, $line, 'missing-const-docblock');
} else if (self::isEmptyDocblock($state['last_doc'])) {
$this->reportIssue($filename, $line, 'empty-const-docblock');
} else {
/*
* The first const in a run consumed a real docblock —
* subsequent consts at the same depth share it.
*/
$state['group_doc_active'] = true;
}
$state['last_doc_used'] = true;
}
/**
* Regex-based docblock check for one JS file. Per the Coding.pdf
* Javascript section, JS uses single-asterisk comment blocks, so
* the criterion is: every named function declaration must be
* preceded by a non-empty comment block with only whitespace
* between its closing delimiter and the function start.
*
* @param string $filename path to the JS source file
*/
private function checkJsFile($filename)
{
if (!is_readable($filename)) {
return;
}
$source = file_get_contents($filename);
if (!$source) {
return;
}
/*
* Blank out the contents of /* ... *\/ comments first so that
* apostrophes inside comment text don't confuse the
* string-literal regex below into matching across many lines.
* Newlines are preserved so line numbers stay aligned.
*/
$cleaned = preg_replace_callback(
'#/\*[\s\S]*?\*/#',
function ($m) {
$len = strlen($m[0]);
if ($len < 4) {
return $m[0];
}
$inner = substr($m[0], 2, $len - 4);
$inner = preg_replace('/[^\n]/', ' ', $inner);
return '/*' . $inner . '*/';
},
$source
);
if ($cleaned === null) {
$cleaned = $source;
}
/*
* Replace string-literal contents with same-length spaces so
* we don't match "function" inside a string. Template literals
* are left alone.
*/
$cleaned = preg_replace_callback(
'/(["\'])(?:\\\\.|(?!\1)[^\\\\])*\1/s',
function ($m) {
// Preserve newlines so line numbers stay aligned.
return preg_replace('/[^\n]/', ' ', $m[0]);
},
$cleaned
);
if ($cleaned === null) {
$cleaned = $source;
}
$patterns = [
'/\bfunction\s+([A-Za-z_$][\w$]*)\s*\(/',
'/(?:^|[\s,;{(])([A-Za-z_$][\w$]*)\s*=\s*function\b\s*\(/m',
'/(?:^|[\s,;{(])([A-Za-z_$][\w$]*)\s*:\s*function\b\s*\(/m',
];
$seen = [];
foreach ($patterns as $pattern) {
if (!preg_match_all($pattern, $cleaned, $matches,
PREG_OFFSET_CAPTURE)) {
continue;
}
foreach ($matches[1] as $idx => $match) {
$offset = $matches[0][$idx][1];
if (isset($seen[$offset])) {
continue;
}
$seen[$offset] = true;
$this->checkJsFunctionPreamble($filename, $source,
$cleaned, $offset, $match[0]);
}
}
}
/**
* Inspects what immediately precedes a JS function declaration and
* reports the issue if any.
*
* @param string $filename file being scanned
* @param string $source original source (for the docblock text)
* @param string $cleaned source with string contents blanked out
* @param int $offset byte offset of the function keyword/name
* @param string $name function name for the issue detail
*/
private function checkJsFunctionPreamble($filename, $source, $cleaned,
$offset, $name)
{
$line = substr_count(substr($cleaned, 0, $offset), "\n") + 1;
$end = strrpos(substr($cleaned, 0, $offset), '*/');
if ($end === false) {
$this->reportIssue($filename, $line,
'missing-function-docblock-js', $name);
return;
}
$start = strrpos(substr($cleaned, 0, $end), '/*');
if ($start === false) {
$this->reportIssue($filename, $line,
'missing-function-docblock-js', $name);
return;
}
$between = substr($cleaned, $end + 2, $offset - ($end + 2));
/*
* Permit common decorators between the docblock and the
* function declaration: assignments like `document.onkeyup =
* `, property assignments like `obj.prop = `, async/export
* prefixes, and variable-binding `let foo = `/`const foo =
* `/`var foo = `, plus a trailing bare `let`/`const`/`var`
* (used when pattern #2 above captures the identifier after
* the keyword).
*/
$between = preg_replace(
'/^\s*(?:(?:let|const|var)\s+)?(?:[\w$.\[\]\'\"]+\s*=\s*)?'.
'(?:export\s+(?:default\s+)?)?(?:async\s+)?'.
'(?:let|const|var)?\s*$/',
'',
$between
);
if (preg_match('/\S/', $between)) {
$this->reportIssue($filename, $line,
'missing-function-docblock-js', $name);
return;
}
$cmtext = substr($source, $start, $end + 2 - $start);
if (self::isEmptyDocblock($cmtext)) {
$this->reportIssue($filename, $line,
'empty-function-docblock-js', $name);
}
}
/**
* Index of the next non-whitespace, non-comment token at or after
* $start, or -1 if none.
*
* @param array $tokens token stream
* @param int $start index to start scanning from
* @return int index of the next non-trivial token, or -1 if none
*/
private static function nextNonTrivialIndex($tokens, $start)
{
$skip = [T_WHITESPACE, T_COMMENT];
$n = count($tokens);
for ($j = $start; $j < $n; $j++) {
$t = $tokens[$j];
if (is_array($t) && in_array($t[0], $skip, true)) {
continue;
}
return $j;
}
return -1;
}
/**
* True if the docblock text is whitespace-only after stripping
* the comment delimiters and per-line leading asterisks.
*
* @param string $text raw comment text including delimiters
* @return bool true when the comment carries no human content
*/
public static function isEmptyDocblock($text)
{
$stripped = preg_replace('#^/\*+|\*+/$#', '', $text);
$stripped = preg_replace('#^\s*\*\s?#m', '', $stripped);
return trim($stripped) === '';
}
/**
* True if the token at $idx is preceded by T_NEW (anonymous class).
*
* @param array $tokens token stream from token_get_all
* @param int $idx index of the T_CLASS token
* @return bool true when this declaration is anonymous (preceded by T_NEW)
*/
public static function isPrecededByNew($tokens, $idx)
{
$skip = [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT];
for ($k = $idx - 1; $k >= 0; $k--) {
$t = $tokens[$k];
if (is_array($t) && in_array($t[0], $skip, true)) {
continue;
}
return is_array($t) && $t[0] === T_NEW;
}
return false;
}
/**
* True if the token at $idx is preceded by :: (i.e. it's the
* ::class magic constant, not a class declaration).
*
* @param array $tokens token stream from token_get_all
* @param int $idx index of the T_CLASS token
* @return bool true when this T_CLASS is part of a ::class expression
*/
public static function isPrecededByDoubleColon($tokens, $idx)
{
$skip = [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT];
for ($k = $idx - 1; $k >= 0; $k--) {
$t = $tokens[$k];
if (is_array($t) && in_array($t[0], $skip, true)) {
continue;
}
return is_array($t) && $t[0] === T_DOUBLE_COLON;
}
return false;
}
/**
* True if the visibility-modifier sequence at $start declares a
* property (rather than a method or constant).
*
* @param array $tokens token stream
* @param int $start index of the visibility modifier
* @return bool true if the visibility-modifier sequence declares a property
*/
public static function isPropertyDeclaration($tokens, $start)
{
$n = count($tokens);
for ($i = $start + 1; $i < $n; $i++) {
$t = $tokens[$i];
if (is_array($t)) {
if ($t[0] === T_FUNCTION || $t[0] === T_CONST) {
return false;
}
if ($t[0] === T_VARIABLE) {
return true;
}
continue;
}
if ($t === '(') {
return false;
}
if ($t === ';') {
return true;
}
}
return false;
}
/**
* Extracts parameter names from a function's parameter list.
* Skips default-value expressions so a literal $x in a default
* doesn't count as another parameter.
*
* @param array $tokens token stream
* @param int $paren_idx index of the '(' that opens the param list
* @return array list of parameter names without the leading $
*/
public static function extractFunctionParams($tokens, $paren_idx)
{
$n = count($tokens);
$depth = 0;
$params = [];
$expect_name = true;
for ($i = $paren_idx; $i < $n; $i++) {
$t = $tokens[$i];
if (!is_array($t)) {
if ($t === '(') {
$depth++;
} else if ($t === ')') {
$depth--;
if ($depth === 0) {
break;
}
} else if ($t === ',' && $depth === 1) {
$expect_name = true;
} else if ($t === '=' && $depth === 1) {
$expect_name = false;
}
} else if ($depth === 1 && $expect_name &&
$t[0] === T_VARIABLE) {
$params[] = ltrim($t[1], '$');
$expect_name = false;
}
}
return $params;
}
/**
* True if the function body contains a return-with-value
* statement. Bare "return;" doesn't count. Skips nested closure
* bodies. Abstract / interface signatures (no body) return false.
*
* @param array $tokens token stream
* @param int $func_start index of the T_FUNCTION token
* @return bool true if at least one value-returning return is reached in the body
*/
public static function functionReturnsValue($tokens, $func_start)
{
$n = count($tokens);
$body_started = false;
$brace_depth = 0;
for ($i = $func_start + 1; $i < $n; $i++) {
$t = $tokens[$i];
if (!is_array($t)) {
if ($t === '{') {
$brace_depth++;
$body_started = true;
} else if ($t === '}') {
$brace_depth--;
if ($body_started && $brace_depth === 0) {
return false;
}
} else if ($t === ';' && !$body_started) {
return false;
}
continue;
}
if ($body_started && $t[0] === T_FUNCTION) {
$i = self::skipNestedFunctionBody($tokens, $i);
continue;
}
if ($body_started && $t[0] === T_RETURN &&
self::returnHasValue($tokens, $i)) {
return true;
}
}
return false;
}
/**
* Advances past a nested function/closure body so its returns
* don't satisfy the outer function. Returns the index of the
* closing '}' (or ';' for a signature with no body).
*
* @param array $tokens token stream
* @param int $func_idx index of the inner T_FUNCTION token
* @return int index of the closing brace (or semicolon for a signature without a body)
*/
public static function skipNestedFunctionBody($tokens, $func_idx)
{
$n = count($tokens);
$depth = 0;
$started = false;
for ($j = $func_idx + 1; $j < $n; $j++) {
$t = $tokens[$j];
if (is_array($t)) {
continue;
}
if ($t === '{') {
$depth++;
$started = true;
} else if ($t === '}') {
$depth--;
if ($started && $depth === 0) {
return $j;
}
} else if ($t === ';' && !$started) {
return $j;
}
}
return $func_idx;
}
/**
* True if a T_RETURN at $idx is followed by a value rather than
* just a semicolon.
*
* @param array $tokens token stream
* @param int $idx index of the T_RETURN token
* @return bool true when the return is followed by an expression rather than a bare semicolon
*/
public static function returnHasValue($tokens, $idx)
{
$skip = [T_WHITESPACE, T_COMMENT];
$n = count($tokens);
for ($j = $idx + 1; $j < $n; $j++) {
$t = $tokens[$j];
if (is_array($t) && in_array($t[0], $skip, true)) {
continue;
}
return !(!is_array($t) && $t === ';');
}
return false;
}
}