<?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");
}
}