/ tests / DocblockCheckerTest.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\tests;

use seekquarry\yioop\configs as C;
use seekquarry\yioop\library\DocblockChecker;
use seekquarry\yioop\library\UnitTest;

/**
 * Tests that DocblockChecker correctly identifies missing, empty, and
 * incomplete docblocks across the categories required by Coding.pdf
 * rules 12-16. Each test writes a small PHP or JS snippet to a temp
 * file, runs the checker against it with a capture callback, and
 * asserts on the captured findings.
 *
 * @author Chris Pollett
 */
class DocblockCheckerTest extends UnitTest
{
    /**
     * Captured findings from the most recent run, populated by the
     * report callback installed in scan().
     * @var array
     */
    public $findings;
    /**
     * Path of the temp file used by the current test, removed in
     * tearDown.
     * @var string
     */
    public $tmp;
    /**
     * Allocates a unique temp filename for the test.
     */
    public function setUp()
    {
        $this->findings = [];
        $this->tmp = tempnam(sys_get_temp_dir(), 'dbct_');
    }
    /**
     * Removes the temp file allocated in setUp.
     */
    public function tearDown()
    {
        if ($this->tmp !== null && file_exists($this->tmp)) {
            unlink($this->tmp);
        }
    }
    /**
     * Writes $contents to the test's temp file with extension $ext,
     * runs the checker over it, and stores the findings on $this.
     *
     * @param string $contents source code to scan
     * @param string $ext file extension to use (php or js)
     */
    public function scan($contents, $ext = 'php')
    {
        $path = $this->tmp . '.' . $ext;
        file_put_contents($path, $contents);
        $checker = new DocblockChecker();
        $checker->setReportCallback(
            function ($file, $line, $type, $detail) {
                $this->findings[] = [$line, $type, $detail];
            }
        );
        $checker->run($path);
        if (file_exists($path)) {
            unlink($path);
        }
    }
    /**
     * True if at least one finding has the given type and (if
     * supplied) detail substring. Used by individual assertions.
     *
     * @param string $type issue category to look for
     * @param string $detail_substr optional substring of the detail
     * @return bool true if any finding's type matches $type and (when
     *      $detail_substr is non-empty) its detail field contains
     *      that substring; false otherwise
     */
    public function hasFinding($type, $detail_substr = '')
    {
        foreach ($this->findings as $f) {
            if ($f[1] !== $type) {
                continue;
            }
            if ($detail_substr === '' ||
                strpos($f[2], $detail_substr) !== false) {
                return true;
            }
        }
        return false;
    }
    /**
     * A complete, well-documented file should produce zero findings.
     */
    public function cleanFileTestCase()
    {
        $src = "<?php\n" .
            "/**\n * @license foo\n * @author bar\n */\n" .
            "namespace t;\n" .
            "/** Good class */\n" .
            "class Good {\n" .
            "    /** @var int x */ public \$x;\n" .
            "    /** Always 1 */ const ONE = 1;\n" .
            "    /**\n     * Sum two numbers\n" .
            "     * @param int \$a a\n     * @param int \$b b\n" .
            "     * @return int sum\n     */\n" .
            "    public function sum(\$a, \$b) { return \$a + \$b; }\n" .
            "}\n";
        $this->scan($src);
        $this->assertEqual(count($this->findings), 0,
            "clean file should produce no findings");
    }
    /**
     * Missing file-level docblock with required @license/@author.
     */
    public function missingFileDocblockTestCase()
    {
        $src = "<?php\nnamespace t;\n" .
            "/** A class */ class A {}\n";
        $this->scan($src);
        $this->assertTrue($this->hasFinding('missing-file-docblock'),
            "missing file docblock should be reported");
    }
    /**
     * @param without matching parameter and parameter without matching
     * @param should both be flagged.
     */
    public function paramTagMismatchTestCase()
    {
        $src = "<?php\n/** @license l\n * @author a\n */\nnamespace t;\n" .
            "/** Class */ class C {\n" .
            "    /**\n     * Bad\n     * @param int \$nope nope\n" .
            "     * @return int\n     */\n" .
            "    public function f(\$x, \$y) { return 1; }\n" .
            "}\n";
        $this->scan($src);
        $this->assertTrue($this->hasFinding('missing-param-tag', '$x'),
            "missing @param for \$x should be reported");
        $this->assertTrue($this->hasFinding('missing-param-tag', '$y'),
            "missing @param for \$y should be reported");
        $this->assertTrue($this->hasFinding('stale-param-tag', '$nope'),
            "stale @param for \$nope should be reported");
    }
    /**
     * A function whose body returns a value but whose docblock has no
     * @return tag should be flagged. A bare-return function should not.
     */
    public function returnTagDetectionTestCase()
    {
        $src = "<?php\n/** @license l\n * @author a\n */\nnamespace t;\n" .
            "/** C */ class C {\n" .
            "    /**\n     * Returns a value\n" .
            "     * @param int \$x x\n     */\n" .
            "    public function r(\$x) { return \$x; }\n" .
            "    /**\n     * Bare\n     * @param int \$x x\n     */\n" .
            "    public function b(\$x) { return; }\n" .
            "}\n";
        $this->scan($src);
        $this->assertTrue($this->hasFinding('missing-return-tag', 'r'),
            "value-returning function should need @return");
        $this->assertFalse($this->hasFinding('missing-return-tag', 'b'),
            "bare-return function should not need @return");
    }
    /**
     * @inheritDoc and @ignore should suppress @param/@return checks.
     */
    public function inheritDocAndIgnoreTestCase()
    {
        $src = "<?php\n/** @license l\n * @author a\n */\nnamespace t;\n" .
            "/** C */ class C {\n" .
            "    /** @inheritDoc */\n" .
            "    public function a(\$p, \$q) { return 1; }\n" .
            "    /** @ignore */\n" .
            "    public function b(\$p, \$q) { return 1; }\n" .
            "}\n";
        $this->scan($src);
        $this->assertFalse($this->hasFinding('missing-param-tag'),
            "@inheritDoc/@ignore should suppress @param checks");
        $this->assertFalse($this->hasFinding('missing-return-tag'),
            "@inheritDoc/@ignore should suppress @return checks");
    }
    /**
     * A return inside a nested closure should not satisfy the outer
     * function's @return-presence check.
     */
    public function nestedClosureReturnTestCase()
    {
        $src = "<?php\n/** @license l\n * @author a\n */\nnamespace t;\n" .
            "/** C */ class C {\n" .
            "    /**\n     * Outer has no real return\n" .
            "     * @param int \$x x\n     */\n" .
            "    public function f(\$x) {\n" .
            "        \$g = function () { return 1; };\n" .
            "        \$g();\n" .
            "    }\n" .
            "}\n";
        $this->scan($src);
        $this->assertFalse($this->hasFinding('missing-return-tag', 'f'),
            "nested closure return should not satisfy outer function");
    }
    /**
     * The ::class magic constant should not be mistaken for a class
     * declaration.
     */
    public function magicClassConstantTestCase()
    {
        $src = "<?php\n/** @license l\n * @author a\n */\nnamespace t;\n" .
            "/** C */ class C {\n" .
            "    /**\n     * Use ::class\n     * @return string\n     */\n" .
            "    public function n() { return \\stdClass::class; }\n" .
            "}\n";
        $this->scan($src);
        $missing_class = 0;
        foreach ($this->findings as $f) {
            if ($f[1] === 'missing-class-docblock') {
                $missing_class++;
            }
        }
        $this->assertEqual($missing_class, 0,
            "::class should not trigger missing-class-docblock");
    }
    /**
     * Properties: missing docblock, empty docblock, and missing @var
     * should each fire; a complete @var-tagged docblock should not.
     */
    public function propertyDocblockTestCase()
    {
        $src = "<?php\n/** @license l\n * @author a\n */\nnamespace t;\n" .
            "/** C */ class C {\n" .
            "    public \$no_doc;\n" .
            "    /** */\n" .
            "    public \$empty;\n" .
            "    /** No var tag here */\n" .
            "    public \$no_var;\n" .
            "    /** @var int complete */\n" .
            "    public \$good;\n" .
            "}\n";
        $this->scan($src);
        $this->assertTrue($this->hasFinding('missing-property-docblock'),
            "property without docblock should be reported");
        $this->assertTrue($this->hasFinding('empty-property-docblock'),
            "property with empty docblock should be reported");
        $this->assertTrue($this->hasFinding('missing-var-tag'),
            "property docblock without @var should be reported");
    }
    /**
     * Class constants: missing and empty docblocks should each fire.
     */
    public function constDocblockTestCase()
    {
        $src = "<?php\n/** @license l\n * @author a\n */\nnamespace t;\n" .
            "/** C */ class C {\n" .
            "    const NO_DOC = 1;\n" .
            "    /** */\n" .
            "    const EMPTY_DOC = 2;\n" .
            "    /** Good */\n" .
            "    const GOOD = 3;\n" .
            "}\n";
        $this->scan($src);
        $this->assertTrue($this->hasFinding('missing-const-docblock'),
            "const without docblock should be reported");
        $this->assertTrue($this->hasFinding('empty-const-docblock'),
            "const with empty docblock should be reported");
    }
    /**
     * A C-style /* ... * / comment immediately preceding a run of
     * consts should serve as a shared group docblock for the whole
     * run.
     */
    public function sharedGroupCommentTestCase()
    {
        $src = "<?php\n/** @license l\n * @author a\n */\nnamespace t;\n" .
            "/** C */ class C {\n" .
            "    /*\n     * Shared group comment.\n     */\n" .
            "    const A = 1;\n" .
            "    const B = 2;\n" .
            "    const C = 3;\n" .
            "}\n";
        $this->scan($src);
        $this->assertEqual(count($this->findings), 0,
            "C-style group comment should document all consts in run");
    }
    /**
     * A /** ... ** / docblock immediately preceding a run of consts
     * should likewise serve as a shared group docblock.
     */
    public function sharedGroupDocblockTestCase()
    {
        $src = "<?php\n/** @license l\n * @author a\n */\nnamespace t;\n" .
            "/** C */ class C {\n" .
            "    /**\n     * @var int Pair of integers.\n     */\n" .
            "    const NUMERATOR = 1;\n" .
            "    const DENOMINATOR = 2;\n" .
            "}\n";
        $this->scan($src);
        $this->assertEqual(count($this->findings), 0,
            "shared docblock should cover both consts in pair");
    }
    /**
     * A line (//) comment is not a group docblock and should not
     * suppress missing-const-docblock findings.
     */
    public function lineCommentNotGroupDocTestCase()
    {
        $src = "<?php\n/** @license l\n * @author a\n */\nnamespace t;\n" .
            "/** C */ class C {\n" .
            "    // not a group doc\n" .
            "    const A = 1;\n" .
            "    const B = 2;\n" .
            "}\n";
        $this->scan($src);
        $missing = 0;
        foreach ($this->findings as $f) {
            if ($f[1] === 'missing-const-docblock') {
                $missing++;
            }
        }
        $this->assertEqual($missing, 2,
            "line comment must not act as a group doc");
    }
    /**
     * Any non-const declaration between two consts breaks the
     * shared group; the second const needs its own docblock.
     */
    public function groupBrokenByMethodTestCase()
    {
        $src = "<?php\n/** @license l\n * @author a\n */\nnamespace t;\n" .
            "/** C */ class C {\n" .
            "    /*\n     * Group comment.\n     */\n" .
            "    const A = 1;\n" .
            "    /** A method */\n" .
            "    public function m() {}\n" .
            "    const B = 2;\n" .
            "}\n";
        $this->scan($src);
        $missing = 0;
        $missing_b = false;
        foreach ($this->findings as $f) {
            if ($f[1] === 'missing-const-docblock') {
                $missing++;
                if (strpos($f[2], '') !== false) {
                    $missing_b = true;
                }
            }
        }
        $this->assertEqual($missing, 1,
            "method between consts should break the group");
        $this->assertTrue($missing_b,
            "const after the broken group should be flagged");
    }
    /**
     * isEmptyDocblock helper: covers stub forms and non-stub forms.
     */
    public function isEmptyDocblockHelperTestCase()
    {
        $this->assertTrue(
            DocblockChecker::isEmptyDocblock("/** */"),
            "/** */ should be empty");
        $this->assertTrue(
            DocblockChecker::isEmptyDocblock("/**\n */"),
            "/**\\n */ should be empty");
        $this->assertTrue(
            DocblockChecker::isEmptyDocblock("/**\n *\n */"),
            "/**\\n *\\n */ should be empty");
        $this->assertFalse(
            DocblockChecker::isEmptyDocblock("/** Hi */"),
            "/** Hi */ should not be empty");
        $this->assertFalse(
            DocblockChecker::isEmptyDocblock("/**\n * @var int\n */"),
            "@var docblock should not be empty");
    }
    /**
     * extractFunctionParams: literal $x in default value must not
     * count as a second parameter.
     */
    public function extractParamsHelperTestCase()
    {
        $src = '<?php function f($a, $b = "$x", $c = [1, 2]) {}';
        $tokens = token_get_all($src);
        $paren = -1;
        foreach ($tokens as $i => $t) {
            if (!is_array($t) && $t === '(') { $paren = $i; break; }
        }
        $params = DocblockChecker::extractFunctionParams($tokens, $paren);
        $this->assertEqual($params, ['a', 'b', 'c'],
            "default-value tokens must not appear as parameters");
    }
    /**
     * @param tags with only a type and a name but no description
     * should be flagged as bare-param-tag.
     */
    public function bareParamTagTestCase()
    {
        $src = "<?php\n/** @license l\n * @author a\n */\nnamespace t;\n" .
            "/** C */ class C {\n" .
            "    /**\n     * Bare params\n" .
            "     * @param int \$x\n" .
            "     * @param string \$y desc here\n" .
            "     * @param bool \$z\n" .
            "     * @return bool\n     */\n" .
            "    public function f(\$x, \$y, \$z) { return true; }\n" .
            "}\n";
        $this->scan($src);
        $bare_x = false;
        $bare_y = false;
        $bare_z = false;
        $bare_ret = false;
        foreach ($this->findings as $f) {
            if ($f[1] === 'bare-param-tag' &&
                strpos($f[2], '$x') !== false) {
                $bare_x = true;
            }
            if ($f[1] === 'bare-param-tag' &&
                strpos($f[2], '$y') !== false) {
                $bare_y = true;
            }
            if ($f[1] === 'bare-param-tag' &&
                strpos($f[2], '$z') !== false) {
                $bare_z = true;
            }
            if ($f[1] === 'bare-return-tag') {
                $bare_ret = true;
            }
        }
        $this->assertTrue($bare_x, "\$x has no description");
        $this->assertFalse($bare_y, "\$y has a description");
        $this->assertTrue($bare_z, "\$z has no description");
        $this->assertTrue($bare_ret, "@return bool has no description");
    }
    /**
     * Functions declared with an empty parameter list and a varargs
     * marker comment (e.g. /* varargs * /) read inputs via
     * func_get_args() and document them with PHPDoc's $name,...
     * variadic notation; @param parity should be skipped.
     */
    public function variadicMarkerTestCase()
    {
        $src = "<?php\n/** @license l\n * @author a\n */\nnamespace t;\n" .
            "/** C */ class C {\n" .
            "    /**\n     * Variadic via func_get_args\n" .
            "     * @param int \$level level\n" .
            "     * @param string \$message msg\n     */\n" .
            "    public function log(/* varargs */) {}\n" .
            "    /**\n     * Args... style\n" .
            "     * @param string \$prop,... names\n     */\n" .
            "    public function load(/* args... */) {}\n" .
            "}\n";
        $this->scan($src);
        $stale = 0;
        foreach ($this->findings as $f) {
            if ($f[1] === 'stale-param-tag') {
                $stale++;
            }
        }
        $this->assertEqual($stale, 0,
            "varargs marker should suppress stale-param-tag findings");
    }
    /**
     * Asserts that documented JS function declarations are not
     * mis-reported as missing-function-docblock-js when an `async`
     * (or `export`, `export async`, etc.) decorator sits between the
     * docblock and the `function` keyword.
     */
    public function asyncFunctionDocblockTestCase()
    {
        $src = "/** Header */\n" .
            "/**\n * Posts JSON.\n * @param {string} url\n */\n" .
            "async function post(url) {}\n" .
            "/**\n * Exports something.\n */\n" .
            "export async function expo() {}\n" .
            "/**\n * Plain.\n */\n" .
            "function plain() {}\n" .
            "/**\n * Onkeyup handler.\n */\n" .
            "document.onkeyup = function keyUp(e) {};\n" .
            "/**\n * Named expression bound to let.\n */\n" .
            "let mover = function mouseMoveHandler(e) {};\n";
        $this->scan($src, 'js');
        $missing = 0;
        $empty = 0;
        foreach ($this->findings as $f) {
            if ($f[1] === 'missing-function-docblock-js') {
                $missing++;
            }
            if ($f[1] === 'empty-function-docblock-js') {
                $empty++;
            }
        }
        $this->assertEqual($missing, 0,
            "async/export decorators should not block docblock recognition");
        $this->assertEqual($empty, 0,
            "non-empty docblocks should not be reported as empty");
    }
    /**
     * Asserts that an example code snippet embedded inside a
     * docblock (e.g. "onComplete: function() {...}") is not
     * mistaken for a real function declaration that needs its own
     * docblock.
     */
    public function commentExampleNotFunctionTestCase()
    {
        $src = "/**\n" .
            " * Example:\n" .
            " *    someAsync({\n" .
            " *        onComplete: function() { fn(); }\n" .
            " *    });\n" .
            " */\n" .
            "function loadingText(el) {}\n";
        $this->scan($src, 'js');
        $missing = 0;
        foreach ($this->findings as $f) {
            if ($f[1] === 'missing-function-docblock-js') {
                $missing++;
            }
        }
        $this->assertEqual($missing, 0,
            "function() inside a docblock example should not be ".
            "treated as a real declaration");
    }
}
X