/ src / library / DocblockChecker.php
<?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;
    }
}
X