├── .github └── workflows │ ├── auto-author-assign.yml │ └── ci.yml ├── README.md ├── classes └── privacy │ └── provider.php ├── cli └── moodlecheck.php ├── file.php ├── index.php ├── lang └── en │ └── local_moodlecheck.php ├── locallib.php ├── renderer.php ├── rules └── phpdocs_basic.php ├── settings.php ├── styles.css ├── tests ├── coverage.php ├── fixtures │ ├── constantclass.php │ ├── empty.php │ ├── error_and_warning.php │ ├── nophp.php │ ├── phpdoc_constructor_property_promotion.php │ ├── phpdoc_constructor_property_promotion_readonly.php │ ├── phpdoc_method_multiline.php │ ├── phpdoc_method_union_types.php │ ├── phpdoc_tags_general.php │ ├── phpdoc_tags_inline.php │ ├── unfinished.php │ └── uses.php ├── moodlecheck_rules_test.php └── phpdocs_basic_test.php └── version.php /.github/workflows/auto-author-assign.yml: -------------------------------------------------------------------------------- 1 | name: Auto Author Assign 2 | 3 | on: 4 | pull_request_target: 5 | types: [ opened, reopened ] 6 | 7 | permissions: 8 | pull-requests: write 9 | 10 | jobs: 11 | assign-author: 12 | name: Auto assign PR 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: toshimaru/auto-author-assign@v2.1.0 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Moodlecheck CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-22.04 8 | 9 | services: 10 | postgres: 11 | image: postgres:13 12 | env: 13 | POSTGRES_USER: 'postgres' 14 | POSTGRES_HOST_AUTH_METHOD: 'trust' 15 | ports: 16 | - 5432:5432 17 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 3 18 | mariadb: 19 | image: mariadb:10 20 | env: 21 | MYSQL_USER: 'root' 22 | MYSQL_ALLOW_EMPTY_PASSWORD: "true" 23 | ports: 24 | - 3306:3306 25 | options: --health-cmd="mysqladmin ping" --health-interval 10s --health-timeout 5s --health-retries 3 26 | 27 | strategy: 28 | fail-fast: false 29 | matrix: 30 | include: 31 | # Highest php versions supported by each branch (with main always being tested twice). 32 | - php: 8.3 33 | moodle-branch: main 34 | database: pgsql 35 | - php: 8.3 36 | moodle-branch: main 37 | database: pgsql 38 | - php: 8.3 39 | moodle-branch: MOODLE_404_STABLE 40 | database: pgsql 41 | 42 | - php: 8.2 43 | moodle-branch: MOODLE_403_STABLE 44 | database: pgsql 45 | - php: 8.2 46 | moodle-branch: MOODLE_402_STABLE 47 | database: pgsql 48 | 49 | - php: 8.1 50 | moodle-branch: MOODLE_401_STABLE 51 | database: pgsql 52 | 53 | - php: 8.0 54 | moodle-branch: MOODLE_400_STABLE 55 | database: pgsql 56 | # The following line is only needed if you're going to run php8 jobs and your 57 | # plugin needs xmlrpc services. 58 | extensions: xmlrpc-beta 59 | - php: 8.0 60 | moodle-branch: MOODLE_311_STABLE 61 | database: pgsql 62 | # The following line is only needed if you're going to run php8 jobs and your 63 | # plugin needs xmlrpc services. 64 | extensions: xmlrpc-beta 65 | 66 | - php: 7.4 67 | moodle-branch: MOODLE_310_STABLE 68 | database: pgsql 69 | - php: 7.4 70 | moodle-branch: MOODLE_39_STABLE 71 | database: pgsql 72 | - php: 7.4 73 | moodle-branch: MOODLE_38_STABLE 74 | database: pgsql 75 | 76 | - php: 7.3 77 | moodle-branch: MOODLE_37_STABLE 78 | database: pgsql 79 | plugin-ci: ^3 80 | 81 | # Lowest php versions supported by each branch (with main always being tested twice). 82 | - php: 8.1 83 | moodle-branch: main 84 | database: pgsql 85 | - php: 8.1 86 | moodle-branch: main 87 | database: pgsql 88 | - php: 8.1 89 | moodle-branch: MOODLE_404_STABLE 90 | database: pgsql 91 | 92 | - php: 8.0 93 | moodle-branch: MOODLE_403_STABLE 94 | database: pgsql 95 | - php: 8.0 96 | moodle-branch: MOODLE_402_STABLE 97 | database: pgsql 98 | 99 | - php: 7.4 100 | moodle-branch: MOODLE_401_STABLE 101 | database: pgsql 102 | 103 | - php: 7.3 104 | moodle-branch: MOODLE_400_STABLE 105 | database: pgsql 106 | plugin-ci: ^3 107 | - php: 7.3 108 | moodle-branch: MOODLE_311_STABLE 109 | database: pgsql 110 | plugin-ci: ^3 111 | 112 | - php: 7.2 113 | moodle-branch: MOODLE_310_STABLE 114 | database: pgsql 115 | plugin-ci: ^3 116 | - php: 7.2 117 | moodle-branch: MOODLE_39_STABLE 118 | database: pgsql 119 | plugin-ci: ^3 120 | steps: 121 | - name: Check out repository code 122 | uses: actions/checkout@v4 123 | with: 124 | path: plugin 125 | 126 | - name: Setup PHP ${{ matrix.php }} 127 | uses: shivammathur/setup-php@v2 128 | with: 129 | php-version: ${{ matrix.php }} 130 | extensions: ${{ matrix.extensions }} 131 | ini-values: max_input_vars=5000 132 | coverage: none 133 | 134 | - name: Initialise moodle-plugin-ci 135 | run: | 136 | if [ ${{ matrix.plugin-ci }} ]; then 137 | composer create-project -n --no-dev --prefer-dist moodlehq/moodle-plugin-ci ci ${{ matrix.plugin-ci }} 138 | else 139 | composer create-project -n --no-dev --prefer-dist moodlehq/moodle-plugin-ci ci ^4 140 | fi 141 | echo $(cd ci/bin; pwd) >> $GITHUB_PATH 142 | echo $(cd ci/vendor/bin; pwd) >> $GITHUB_PATH 143 | sudo locale-gen en_AU.UTF-8 144 | echo "NVM_DIR=$HOME/.nvm" >> $GITHUB_ENV 145 | 146 | - name: Install moodle-plugin-ci 147 | run: | 148 | moodle-plugin-ci install --plugin ./plugin --db-host=127.0.0.1 149 | env: 150 | DB: ${{ matrix.database }} 151 | MOODLE_BRANCH: ${{ matrix.moodle-branch }} 152 | IGNORE_PATHS: tests/fixtures 153 | 154 | - name: PHP Lint 155 | if: ${{ !cancelled() }} 156 | run: moodle-plugin-ci phplint 157 | 158 | - name: PHP Mess Detector 159 | continue-on-error: true # This step will show errors but will not fail 160 | if: ${{ !cancelled() }} 161 | run: moodle-plugin-ci phpmd 162 | 163 | - name: Moodle Code Checker 164 | if: ${{ !cancelled() }} 165 | run: moodle-plugin-ci codechecker --max-warnings 0 166 | 167 | - name: Validating 168 | if: ${{ !cancelled() }} 169 | run: moodle-plugin-ci validate 170 | 171 | - name: Check upgrade savepoints 172 | if: ${{ !cancelled() }} 173 | run: moodle-plugin-ci savepoints 174 | 175 | - name: Mustache Lint 176 | if: ${{ !cancelled() }} 177 | run: moodle-plugin-ci mustache 178 | 179 | - name: Grunt 180 | if: ${{ !cancelled() }} 181 | run: moodle-plugin-ci grunt --max-lint-warnings 0 182 | 183 | - name: PHPUnit tests 184 | if: ${{ !cancelled() }} 185 | run: moodle-plugin-ci phpunit 186 | 187 | - name: Behat features 188 | if: ${{ !cancelled() }} 189 | run: moodle-plugin-ci behat --profile chrome 190 | 191 | - name: Mark cancelled jobs as failed. 192 | if: ${{ cancelled() }} 193 | run: exit 1 194 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Moodle PHPdoc Checker 2 | ===================== 3 | 4 | [![Moodlecheck CI](https://github.com/moodlehq/moodle-local_moodlecheck/actions/workflows/ci.yml/badge.svg)](https://github.com/moodlehq/moodle-local_moodlecheck/actions/workflows/ci.yml) 5 | 6 | Important note: 7 | --------------- 8 | This plugin is becoming deprecated and, soon, [moodle-cs](https://github.com/moodlehq/moodle-cs) will get 9 | the baton about all the PHPDoc checks. The switch is planned to be implemented progressively. For more 10 | details, see the [announcement](https://moodle.org/mod/forum/discuss.php?d=455786) and the 11 | [tracking issue](https://github.com/moodlehq/moodle-cs/issues/30). 12 | 13 | Installation: 14 | ------------- 15 | 16 | Install the source into the local/moodlecheck directory in your moodle 17 | 18 | Log in as admin and select: 19 | 20 | Settings 21 | Site administration 22 | Development 23 | Moodle PHPdoc check 24 | 25 | Enter paths to check and select rules to use. 26 | 27 | Customization: 28 | -------------- 29 | 30 | You can add new rules by adding new php files in rules/ directory, 31 | they will be included automatically. 32 | 33 | Look at other files in this directory for examples. 34 | 35 | Please note that if you register the rule with code 'mynewrule', 36 | the rule registry will look in language file for strings 37 | 'rule_mynewrule' and 'error_mynewrule'. If they are not present, 38 | the rule code will be used instead of the rule name and 39 | default error message appears. 40 | -------------------------------------------------------------------------------- /classes/privacy/provider.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | namespace local_moodlecheck\privacy; 18 | 19 | /** 20 | * Privacy provider for local_moodlecheck implementing null provider 21 | * 22 | * @package local_moodlecheck 23 | * @copyright 2019 Paul Holden 24 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 25 | */ 26 | class provider implements \core_privacy\local\metadata\null_provider { 27 | 28 | /** 29 | * Get the language string identifier with the component's language 30 | * file to explain why this plugin stores no data. 31 | * 32 | * @return string 33 | */ 34 | public static function get_reason(): string { 35 | return 'privacy:metadata'; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /cli/moodlecheck.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Command-line script to Moodle PHP code check 19 | * 20 | * @package local_moodlecheck 21 | * @copyright 2012 Marina Glancy 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | 25 | define('CLI_SCRIPT', true); 26 | 27 | require(dirname(dirname(dirname(dirname(__FILE__)))).'/config.php'); 28 | require_once($CFG->libdir.'/clilib.php'); // CLI only functions. 29 | require_once($CFG->dirroot. '/local/moodlecheck/locallib.php'); 30 | 31 | // Now get cli options. 32 | list($options, $unrecognized) = cli_get_params( 33 | ['help' => false, 'path' => '', 'format' => 'xml', 'exclude' => '', 'rules' => 'all', 'componentsfile' => ''], 34 | ['h' => 'help', 'p' => 'path', 'f' => 'format', 'e' => 'exclude', 'r' => 'rules', 'c' => 'componentsfile'] 35 | ); 36 | 37 | $rules = preg_split('/\s*[\n,;]\s*/', trim($options['rules']), -1, PREG_SPLIT_NO_EMPTY); 38 | $paths = preg_split('/\s*[\n,;]\s*/', trim($options['path']), -1, PREG_SPLIT_NO_EMPTY); 39 | $exclude = preg_split('/\s*[\n,;]\s*/', trim($options['exclude']), -1, PREG_SPLIT_NO_EMPTY); 40 | if (!in_array($options['format'], ['xml', 'html', 'text'])) { 41 | unset($options['format']); 42 | } 43 | 44 | if ($options['help'] || !isset($options['format']) || !count($paths)) { 45 | $help = "Perform Moodle PHP code check. 46 | 47 | This script checks all files found in the specified paths against defined rules 48 | 49 | Options: 50 | -h, --help Print out this help 51 | -p, --path Path(s) to check. Specify paths from the root directory, 52 | separate multiple paths with comman, semicolon or newline 53 | -e, --exclude Path(s) or files to be excluded. Non-php files are 54 | automatically excluded 55 | -r, --rules List rules to check against. Default 'all' 56 | -f, --format Output format: html, xml, text. Default 'xml' 57 | -c, --componentsfile Path to one file contaning the list of valid components in format: type, name, fullpath 58 | 59 | Example: 60 | \$sudo -u www-data /usr/bin/php local/moodlecheck/cli/moodlecheck.php -p=local/moodlecheck 61 | "; 62 | 63 | echo $help; 64 | die; 65 | } 66 | 67 | // Include all files from rules directory. 68 | if ($dh = opendir($CFG->dirroot. '/local/moodlecheck/rules')) { 69 | while (($file = readdir($dh)) !== false) { 70 | if ($file != '.' && $file != '..') { 71 | $pathinfo = pathinfo($file); 72 | if (isset($pathinfo['extension']) && $pathinfo['extension'] == 'php') { 73 | require_once($CFG->dirroot. '/local/moodlecheck/rules/'. $file); 74 | } 75 | } 76 | } 77 | closedir($dh); 78 | } 79 | 80 | $output = $PAGE->get_renderer('local_moodlecheck'); 81 | 82 | if (count($rules) && !in_array('all', $rules)) { 83 | foreach ($rules as $code) { 84 | local_moodlecheck_registry::enable_rule($code); 85 | } 86 | } else { 87 | local_moodlecheck_registry::enable_all_rules(); 88 | } 89 | foreach ($paths as $filename) { 90 | $path = new local_moodlecheck_path($filename, $exclude); 91 | local_moodlecheck_path::get_components($options['componentsfile']); 92 | echo $output->display_path($path, $options['format']); 93 | } 94 | -------------------------------------------------------------------------------- /file.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * File handling in moodlecheck 19 | * 20 | * @package local_moodlecheck 21 | * @copyright 2012 Marina Glancy 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | 25 | // phpcs:disable moodle.Commenting.VariableComment.Missing 26 | // phpcs:disable moodle.Commenting.MissingDocblock.Missing 27 | // phpcs:disable moodle.Commenting.VariableComment.TagNotAllowed 28 | 29 | defined('MOODLE_INTERNAL') || die; 30 | 31 | /** 32 | * Handles one file being validated 33 | * 34 | * @package local_moodlecheck 35 | * @copyright 2012 Marina Glancy 36 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 37 | */ 38 | class local_moodlecheck_file { 39 | private const MODIFIERS = [T_ABSTRACT, T_PRIVATE, T_PUBLIC, T_PROTECTED, T_STATIC, T_VAR, T_FINAL, T_CONST]; 40 | 41 | protected $filepath = null; 42 | protected $needsvalidation = null; 43 | protected $errors = null; 44 | protected $tokens = null; 45 | protected $tokenscount = 0; 46 | protected $classes = null; 47 | protected $interfaces = null; 48 | protected $traits = null; 49 | protected $functions = null; 50 | protected $filephpdocs = null; 51 | protected $allphpdocs = null; 52 | 53 | /** 54 | * Creates an object from path to the file 55 | * 56 | * @param string $filepath 57 | */ 58 | public function __construct($filepath) { 59 | $this->filepath = str_replace(DIRECTORY_SEPARATOR, "/", $filepath); 60 | } 61 | 62 | /** 63 | * Cleares all cached stuff to free memory 64 | */ 65 | protected function clear_memory() { 66 | $this->tokens = null; 67 | $this->tokenscount = 0; 68 | $this->classes = null; 69 | $this->interfaces = null; 70 | $this->traits = null; 71 | $this->functions = null; 72 | $this->filephpdocs = null; 73 | $this->allphpdocs = null; 74 | } 75 | 76 | /** 77 | * Returns true if this file is inside specified directory 78 | * 79 | * @param string $dirpath 80 | * @return bool 81 | */ 82 | public function is_in_dir($dirpath) { 83 | // Normalize dir path to also work with Windows style directory separators... 84 | $dirpath = str_replace(DIRECTORY_SEPARATOR, "/", $dirpath); 85 | if (substr($dirpath, -1) != '/') { 86 | $dirpath .= '/'; 87 | } 88 | return substr($this->filepath, 0, strlen($dirpath)) == $dirpath; 89 | } 90 | 91 | /** 92 | * Retuns true if the file needs validation (is PHP file) 93 | * 94 | * @return bool 95 | */ 96 | public function needs_validation() { 97 | if ($this->needsvalidation === null) { 98 | $this->needsvalidation = true; 99 | $pathinfo = pathinfo($this->filepath); 100 | if (empty($pathinfo['extension']) || ($pathinfo['extension'] != 'php' && $pathinfo['extension'] != 'inc')) { 101 | $this->needsvalidation = false; 102 | } 103 | } 104 | return $this->needsvalidation; 105 | } 106 | 107 | /** 108 | * Validates a file over registered rules and returns an array of errors 109 | * 110 | * @return array 111 | */ 112 | public function validate() { 113 | if ($this->errors !== null) { 114 | return $this->errors; 115 | } 116 | $this->errors = []; 117 | if (!$this->needs_validation()) { 118 | return $this->errors; 119 | } 120 | // If the file doesn't have tokens, has one or misses open tag, report it as one more error and stop processing. 121 | if (!$this->get_tokens() || 122 | count($this->get_tokens()) === 1 || 123 | (isset($this->get_tokens()[0][0]) && $this->get_tokens()[0][0] !== T_OPEN_TAG)) { 124 | $this->errors[] = [ 125 | 'line' => 1, 126 | 'severity' => 'error', 127 | 'message' => get_string('error_emptynophpfile', 'local_moodlecheck'), 128 | 'source' => 'Ø', 129 | ]; 130 | return $this->errors; 131 | } 132 | foreach (local_moodlecheck_registry::get_enabled_rules() as $code => $rule) { 133 | $ruleerrors = $rule->validatefile($this); 134 | if (count($ruleerrors)) { 135 | $this->errors = array_merge($this->errors, $ruleerrors); 136 | } 137 | } 138 | $this->clear_memory(); 139 | return $this->errors; 140 | } 141 | 142 | /** 143 | * Return the filepath of the file. 144 | * 145 | * @return string 146 | */ 147 | public function get_filepath() { 148 | return $this->filepath; 149 | } 150 | 151 | /** 152 | * Returns a file contents converted to array of tokens. 153 | * 154 | * Each token is an array with two elements: code of token and text 155 | * For simple 1-character tokens the code is -1 156 | * 157 | * @return array 158 | */ 159 | public function &get_tokens() { 160 | if ($this->tokens === null) { 161 | $source = file_get_contents($this->filepath); 162 | $this->tokens = @token_get_all($source); 163 | $this->tokenscount = count($this->tokens); 164 | $inquotes = -1; 165 | for ($tid = 0; $tid < $this->tokenscount; $tid++) { 166 | if (is_string($this->tokens[$tid])) { 167 | // Simple 1-character token. 168 | $this->tokens[$tid] = [-1, $this->tokens[$tid]]; 169 | } 170 | // And now, for the purpose of this project we don't need strings with variables inside to be parsed 171 | // so when we find string in double quotes that is split into several tokens and combine all content in one token. 172 | if ($this->tokens[$tid][0] == -1 && $this->tokens[$tid][1] == '"') { 173 | if ($inquotes == -1) { 174 | $inquotes = $tid; 175 | $this->tokens[$tid][0] = T_STRING; 176 | } else { 177 | $this->tokens[$inquotes][1] .= $this->tokens[$tid][1]; 178 | $this->tokens[$tid] = [T_WHITESPACE, '']; 179 | $inquotes = -1; 180 | } 181 | } else if ($inquotes > -1) { 182 | $this->tokens[$inquotes][1] .= $this->tokens[$tid][1]; 183 | $this->tokens[$tid] = [T_WHITESPACE, '']; 184 | } 185 | } 186 | } 187 | return $this->tokens; 188 | } 189 | 190 | /** 191 | * Returns all artifacts (classes, interfaces, traits) found in file 192 | * 193 | * Returns 3 arrays (classes, interfaces and traits) of objects where each element represents an artifact: 194 | * ->type : token type of the artifact (T_CLASS, T_INTERFACE, T_TRAIT) 195 | * ->typestring : type of the artifact as a string ('class', 'interface', 'trait') 196 | * ->name : name of the artifact 197 | * ->tagpair : array of two elements: id of token { for the class and id of token } (false if not found) 198 | * ->phpdocs : phpdocs for this artifact (instance of local_moodlecheck_phpdocs or false if not found) 199 | * ->boundaries : array with ids of first and last token for this artifact. 200 | * ->hasextends : boolean indicating whether this artifact has an `extends` clause 201 | * ->hasimplements : boolean indicating whether this artifact has an `implements` clause 202 | * 203 | * @return array with 3 elements (classes, interfaces & traits), each being an array. 204 | */ 205 | public function get_artifacts() { 206 | $types = [T_CLASS, T_INTERFACE, T_TRAIT]; // We are interested on these. 207 | $artifacts = array_combine($types, $types); 208 | if ($this->classes === null) { 209 | $this->classes = []; 210 | $this->interfaces = []; 211 | $this->traits = []; 212 | $tokens = &$this->get_tokens(); 213 | for ($tid = 0; $tid < $this->tokenscount; $tid++) { 214 | if (isset($artifacts[$this->tokens[$tid][0]])) { 215 | if ($this->previous_nonspace_token($tid) === "::") { 216 | // Skip use of the ::class special constant. 217 | continue; 218 | } 219 | 220 | if ($this->previous_nonspace_token($tid) == 'new') { 221 | // This looks to be an anonymous class. 222 | 223 | $tpid = $tid; // Let's keep the original $tid and use own for anonymous searches. 224 | if ($this->next_nonspace_token($tpid) == '(') { 225 | // It may be an anonymous class with parameters, let's skip them 226 | // by advancing till we find the corresponding bracket closing token. 227 | $level = 0; // To control potential nesting of brackets within the params. 228 | while ($tpid = $this->next_nonspace_token($tpid, true)) { 229 | if ($this->tokens[$tpid][1] == '(') { 230 | $level++; 231 | } 232 | if ($this->tokens[$tpid][1] == ')') { 233 | $level--; 234 | // We are back to level 0, we are done (have walked over all params). 235 | if ($level === 0) { 236 | $tpid = $tpid; 237 | break; 238 | } 239 | } 240 | } 241 | } 242 | 243 | if ($this->next_nonspace_token($tpid) == '{') { 244 | // An anonymous class in the format `new class {`. 245 | continue; 246 | } 247 | 248 | if ($this->next_nonspace_token($tpid) == 'extends') { 249 | // An anonymous class in the format `new class extends otherclasses {`. 250 | continue; 251 | } 252 | 253 | if ($this->next_nonspace_token($tpid) == 'implements') { 254 | // An anonymous class in the format `new class implements someinterface {`. 255 | continue; 256 | } 257 | } 258 | $artifact = new stdClass(); 259 | $artifact->type = $artifacts[$this->tokens[$tid][0]]; 260 | $artifact->typestring = $this->tokens[$tid][1]; 261 | 262 | $artifact->tid = $tid; 263 | $artifact->name = $this->next_nonspace_token($tid); 264 | $artifact->phpdocs = $this->find_preceeding_phpdoc($tid); 265 | $artifact->tagpair = $this->find_tag_pair($tid, '{', '}'); 266 | 267 | $artifact->hasextends = false; 268 | $artifact->hasimplements = false; 269 | 270 | if ($artifact->tagpair) { 271 | // Iterate over the remaining tokens in the class definition (until opening {). 272 | foreach (array_slice($this->tokens, $tid, $artifact->tagpair[0] - $tid) as $token) { 273 | if ($token[0] == T_EXTENDS) { 274 | $artifact->hasextends = true; 275 | } 276 | if ($token[0] == T_IMPLEMENTS) { 277 | $artifact->hasimplements = true; 278 | } 279 | } 280 | } 281 | 282 | $artifact->boundaries = $this->find_object_boundaries($artifact); 283 | switch ($artifact->type) { 284 | case T_CLASS: 285 | $this->classes[] = $artifact; 286 | break; 287 | case T_INTERFACE: 288 | $this->interfaces[] = $artifact; 289 | break; 290 | case T_TRAIT: 291 | $this->traits[] = $artifact; 292 | break; 293 | } 294 | } 295 | } 296 | } 297 | return [T_CLASS => $this->classes, T_INTERFACE => $this->interfaces, T_TRAIT => $this->traits]; 298 | } 299 | 300 | /** 301 | * Like {@see get_artifacts()}, but returns classes, interfaces and traits in a single flat array. 302 | * 303 | * @return stdClass[] 304 | * @see get_artifacts() 305 | */ 306 | public function get_artifacts_flat(): array { 307 | $artifacts = $this->get_artifacts(); 308 | return array_merge($artifacts[T_CLASS], $artifacts[T_INTERFACE], $artifacts[T_TRAIT]); 309 | } 310 | 311 | /** 312 | * Returns all classes found in file 313 | * 314 | * Returns array of objects where each element represents a class: 315 | * $class->name : name of the class 316 | * $class->tagpair : array of two elements: id of token { for the class and id of token } (false if not found) 317 | * $class->phpdocs : phpdocs for this class (instance of local_moodlecheck_phpdocs or false if not found) 318 | * $class->boundaries : array with ids of first and last token for this class 319 | */ 320 | public function &get_classes() { 321 | return $this->get_artifacts()[T_CLASS]; 322 | } 323 | 324 | /** 325 | * Returns all functions (including class methods) found in file 326 | * 327 | * Returns array of objects where each element represents a function: 328 | * $function->tid : token id of the token 'function' 329 | * $function->name : name of the function 330 | * $function->phpdocs : phpdocs for this function (instance of local_moodlecheck_phpdocs or false if not found) 331 | * TODO: Delete this because it's not used anymore (2023). See #97 332 | * $function->class : containing class object (false if this is not a class method) 333 | * $function->owner : containing artifact object (class, interface, trait, or false if this is not a method) 334 | * $function->fullname : name of the function with class name (if applicable) 335 | * $function->accessmodifiers : tokens like static, public, protected, abstract, etc. 336 | * $function->tagpair : array of two elements: id of token { for the function and id of token } (false if not found) 337 | * $function->argumentstoken : array of tokens found inside function arguments 338 | * $function->arguments : array of function arguments where each element is [typename, variablename] 339 | * $function->boundaries : array with ids of first and last token for this function 340 | * 341 | * @return array 342 | */ 343 | public function &get_functions() { 344 | if ($this->functions === null) { 345 | $this->functions = []; 346 | $tokens = &$this->get_tokens(); 347 | for ($tid = 0; $tid < $this->tokenscount; $tid++) { 348 | if ($this->tokens[$tid][0] == T_USE) { 349 | // Skip the entire use statement, to avoid interpreting "use function" as a function. 350 | $tid = $this->end_of_statement($tid); 351 | continue; 352 | } 353 | 354 | if ($this->tokens[$tid][0] == T_FUNCTION) { 355 | $function = new stdClass(); 356 | $function->tid = $tid; 357 | $function->fullname = $function->name = $this->next_nonspace_token($tid, false, ['&']); 358 | 359 | // Skip anonymous functions. 360 | if ($function->name == '(') { 361 | continue; 362 | } 363 | $function->phpdocs = $this->find_preceeding_phpdoc($tid); 364 | $function->class = $this->is_inside_class($tid); 365 | $function->owner = $this->is_inside_artifact($tid); 366 | if ($function->owner !== false) { 367 | $function->fullname = $function->owner->name . '::' . $function->name; 368 | } 369 | $function->accessmodifiers = $this->find_access_modifiers($tid); 370 | if (!in_array(T_ABSTRACT, $function->accessmodifiers)) { 371 | $function->tagpair = $this->find_tag_pair($tid, '{', '}'); 372 | } else { 373 | $function->tagpair = false; 374 | } 375 | 376 | $argumentspair = $this->find_tag_pair($tid, '(', ')', ['{', ';']); 377 | if ($argumentspair !== false && $argumentspair[1] - $argumentspair[0] > 1) { 378 | $function->argumentstokens = $this->break_tokens_by( 379 | array_slice($tokens, $argumentspair[0] + 1, $argumentspair[1] - $argumentspair[0] - 1) ); 380 | } else { 381 | $function->argumentstokens = []; 382 | } 383 | $function->arguments = []; 384 | foreach ($function->argumentstokens as $argtokens) { 385 | // If the token is completely empty then it's not an argument. This happens, for example, with 386 | // trailing commas in parameters, allowed since PHP 8.0 and break_tokens_by() returns it that way. 387 | if (empty($argtokens)) { 388 | continue; 389 | } 390 | $possibletypes = []; 391 | $variable = null; 392 | $splat = false; 393 | 394 | if (PHP_VERSION_ID < 80000) { 395 | $maxindex = array_key_last($argtokens); 396 | // In PHP 7.4 and earlier, the namespace was parsed separately, for example: 397 | // \core\course would be come '\', 'core', '\', 'course'. 398 | // From PHP 8.0 this becomes '\core\course'. 399 | // To address this we modify the tokens to match the PHP 8.0 format. 400 | // This is a bit of a hack, but it works. 401 | // Note: argtokens contains arrays of [token index, string content, line number]. 402 | for ($j = 0; $j < $maxindex; $j++) { 403 | if ($argtokens[$j][0] === T_NS_SEPARATOR || $argtokens[$j][0] === T_STRING) { 404 | $argtokens[$j][0] = T_STRING; 405 | $initialtoken = $j; 406 | for ($namespacesearch = $j + 1; $namespacesearch < $maxindex; $namespacesearch++) { 407 | switch ($argtokens[$namespacesearch][0]) { 408 | case T_STRING: 409 | case T_NS_SEPARATOR: 410 | break; 411 | default: 412 | break 2; 413 | } 414 | $argtokens[$initialtoken][1] .= $argtokens[$namespacesearch][1]; 415 | unset($argtokens[$namespacesearch]); 416 | $j = $namespacesearch; 417 | } 418 | } 419 | } 420 | } 421 | $argtokens = array_values($argtokens); 422 | 423 | for ($j = 0; $j < count($argtokens); $j++) { 424 | if (version_compare(PHP_VERSION, '8.1.0') >= 0) { 425 | // T_READONLY introduced in PHP 8.1. 426 | if ($argtokens[$j][0] === T_READONLY) { 427 | continue; 428 | } 429 | } 430 | switch ($argtokens[$j][0]) { 431 | // Skip any whitespace, or argument visibility. 432 | case T_COMMENT: 433 | case T_DOC_COMMENT: 434 | case T_WHITESPACE: 435 | case T_PUBLIC: 436 | case T_PROTECTED: 437 | case T_PRIVATE: 438 | continue 2; 439 | case T_VARIABLE: 440 | // The variale name, adding in the vardiadic if required. 441 | $variable = ($splat) ? '...' . $argtokens[$j][1] : $argtokens[$j][1]; 442 | continue 2; 443 | case T_ELLIPSIS: 444 | // For example ...$example 445 | // Variadic function. 446 | $splat = true; 447 | continue 2; 448 | } 449 | switch ($argtokens[$j][1]) { 450 | case '|': 451 | // Union types. 452 | case '&': 453 | // Return by reference. 454 | continue 2; 455 | case '?': 456 | // Nullable type. 457 | $possibletypes[] = 'null'; 458 | continue 2; 459 | case '=': 460 | // Default value. 461 | $j = count($argtokens); 462 | continue 2; 463 | } 464 | 465 | $possibletypes[] = $argtokens[$j][1]; 466 | } 467 | 468 | $type = implode('|', $possibletypes); 469 | 470 | $function->arguments[] = [$type, $variable]; 471 | } 472 | $function->boundaries = $this->find_object_boundaries($function); 473 | $this->functions[] = $function; 474 | } 475 | } 476 | } 477 | return $this->functions; 478 | } 479 | 480 | /** 481 | * Finds and returns object boundaries 482 | * 483 | * $obj is an object representing function, class or variable. This function 484 | * returns token ids for the very first token applicable to this object 485 | * to the very last 486 | * 487 | * @param stdClass $obj 488 | * @return array 489 | */ 490 | public function find_object_boundaries($obj) { 491 | $boundaries = [$obj->tid, $obj->tid]; 492 | $tokens = &$this->get_tokens(); 493 | if (!empty($obj->tagpair)) { 494 | $boundaries[1] = $obj->tagpair[1]; 495 | } else { 496 | // Find the next ; char. 497 | for ($i = $boundaries[1]; $i < $this->tokenscount; $i++) { 498 | if ($tokens[$i][1] == ';') { 499 | $boundaries[1] = $i; 500 | break; 501 | } 502 | } 503 | } 504 | if (isset($obj->phpdocs) && $obj->phpdocs instanceof local_moodlecheck_phpdocs) { 505 | $boundaries[0] = $obj->phpdocs->get_original_token_id(); 506 | } else { 507 | // Walk back until we meet one of the characters that means that we are outside of the object. 508 | for ($i = $boundaries[0] - 1; $i >= 0; $i--) { 509 | $token = $tokens[$i]; 510 | if (in_array($token[0], [T_OPEN_TAG, T_OPEN_TAG_WITH_ECHO, T_CLOSE_TAG])) { 511 | break; 512 | } else if (in_array($token[1], ['{', '}', '(', ';', ',', '['])) { 513 | break; 514 | } 515 | } 516 | // Walk forward to the next meaningful token skipping all spaces and comments. 517 | for ($i = $i + 1; $i < $boundaries[0]; $i++) { 518 | if (!in_array($tokens[$i][0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT])) { 519 | break; 520 | } 521 | } 522 | $boundaries[0] = $i; 523 | } 524 | return $boundaries; 525 | } 526 | 527 | /** 528 | * Checks if the token with id $tid in inside some class 529 | * 530 | * @param int $tid 531 | * @return stdClass|false containing class or false if this is not a member 532 | */ 533 | public function is_inside_class($tid) { 534 | $classes = &$this->get_classes(); 535 | $classescnt = count($classes); 536 | for ($clid = 0; $clid < $classescnt; $clid++) { 537 | if ($classes[$clid]->boundaries[0] <= $tid && $classes[$clid]->boundaries[1] >= $tid) { 538 | return $classes[$clid]; 539 | } 540 | } 541 | return false; 542 | } 543 | 544 | /** 545 | * Checks if the token with id $tid in inside some artifact (class, interface, or trait). 546 | * 547 | * @param int $tid 548 | * @return stdClass|false containing artifact or false if this is not a member 549 | */ 550 | public function is_inside_artifact(int $tid) { 551 | $artifacts = $this->get_artifacts_flat(); 552 | foreach ($artifacts as $artifact) { 553 | if ($artifact->boundaries[0] <= $tid && $artifact->boundaries[1] >= $tid) { 554 | return $artifact; 555 | } 556 | } 557 | return false; 558 | } 559 | 560 | /** 561 | * Checks if the token with id $tid in inside some function or class method 562 | * 563 | * @param int $tid 564 | * @return stdClass|false containing function or false if this is not inside a function 565 | */ 566 | public function is_inside_function($tid) { 567 | $functions = &$this->get_functions(); 568 | $functionscnt = count($functions); 569 | for ($fid = 0; $fid < $functionscnt; $fid++) { 570 | if ($functions[$fid]->boundaries[0] <= $tid && $functions[$fid]->boundaries[1] >= $tid) { 571 | return $functions[$fid]; 572 | } 573 | } 574 | return false; 575 | } 576 | 577 | /** 578 | * Checks if token with id $tid is a whitespace 579 | * 580 | * @param int $tid 581 | * @return boolean 582 | */ 583 | public function is_whitespace_token($tid) { 584 | $this->get_tokens(); 585 | return (isset($this->tokens[$tid][0]) && $this->tokens[$tid][0] == T_WHITESPACE); 586 | } 587 | 588 | /** 589 | * Returns how many line feeds are in this token 590 | * 591 | * @param int $tid 592 | * @return int 593 | */ 594 | public function is_multiline_token($tid) { 595 | $this->get_tokens(); 596 | return substr_count($this->tokens[$tid][1], "\n"); 597 | } 598 | 599 | /** 600 | * Returns the first token which is not whitespace following the token with id $tid 601 | * 602 | * Also returns false if no meaningful token found till the end of file 603 | * 604 | * @param int $tid 605 | * @param bool $returnid 606 | * @param array $alsoignore 607 | * @return int|false 608 | */ 609 | public function next_nonspace_token($tid, $returnid = false, $alsoignore = []) { 610 | $this->get_tokens(); 611 | for ($i = $tid + 1; $i < $this->tokenscount; $i++) { 612 | if (!$this->is_whitespace_token($i) && !in_array($this->tokens[$i][1], $alsoignore)) { 613 | if ($returnid) { 614 | return $i; 615 | } else { 616 | return $this->tokens[$i][1]; 617 | } 618 | } 619 | } 620 | return false; 621 | } 622 | 623 | /** 624 | * Returns the first token which is not whitespace before the token with id $tid 625 | * 626 | * Also returns false if no meaningful token found till the beginning of file 627 | * 628 | * @param int $tid 629 | * @param bool $returnid 630 | * @param array $alsoignore 631 | * @return int|false 632 | */ 633 | public function previous_nonspace_token($tid, $returnid = false, $alsoignore = []) { 634 | $this->get_tokens(); 635 | for ($i = $tid - 1; $i > 0; $i--) { 636 | if (!$this->is_whitespace_token($i) && !in_array($this->tokens[$i][1], $alsoignore)) { 637 | if ($returnid) { 638 | return $i; 639 | } else { 640 | return $this->tokens[$i][1]; 641 | } 642 | } 643 | } 644 | return false; 645 | } 646 | 647 | /** 648 | * Returns the next semicolon or close tag following $tid, or the last token of the file, whichever comes first. 649 | * 650 | * @param int $tid starting token 651 | * @return int index of the next semicolon or close tag following $tid, or the last token of the file, whichever 652 | * comes first 653 | */ 654 | public function end_of_statement($tid) { 655 | for (; $tid < $this->tokenscount; $tid++) { 656 | if ($this->tokens[$tid][1] == ";" || $this->tokens[$tid][0] == T_CLOSE_TAG) { 657 | // Semicolons and close tags (?>) end statements. 658 | return $tid; 659 | } 660 | } 661 | // EOF also ends statements. 662 | return $tid; 663 | } 664 | 665 | /** 666 | * Returns all modifiers (private, public, static, ...) preceeding token with id $tid 667 | * 668 | * @param int $tid 669 | * @return array 670 | */ 671 | public function find_access_modifiers($tid) { 672 | $tokens = &$this->get_tokens(); 673 | $modifiers = []; 674 | for ($i = $tid - 1; $i >= 0; $i--) { 675 | if ($this->is_whitespace_token($i)) { 676 | // Skip. 677 | continue; 678 | } else if (in_array($tokens[$i][0], self::MODIFIERS)) { 679 | $modifiers[] = $tokens[$i][0]; 680 | } else { 681 | break; 682 | } 683 | } 684 | return $modifiers; 685 | } 686 | 687 | /** 688 | * Finds phpdocs preceeding the token with id $tid 689 | * 690 | * skips words abstract, private, public, protected and non-multiline whitespaces 691 | * 692 | * @param int $tid 693 | * @return local_moodlecheck_phpdocs|false 694 | */ 695 | public function find_preceeding_phpdoc($tid) { 696 | $tokens = &$this->get_tokens(); 697 | $modifiers = $this->find_access_modifiers($tid); 698 | 699 | for ($i = $tid - 1; $i >= 0; $i--) { 700 | if ($this->is_whitespace_token($i)) { 701 | if ($this->is_multiline_token($i) > 1) { 702 | // More that one line feed means that no phpdocs for this element exists. 703 | return false; 704 | } 705 | } else if ($tokens[$i][0] == T_DOC_COMMENT) { 706 | return $this->get_phpdocs($i); 707 | } else if (in_array($tokens[$i][0], $modifiers)) { 708 | // Just skip. 709 | continue; 710 | } else if (in_array($tokens[$i][1], ['{', '}', ';'])) { 711 | // This means that no phpdocs exists. 712 | return false; 713 | } else if ($tokens[$i][0] == T_COMMENT) { 714 | // This probably needed to be doc_comment. 715 | return false; 716 | } else { 717 | // No idea what it is! 718 | // TODO: change to debugging 719 | // echo "************ Unknown preceeding token id = {$tokens[$i][0]}, text = '{$tokens[$i][1]}' **************
". 720 | return false; 721 | } 722 | } 723 | return false; 724 | } 725 | 726 | /** 727 | * Skips any tokens that _could be_ part of a type of a typed property definition. 728 | * 729 | * @param int $tid the token before which a type is expected 730 | * @return int the token id (`< $tid`) directly before the first token of the type. If there is no type, this will 731 | * be the token directly preceding `$tid`. 732 | */ 733 | private function skip_preceding_type(int $tid): int { 734 | for ($i = $tid - 1; $i >= 0; $i--) { 735 | if ($this->is_whitespace_token($i)) { 736 | continue; 737 | } 738 | 739 | $token = $this->tokens[$i]; 740 | 741 | if (in_array($token[0], self::MODIFIERS)) { 742 | // This looks like the last modifier. Return the token after it. 743 | return $i + 1; 744 | } else if (in_array($token[1], ['{', '}', ';'])) { 745 | // We've gone past the beginning of the statement. This isn't possible in valid PHP, but still... 746 | // Return the first token of the statement we were in. 747 | return $i + 1; 748 | } 749 | 750 | // This is something else. Let's assume it to be part of the property's type and skip it. 751 | } 752 | 753 | // We've gone all the way to the start of the file, which shouldn't be possible in valid PHP. 754 | return 0; 755 | } 756 | 757 | /** 758 | * Finds the next pair of matching open and close symbols (usually some sort of brackets) 759 | * 760 | * @param int $startid id of token where we start looking from 761 | * @param string $opensymbol opening symbol (, { or [ 762 | * @param string $closesymbol closing symbol ), } or ] respectively 763 | * @param array $breakifmeet array of symbols that are not allowed not preceed the $opensymbol 764 | * @return array|false array of ids of two corresponding tokens or false if not found 765 | */ 766 | public function find_tag_pair($startid, $opensymbol, $closesymbol, $breakifmeet = []) { 767 | $openid = false; 768 | $counter = 0; 769 | // Also break if we find closesymbol before opensymbol. 770 | $breakifmeet[] = $closesymbol; 771 | for ($i = $startid; $i < $this->tokenscount; $i++) { 772 | if ($openid === false && in_array($this->tokens[$i][1], $breakifmeet)) { 773 | return false; 774 | } else if ($openid !== false && $this->tokens[$i][1] == $closesymbol) { 775 | $counter--; 776 | if ($counter == 0) { 777 | return [$openid, $i]; 778 | } 779 | } else if ($this->tokens[$i][1] == $opensymbol) { 780 | if ($openid === false) { 781 | $openid = $i; 782 | } 783 | $counter++; 784 | } 785 | } 786 | return false; 787 | } 788 | 789 | /** 790 | * Finds the next pair of matching open and close symbols (usually some sort of brackets) 791 | * 792 | * @param array $tokens array of tokens to parse 793 | * @param int $startid id of token where we start looking from 794 | * @param string $opensymbol opening symbol (, { or [ 795 | * @param string $closesymbol closing symbol ), } or ] respectively 796 | * @param array $breakifmeet array of symbols that are not allowed not preceed the $opensymbol 797 | * @return array|false array of ids of two corresponding tokens or false if not found 798 | */ 799 | public function find_tag_pair_inlist(&$tokens, $startid, $opensymbol, $closesymbol, $breakifmeet = []) { 800 | $openid = false; 801 | $counter = 0; 802 | // Also break if we find closesymbol before opensymbol. 803 | $breakifmeet[] = $closesymbol; 804 | $tokenscount = count($tokens); 805 | for ($i = $startid; $i < $tokenscount; $i++) { 806 | if ($openid === false && in_array($tokens[$i][1], $breakifmeet)) { 807 | return false; 808 | } else if ($openid !== false && $tokens[$i][1] == $closesymbol) { 809 | $counter--; 810 | if ($counter == 0) { 811 | return [$openid, $i]; 812 | } 813 | } else if ($tokens[$i][1] == $opensymbol) { 814 | if ($openid === false) { 815 | $openid = $i; 816 | } 817 | $counter++; 818 | } 819 | } 820 | return false; 821 | } 822 | 823 | /** 824 | * Locates the file-level phpdocs and returns it 825 | * 826 | * @return string|false either the contents of phpdocs or false if not found 827 | */ 828 | public function find_file_phpdocs() { 829 | $tokens = &$this->get_tokens(); 830 | if ($this->filephpdocs === null) { 831 | $found = false; 832 | for ($tid = 0; $tid < $this->tokenscount; $tid++) { 833 | if (in_array($tokens[$tid][0], [T_OPEN_TAG, T_WHITESPACE, T_COMMENT])) { 834 | // All allowed before the file-level phpdocs. 835 | $found = false; 836 | } else if ($tokens[$tid][0] == T_DOC_COMMENT) { 837 | $found = $tid; 838 | break; 839 | } else { 840 | // Found something else. 841 | break; 842 | } 843 | } 844 | if ($found !== false) { 845 | // Now let's check that this is not phpdocs to the next function or class or define. 846 | $nexttokenid = $this->next_nonspace_token($tid, true); 847 | if ($nexttokenid !== false) { // Still tokens to look. 848 | $nexttoken = $this->tokens[$nexttokenid]; 849 | if ($this->is_whitespace_token($tid + 1) && $this->is_multiline_token($tid + 1) > 1) { 850 | // At least one empty line follows, it's all right. 851 | $found = $tid; 852 | } else if (in_array($nexttoken[0], 853 | [T_DOC_COMMENT, T_COMMENT, T_REQUIRE_ONCE, T_REQUIRE, T_IF, T_INCLUDE_ONCE, T_INCLUDE])) { 854 | // Something non-documentable following, ok. 855 | $found = $tid; 856 | } else if ($nexttoken[0] == T_STRING && $nexttoken[1] == 'defined') { 857 | // Something non-documentable following. 858 | $found = $tid; 859 | } else if (in_array($nexttoken[0], [T_CLASS, T_ABSTRACT, T_INTERFACE, T_FUNCTION])) { 860 | // This is the doc comment to the following class/function. 861 | $found = false; 862 | } 863 | // TODO: change to debugging. 864 | // } else { 865 | // echo "************ " 866 | // echo "Unknown token following the first phpdocs in " 867 | // echo "{$this->filepath}: id = {$nexttoken[0]}, text = '{$nexttoken[1]}'" 868 | // echo " **************
" 869 | // }. 870 | } 871 | } 872 | $this->filephpdocs = $this->get_phpdocs($found); 873 | } 874 | return $this->filephpdocs; 875 | } 876 | 877 | /** 878 | * Returns all parsed phpdocs block found in file 879 | * 880 | * @return array 881 | */ 882 | public function &get_all_phpdocs() { 883 | if ($this->allphpdocs === null) { 884 | $this->allphpdocs = []; 885 | $this->get_tokens(); 886 | for ($id = 0; $id < $this->tokenscount; $id++) { 887 | if (($this->tokens[$id][0] == T_DOC_COMMENT || $this->tokens[$id][0] === T_COMMENT)) { 888 | $this->allphpdocs[$id] = new local_moodlecheck_phpdocs($this->tokens[$id], $id); 889 | } 890 | } 891 | } 892 | return $this->allphpdocs; 893 | } 894 | 895 | /** 896 | * Returns one parsed phpdocs block found in file 897 | * 898 | * @param int $tid token id of phpdocs 899 | * @return local_moodlecheck_phpdocs 900 | */ 901 | public function get_phpdocs($tid) { 902 | if ($tid === false) { 903 | return false; 904 | } 905 | $this->get_all_phpdocs(); 906 | if (isset($this->allphpdocs[$tid])) { 907 | return $this->allphpdocs[$tid]; 908 | } else { 909 | return false; 910 | } 911 | } 912 | 913 | /** 914 | * Given an array of tokens breaks them into chunks by $separator 915 | * 916 | * @param array $tokens 917 | * @param string $separator one-character separator (usually comma) 918 | * @return array of arrays of tokens 919 | */ 920 | public function break_tokens_by($tokens, $separator = ',') { 921 | $rv = []; 922 | if (!count($tokens)) { 923 | return $rv; 924 | } 925 | $rv[] = []; 926 | for ($i = 0; $i < count($tokens); $i++) { 927 | if ($tokens[$i][1] == $separator) { 928 | $rv[] = []; 929 | } else { 930 | $nextpair = false; 931 | if ($tokens[$i][1] == '(') { 932 | $nextpair = $this->find_tag_pair_inlist($tokens, $i, '(', ')'); 933 | } else if ($tokens[$i][1] == '[') { 934 | $nextpair = $this->find_tag_pair_inlist($tokens, $i, '[', ']'); 935 | } else if ($tokens[$i][1] == '{') { 936 | $nextpair = $this->find_tag_pair_inlist($tokens, $i, '{', '}'); 937 | } 938 | if ($nextpair !== false) { 939 | // Skip to the end of the tag pair. 940 | for ($j = $i; $j <= $nextpair[1]; $j++) { 941 | $rv[count($rv) - 1][] = $tokens[$j]; 942 | } 943 | $i = $nextpair[1]; 944 | } else { 945 | $rv[count($rv) - 1][] = $tokens[$i]; 946 | } 947 | } 948 | } 949 | // Now trim whitespaces. 950 | for ($i = 0; $i < count($rv); $i++) { 951 | if (count($rv[$i]) && $rv[$i][0][0] == T_WHITESPACE) { 952 | array_shift($rv[$i]); 953 | } 954 | if (count($rv[$i]) && $rv[$i][count($rv[$i]) - 1][0] == T_WHITESPACE) { 955 | array_pop($rv[$i]); 956 | } 957 | } 958 | return $rv; 959 | } 960 | 961 | /** 962 | * Returns line number for the token with specified id 963 | * 964 | * @param int $tid id of the token 965 | */ 966 | public function get_line_number($tid) { 967 | $tokens = &$this->get_tokens(); 968 | if (count($tokens[$tid]) > 2) { 969 | return $tokens[$tid][2]; 970 | } else if ($tid == 0) { 971 | return 1; 972 | } else { 973 | return $this->get_line_number($tid - 1) + count(preg_split('/\n/', $tokens[$tid - 1][1])) - 1; 974 | } 975 | } 976 | } 977 | 978 | /** 979 | * Handles one phpdocs 980 | * 981 | * @package local_moodlecheck 982 | * @copyright 2012 Marina Glancy 983 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 984 | */ 985 | class local_moodlecheck_phpdocs { 986 | /** @var array static property storing the list of valid, 987 | * well known, phpdocs tags, always accepted. 988 | * @link http://manual.phpdoc.org/HTMLSmartyConverter/HandS/ */ 989 | public static $validtags = [ 990 | // Behat tags. 991 | 'Given', 992 | 'Then', 993 | 'When', 994 | // PHPUnit tags. 995 | 'codeCoverageIgnore', 996 | 'codeCoverageIgnoreStart', 997 | 'codeCoverageIgnoreEnd', 998 | 'covers', 999 | 'coversDefaultClass', 1000 | 'coversNothing', 1001 | 'dataProvider', 1002 | 'depends', 1003 | 'group', 1004 | 'requires', 1005 | 'runTestsInSeparateProcesses', 1006 | 'runInSeparateProcess', 1007 | 'testWith', 1008 | 'uses', 1009 | // PHPDoc tags. 1010 | 'abstract', 1011 | 'access', 1012 | 'author', 1013 | 'category', 1014 | 'copyright', 1015 | 'deprecated', 1016 | 'example', 1017 | 'final', 1018 | 'filesource', 1019 | 'global', 1020 | 'ignore', 1021 | 'internal', 1022 | 'license', 1023 | 'link', 1024 | 'method', 1025 | 'name', 1026 | 'package', 1027 | 'param', 1028 | 'property', 1029 | 'property-read', 1030 | 'property-write', 1031 | 'return', 1032 | 'see', 1033 | 'since', 1034 | 'static', 1035 | 'staticvar', 1036 | 'subpackage', 1037 | 'throws', 1038 | 'todo', 1039 | 'tutorial', 1040 | 'uses', 1041 | 'var', 1042 | 'version', 1043 | ]; 1044 | 1045 | /** @var array static property storing the list of phpdoc tags 1046 | * allowed to be used inline within Moodle phpdocs. */ 1047 | public static $inlinetags = [ 1048 | 'link', 1049 | 'see', 1050 | ]; 1051 | /** @var array stores the original token for this phpdocs */ 1052 | protected $originaltoken = null; 1053 | /** @var int stores id the original token for this phpdocs */ 1054 | protected $originaltid = null; 1055 | /** @var string text of phpdocs with trimmed start/end tags 1056 | * as well as * in the beginning of the lines */ 1057 | protected $trimmedtext = null; 1058 | /** @var array array of string where each string 1059 | * represents found token (may be also multiline) */ 1060 | protected $tokens; 1061 | 1062 | /** 1063 | * Constructor. Creates an object and parses it 1064 | * 1065 | * @param array $token corresponding token parsed from file 1066 | * @param int $tid id of token in the file 1067 | */ 1068 | public function __construct($token, $tid) { 1069 | $this->originaltoken = $token; 1070 | $this->originaltid = $tid; 1071 | if (preg_match('|^///|', $token[1])) { 1072 | $this->trimmedtext = substr($token[1], 3); 1073 | } else { 1074 | $this->trimmedtext = preg_replace(['|^\s*/\*+|', '|\*+/\s*$|'], '', $token[1]); 1075 | $this->trimmedtext = preg_replace('|\n[ \t]*\*|', "\n", $this->trimmedtext); 1076 | } 1077 | $lines = preg_split('/\n/', $this->trimmedtext); 1078 | 1079 | $this->tokens = []; 1080 | $istokenline = false; 1081 | for ($i = 0; $i < count($lines); $i++) { 1082 | if (preg_match('|^\s*\@(\w+)|', $lines[$i])) { 1083 | // First line of token. 1084 | $istokenline = true; 1085 | $this->tokens[] = $lines[$i]; 1086 | } else if (strlen(trim($lines[$i])) && $istokenline) { 1087 | // Second/third line of token description. 1088 | $this->tokens[count($this->tokens) - 1] .= "\n". $lines[$i]; 1089 | } else { 1090 | $istokenline = false; 1091 | } 1092 | } 1093 | foreach ($this->tokens as $i => $token) { 1094 | $this->tokens[$i] = trim($token); 1095 | } 1096 | } 1097 | 1098 | /** 1099 | * Returns all tags found in phpdocs 1100 | * 1101 | * Returns array of found tokens. Each token is an unparsed string that 1102 | * may consist of multiple lines. 1103 | * Asterisk in the beginning of the lines are trimmed out 1104 | * 1105 | * @param string $tag if specified only tokens matching this tag are returned 1106 | * in this case the token itself is excluded from string 1107 | * @param bool $nonempty if true return only non-empty tags 1108 | * @return array 1109 | */ 1110 | public function get_tags($tag = null, $nonempty = false) { 1111 | if ($tag === null) { 1112 | return $this->tokens; 1113 | } else { 1114 | $rv = []; 1115 | foreach ($this->tokens as $token) { 1116 | if (preg_match('/^\s*\@'.$tag.'\s([^\0]*)$/', $token.' ', $matches) && (!$nonempty || strlen(trim($matches[1])))) { 1117 | $rv[] = trim($matches[1]); 1118 | } 1119 | } 1120 | return $rv; 1121 | } 1122 | } 1123 | 1124 | /** 1125 | * Returns all tags found in phpdocs 1126 | * 1127 | * @deprecated use get_tags() 1128 | * @param string $tag 1129 | * @param bool $nonempty 1130 | * @return array 1131 | */ 1132 | public function get_tokens($tag = null, $nonempty = false) { 1133 | return get_tags($tag, $nonempty); 1134 | } 1135 | 1136 | /** 1137 | * Returns true if this is an inline phpdoc comment (starting with three slashes) 1138 | * 1139 | * @return bool 1140 | */ 1141 | public function is_inline() { 1142 | return preg_match('|^\s*///|', $this->originaltoken[1]); 1143 | } 1144 | 1145 | /** 1146 | * Returns the original token storing this phpdocs 1147 | * 1148 | * @return array 1149 | */ 1150 | public function get_original_token() { 1151 | return $this->originaltoken; 1152 | } 1153 | 1154 | /** 1155 | * Returns the id for original token storing this phpdocs 1156 | * 1157 | * @return int 1158 | */ 1159 | public function get_original_token_id() { 1160 | return $this->originaltid; 1161 | } 1162 | 1163 | /** 1164 | * Returns list of parsed param tokens found in phpdocs 1165 | * 1166 | * Each element is [typename, variablename, variabledescription] 1167 | * 1168 | * @param string $tag tag name to look for. Usually param but may be var for variables 1169 | * @param int $splitlimit maximum number of chunks to return 1170 | * @return array 1171 | */ 1172 | public function get_params($tag = 'param', $splitlimit = 3) { 1173 | $params = []; 1174 | 1175 | foreach ($this->get_tags($tag) as $token) { 1176 | $params[] = preg_split('/\s+/', trim($token), $splitlimit); // AKA 'type $name multi-word description'. 1177 | } 1178 | 1179 | foreach ($params as $key => $param) { 1180 | if (strpos($param[0], '?') !== false) { 1181 | $param[0] = str_replace('?', 'null|', $param[0]); 1182 | } 1183 | $types = explode('|', $param[0]); 1184 | $types = array_map(function($type): string { 1185 | // Normalise array types such as `string[]` to `array`. 1186 | if (substr($type, -2) == '[]') { 1187 | return 'array'; 1188 | } 1189 | return $type; 1190 | }, $types); 1191 | sort($types); 1192 | $params[$key][0] = implode('|', $types); 1193 | } 1194 | return $params; 1195 | } 1196 | 1197 | /** 1198 | * Returns the line number where this phpdoc occurs in the file 1199 | * 1200 | * @param local_moodlecheck_file $file 1201 | * @param string $substring if specified the line number of first occurence of $substring is returned 1202 | * @return int 1203 | */ 1204 | public function get_line_number(local_moodlecheck_file $file, $substring = null) { 1205 | $line0 = $file->get_line_number($this->get_original_token_id()); 1206 | if ($substring === null) { 1207 | return $line0; 1208 | } else { 1209 | $chunks = preg_split('!' . preg_quote($substring, '!') . '!', $this->originaltoken[1]); 1210 | if (count($chunks) > 1) { 1211 | $lines = preg_split('/\n/', $chunks[0]); 1212 | return $line0 + count($lines) - 1; 1213 | } else { 1214 | return $line0; 1215 | } 1216 | } 1217 | } 1218 | 1219 | /** 1220 | * Returns all the inline tags found in the phpdoc 1221 | * 1222 | * This method returns all the phpdocs tags found inline, 1223 | * embed into the phpdocs contents. Only valid tags are 1224 | * considered See {@link self::$validtags}. 1225 | * 1226 | * @param bool $withcurly if true, only tags properly enclosed 1227 | * with curly brackets are returned. Else all the inline tags are returned. 1228 | * @param bool $withcontent if true, the contents after the tag are also returned. 1229 | * Else, the tags are returned both as key and values (for BC). 1230 | * 1231 | * @return array inline tags found in the phpdoc, with contents if specified. 1232 | */ 1233 | public function get_inline_tags($withcurly = true, $withcontent = false) { 1234 | $inlinetags = []; 1235 | // Trim the non-inline phpdocs tags. 1236 | $text = preg_replace('|^\s*@?|m', '', $this->trimmedtext); 1237 | if ($withcurly) { 1238 | $regex = '#{@([a-z\-]*)(.*?)[}\n]#'; 1239 | } else { 1240 | $regex = '#@([a-z\-]*)(.*?)[}\n]#'; 1241 | } 1242 | if (preg_match_all($regex, $text, $matches)) { 1243 | // Filter out invalid ones, can be ignored. 1244 | foreach ($matches[1] as $key => $tag) { 1245 | if (in_array($tag, self::$validtags)) { 1246 | if ($withcontent && isset($matches[2][$key])) { 1247 | // Let's add the content. 1248 | $inlinetags[] = $tag . ' ' . trim($matches[2][$key]); 1249 | } else { 1250 | // Just the tag, without content. 1251 | $inlinetags[] = $tag; 1252 | } 1253 | } 1254 | } 1255 | } 1256 | return $inlinetags; 1257 | } 1258 | } 1259 | -------------------------------------------------------------------------------- /index.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Main interface to Moodle PHP code check 19 | * 20 | * @package local_moodlecheck 21 | * @copyright 2012 Marina Glancy 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | 25 | require_once('../../config.php'); 26 | require_once($CFG->libdir . '/adminlib.php'); 27 | 28 | require_once($CFG->dirroot. '/local/moodlecheck/locallib.php'); 29 | 30 | // Include all files from rules directory. 31 | if ($dh = opendir($CFG->dirroot. '/local/moodlecheck/rules')) { 32 | while (($file = readdir($dh)) !== false) { 33 | if ($file != '.' && $file != '..') { 34 | $pathinfo = pathinfo($file); 35 | if (isset($pathinfo['extension']) && $pathinfo['extension'] == 'php') { 36 | require_once($CFG->dirroot. '/local/moodlecheck/rules/'. $file); 37 | } 38 | } 39 | } 40 | closedir($dh); 41 | } 42 | 43 | $pathlist = optional_param('path', '', PARAM_RAW); 44 | $ignore = optional_param('ignorepath', '', PARAM_NOTAGS); 45 | $checkall = optional_param('checkall', 'all', PARAM_NOTAGS); 46 | $rules = optional_param_array('rule', [], PARAM_NOTAGS); 47 | 48 | $pageparams = []; 49 | if ($pathlist) { 50 | $pageparams['path'] = $pathlist; 51 | } 52 | if ($ignore) { 53 | $pageparams['ignorepath'] = $ignore; 54 | } 55 | if ($checkall) { 56 | $pageparams['checkall'] = $checkall; 57 | } 58 | if ($rules) { 59 | foreach ($rules as $name => $value) { 60 | $pageparams['rule[' . $name . ']'] = $value; 61 | } 62 | } 63 | 64 | admin_externalpage_setup('local_moodlecheck', $pageparams); 65 | 66 | $form = new local_moodlecheck_form(new moodle_url('/local/moodlecheck/')); 67 | $form->set_data((object)$pageparams); 68 | if ($data = $form->get_data()) { 69 | redirect(new moodle_url('/local/moodlecheck/', $pageparams)); 70 | } 71 | 72 | $output = $PAGE->get_renderer('local_moodlecheck'); 73 | 74 | echo $output->header(); 75 | 76 | if ($pathlist) { 77 | $paths = preg_split('/\s*\n\s*/', trim((string)$pathlist), -1, PREG_SPLIT_NO_EMPTY); 78 | $ignorepaths = preg_split('/\s*\n\s*/', trim((string)$ignore), -1, PREG_SPLIT_NO_EMPTY); 79 | if (isset($checkall) && $checkall == 'selected' && isset($rules)) { 80 | foreach ($rules as $code => $value) { 81 | local_moodlecheck_registry::enable_rule($code); 82 | } 83 | } else { 84 | local_moodlecheck_registry::enable_all_rules(); 85 | } 86 | 87 | // Store result for later output. 88 | $result = []; 89 | 90 | foreach ($paths as $filename) { 91 | $path = new local_moodlecheck_path($filename, $ignorepaths); 92 | $result[] = $output->display_path($path); 93 | } 94 | 95 | echo $output->display_summary(); 96 | 97 | foreach ($result as $line) { 98 | echo $line; 99 | } 100 | } 101 | 102 | $form->display(); 103 | 104 | echo $output->footer(); 105 | -------------------------------------------------------------------------------- /lang/en/local_moodlecheck.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Strings for local_moodlecheck 19 | * 20 | * @package local_moodlecheck 21 | * @copyright 2012 Marina Glancy 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | 25 | // phpcs:disable moodle.Files.LangFilesOrdering 26 | 27 | $string['pluginname'] = 'Moodle PHPdoc check'; 28 | $string['path'] = 'Path(s)'; 29 | $string['ignorepath'] = 'Subpaths to ignore'; 30 | $string['path_help'] = 'Specify one or more files and/or directories to check as local paths from Moodle installation directory'; 31 | $string['check'] = 'Check'; 32 | $string['checkallrules'] = 'Check over all rules'; 33 | $string['checkselectedrules'] = 'Check over selected rules'; 34 | $string['error_default'] = 'Error: {$a}'; 35 | $string['linenum'] = 'Line {$a}: '; 36 | $string['notificationerror'] = 'Found {$a} errors'; 37 | $string['notificationsuccess'] = 'Well done!'; 38 | $string['privacy:metadata'] = 'The Moodle PHPdoc check plugin does not store any personal data.'; 39 | 40 | $string['error_emptynophpfile'] = 'The file is empty or doesn\'t contain PHP code. Skipped.'; 41 | 42 | $string['rule_noinlinephpdocs'] = 'There are no comments starting with three or more slashes'; 43 | $string['error_noinlinephpdocs'] = 'Found comment starting with three or more slashes'; 44 | 45 | $string['error_phpdocsinvalidinlinetag'] = 'Invalid inline phpdocs tag {$a->tag} found'; 46 | $string['rule_phpdocsinvalidinlinetag'] = 'Inline phpdocs tags are valid'; 47 | 48 | $string['error_phpdocsuncurlyinlinetag'] = 'Inline phpdocs tag not enclosed with curly brackets {$a->tag} found'; 49 | $string['rule_phpdocsuncurlyinlinetag'] = 'Inline phpdocs tags are enclosed with curly brackets'; 50 | 51 | $string['error_phpdoccontentsinlinetag'] = 'Inline phpdocs tag {$a->tag} with incorrect contents found. It must match {@link [valid URL] [description (optional)]} or {@see [valid FQSEN] [description (optional)]}'; 52 | $string['rule_phpdoccontentsinlinetag'] = 'Inline phpdocs tags have correct contents'; 53 | 54 | $string['error_functionarguments'] = 'Phpdocs for function {$a->function} has incomplete parameters list'; 55 | $string['rule_functionarguments'] = 'Phpdocs for functions properly define all parameters'; 56 | 57 | $string['rule_categoryvalid'] = 'Category tag is valid'; 58 | $string['error_categoryvalid'] = 'Category {$a->category} is not valid'; 59 | -------------------------------------------------------------------------------- /locallib.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Usefull classes for package local_moodlecheck 19 | * 20 | * @package local_moodlecheck 21 | * @copyright 2012 Marina Glancy 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | 25 | // phpcs:disable moodle.Commenting.VariableComment.Missing 26 | // phpcs:disable moodle.Commenting.MissingDocblock.Missing 27 | 28 | defined('MOODLE_INTERNAL') || die; 29 | require_once($CFG->libdir . '/formslib.php'); 30 | require_once($CFG->dirroot . '/local/moodlecheck/file.php'); 31 | 32 | /** 33 | * Handles one rule 34 | * 35 | * @package local_moodlecheck 36 | * @copyright 2012 Marina Glancy 37 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 38 | */ 39 | class local_moodlecheck_rule { 40 | protected $code; 41 | protected $callback; 42 | protected $rulestring; 43 | protected $errorstring; 44 | protected $severity = 'error'; 45 | 46 | public function __construct($code) { 47 | $this->code = $code; 48 | } 49 | 50 | public function set_callback($callback) { 51 | $this->callback = $callback; 52 | return $this; 53 | } 54 | 55 | public function set_rulestring($rulestring) { 56 | $this->rulestring = $rulestring; 57 | return $this; 58 | } 59 | 60 | public function set_errorstring($errorstring) { 61 | $this->errorstring = $errorstring; 62 | return $this; 63 | } 64 | 65 | public function set_severity($severity) { 66 | $this->severity = $severity; 67 | return $this; 68 | } 69 | 70 | public function get_name() { 71 | if ($this->rulestring !== null && get_string_manager()->string_exists($this->rulestring, 'local_moodlecheck')) { 72 | return get_string($this->rulestring, 'local_moodlecheck'); 73 | } else if (get_string_manager()->string_exists('rule_'. $this->code, 'local_moodlecheck')) { 74 | return get_string('rule_'. $this->code, 'local_moodlecheck'); 75 | } else { 76 | return $this->code; 77 | } 78 | } 79 | 80 | public function get_error_message($args) { 81 | if (!empty($this->errorstring) && get_string_manager()->string_exists($this->errorstring, 'local_moodlecheck')) { 82 | return get_string($this->errorstring, 'local_moodlecheck', $args); 83 | } else if (get_string_manager()->string_exists('error_'. $this->code, 'local_moodlecheck')) { 84 | return get_string('error_'. $this->code, 'local_moodlecheck', $args); 85 | } else { 86 | if (isset($args['line'])) { 87 | // Do not dump line number, it will be included in the final message. 88 | unset($args['line']); 89 | } 90 | if (is_array($args)) { 91 | $args = ': '. var_export($args, true); 92 | } else if ($args !== true && $args !== null) { 93 | $args = ': '. $args; 94 | } else { 95 | $args = ''; 96 | } 97 | return $this->get_name(). '. Error'. $args; 98 | } 99 | } 100 | 101 | public function validatefile(local_moodlecheck_file $file) { 102 | $callback = $this->callback; 103 | $reterrors = $callback($file); 104 | $ruleerrors = []; 105 | foreach ($reterrors as $args) { 106 | $ruleerrors[] = [ 107 | 'line' => $args['line'], 108 | 'severity' => $args["severity"] ?? $this->severity, 109 | 'message' => $this->get_error_message($args), 110 | 'source' => $this->code, 111 | ]; 112 | } 113 | return $ruleerrors; 114 | } 115 | } 116 | 117 | /** 118 | * Rule registry 119 | * 120 | * @package local_moodlecheck 121 | * @copyright 2012 Marina Glancy 122 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 123 | */ 124 | class local_moodlecheck_registry { 125 | protected static $rules = []; 126 | protected static $enabledrules = []; 127 | 128 | public static function add_rule($code) { 129 | $rule = new local_moodlecheck_rule($code); 130 | self::$rules[$code] = $rule; 131 | return $rule; 132 | } 133 | 134 | public static function get_registered_rules() { 135 | return self::$rules; 136 | } 137 | 138 | public static function enable_rule($code, $enable = true) { 139 | if (!isset(self::$rules[$code])) { 140 | // Can not enable/disable unexisting rule. 141 | return; 142 | } 143 | if (!$enable) { 144 | if (isset(self::$enabledrules[$code])) { 145 | unset(self::$enabledrules[$code]); 146 | } 147 | } else { 148 | self::$enabledrules[$code] = self::$rules[$code]; 149 | } 150 | } 151 | 152 | public static function &get_enabled_rules() { 153 | return self::$enabledrules; 154 | } 155 | 156 | public static function enable_all_rules() { 157 | foreach (array_keys(self::$rules) as $code) { 158 | self::enable_rule($code); 159 | } 160 | } 161 | } 162 | 163 | /** 164 | * Handles one path being validated (file or directory) 165 | * 166 | * @package local_moodlecheck 167 | * @copyright 2012 Marina Glancy 168 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 169 | */ 170 | class local_moodlecheck_path { 171 | protected $path = null; 172 | protected $ignorepaths = null; 173 | protected $file = null; 174 | protected $subpaths = null; 175 | protected $validated = false; 176 | protected $rootpath = true; 177 | 178 | public function __construct($path, $ignorepaths) { 179 | $path = clean_param(trim($path), PARAM_PATH); 180 | // If the path is already one existing full path 181 | // accept it, else assume it's a relative one. 182 | if (!file_exists($path) && substr($path, 0, 1) == '/') { 183 | $path = substr($path, 1); 184 | } 185 | $this->path = $path; 186 | $this->ignorepaths = $ignorepaths; 187 | } 188 | 189 | public function get_fullpath() { 190 | global $CFG; 191 | // It's already one full path. 192 | if (file_exists($this->path)) { 193 | return $this->path; 194 | } 195 | return $CFG->dirroot. '/'. $this->path; 196 | } 197 | 198 | public function validate() { 199 | if ($this->validated) { 200 | // Prevent from second validation. 201 | return; 202 | } 203 | if (is_file($this->get_fullpath())) { 204 | $this->file = new local_moodlecheck_file($this->get_fullpath()); 205 | } else if (is_dir($this->get_fullpath())) { 206 | $this->subpaths = []; 207 | if ($dh = opendir($this->get_fullpath())) { 208 | while (($file = readdir($dh)) !== false) { 209 | if ($file != '.' && $file != '..' && $file != '.git' && $file != '.hg' && !$this->is_ignored($file)) { 210 | $subpath = new local_moodlecheck_path($this->path . '/'. $file, $this->ignorepaths); 211 | $subpath->set_rootpath(false); 212 | $this->subpaths[] = $subpath; 213 | } 214 | } 215 | closedir($dh); 216 | } 217 | } 218 | $this->validated = true; 219 | } 220 | 221 | protected function is_ignored($file) { 222 | $filepath = $this->path. '/'. $file; 223 | foreach ($this->ignorepaths as $ignorepath) { 224 | $ignorepath = rtrim($ignorepath, '/'); 225 | if ($filepath == $ignorepath || substr($filepath, 0, strlen($ignorepath) + 1) == $ignorepath . '/') { 226 | return true; 227 | } 228 | } 229 | return false; 230 | } 231 | 232 | public function is_file() { 233 | return $this->file !== null; 234 | } 235 | 236 | public function is_dir() { 237 | return $this->subpaths !== null; 238 | } 239 | 240 | public function get_path() { 241 | return $this->path; 242 | } 243 | 244 | public function get_file() { 245 | return $this->file; 246 | } 247 | 248 | public function get_subpaths() { 249 | return $this->subpaths; 250 | } 251 | 252 | protected function set_rootpath($rootpath) { 253 | $this->rootpath = (boolean)$rootpath; 254 | } 255 | 256 | public function is_rootpath() { 257 | return $this->rootpath; 258 | } 259 | 260 | public static function get_components($componentsfile = null) { 261 | static $components = []; 262 | if (!empty($components)) { 263 | return $components; 264 | } 265 | if (empty($componentsfile)) { 266 | return []; 267 | } 268 | if (file_exists($componentsfile) && is_readable($componentsfile)) { 269 | $fh = fopen($componentsfile, 'r'); 270 | while (($line = fgets($fh, 4096)) !== false) { 271 | $split = explode(',', $line); 272 | if (count($split) != 3) { 273 | // Wrong count of elements in the line. 274 | continue; 275 | } 276 | if (trim($split[0]) != 'plugin' && trim($split[0]) != 'subsystem') { 277 | // Wrong type. 278 | continue; 279 | } 280 | // Let's assume it's a correct line. 281 | $components[trim($split[0])][trim($split[1])] = trim($split[2]); 282 | } 283 | fclose($fh); 284 | } 285 | return $components; 286 | } 287 | } 288 | 289 | /** 290 | * Form for check options 291 | * 292 | * @package local_moodlecheck 293 | * @copyright 2012 Marina Glancy 294 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 295 | */ 296 | class local_moodlecheck_form extends moodleform { 297 | protected function definition() { 298 | $mform = $this->_form; 299 | 300 | $mform->addElement('textarea', 'path', get_string('path', 'local_moodlecheck'), 301 | ['rows' => 8, 'cols' => 120]); 302 | $mform->addHelpButton('path', 'path', 'local_moodlecheck'); 303 | 304 | $mform->addElement('header', 'selectivecheck', get_string('options')); 305 | $mform->setExpanded('selectivecheck', false); 306 | 307 | $mform->addElement('textarea', 'ignorepath', get_string('ignorepath', 'local_moodlecheck'), 308 | ['rows' => 3, 'cols' => 120]); 309 | 310 | $mform->addElement('radio', 'checkall', '', get_string('checkallrules', 'local_moodlecheck'), 'all'); 311 | $mform->addElement('radio', 'checkall', '', get_string('checkselectedrules', 'local_moodlecheck'), 'selected'); 312 | $mform->setDefault('checkall', 'all'); 313 | 314 | $group = []; 315 | foreach (local_moodlecheck_registry::get_registered_rules() as $code => $rule) { 316 | $group[] =& $mform->createElement('checkbox', "rule[$code]", ' ', $rule->get_name()); 317 | } 318 | $mform->addGroup($group, 'checkboxgroup', '', ['
'], false); 319 | foreach (local_moodlecheck_registry::get_registered_rules() as $code => $rule) { 320 | $group[] =& $mform->createElement('checkbox', "rule[$code]", ' ', $rule->get_name()); 321 | $mform->setDefault("rule[$code]", 0); 322 | $mform->disabledIf("rule[$code]", 'checkall', 'eq', 'all'); 323 | } 324 | 325 | $this->add_action_buttons(false, get_string('check', 'local_moodlecheck')); 326 | } 327 | } 328 | -------------------------------------------------------------------------------- /renderer.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Renderer for displaying local_moodlecheck 19 | * 20 | * @package local_moodlecheck 21 | * @copyright 2012 Marina Glancy 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | class local_moodlecheck_renderer extends plugin_renderer_base { 25 | 26 | /** @var int $errorcount */ 27 | protected $errorcount = 0; 28 | 29 | /** 30 | * Generates html to display one path validation results (invoked recursively) 31 | * 32 | * @param local_moodlecheck_path $path 33 | * @param string $format display format: html, xml, text 34 | * @return string 35 | */ 36 | public function display_path(local_moodlecheck_path $path, $format = 'html') { 37 | $output = ''; 38 | $path->validate(); 39 | if ($path->is_dir()) { 40 | if ($format == 'html') { 41 | $output .= html_writer::start_tag('li', ['class' => 'directory']); 42 | $output .= html_writer::tag('span', $path->get_path(), ['class' => 'dirname']); 43 | $output .= html_writer::start_tag('ul', ['class' => 'directory']); 44 | } else if ($format == 'xml' && $path->is_rootpath()) { 45 | // Insert XML preamble and root element. 46 | $output .= '' . PHP_EOL . 47 | '' . PHP_EOL; 48 | } 49 | foreach ($path->get_subpaths() as $subpath) { 50 | $output .= $this->display_path($subpath, $format); 51 | } 52 | if ($format == 'html') { 53 | $output .= html_writer::end_tag('li'); 54 | $output .= html_writer::end_tag('ul'); 55 | } else if ($format == 'xml' && $path->is_rootpath()) { 56 | // Close root element. 57 | $output .= ''; 58 | } 59 | } else if ($path->is_file() && $path->get_file()->needs_validation()) { 60 | $output .= $this->display_file_validation($path->get_path(), $path->get_file(), $format); 61 | } 62 | return $output; 63 | } 64 | 65 | /** 66 | * Generates html to display one file validation results 67 | * 68 | * @param string $filename 69 | * @param local_moodlecheck_file $file 70 | * @param string $format display format: html, xml, text 71 | * @return string 72 | */ 73 | public function display_file_validation($filename, local_moodlecheck_file $file, $format = 'html') { 74 | $output = ''; 75 | $errors = $file->validate(); 76 | $this->errorcount += count($errors); 77 | if ($format == 'html') { 78 | $output .= html_writer::start_tag('li', ['class' => 'file']); 79 | $output .= html_writer::tag('span', $filename, ['class' => 'filename']); 80 | $output .= html_writer::start_tag('ul', ['class' => 'file']); 81 | } else if ($format == 'xml') { 82 | $output .= html_writer::start_tag('file', ['name' => $filename]). "\n"; 83 | } else if ($format == 'text') { 84 | $output .= $filename. "\n"; 85 | } 86 | foreach ($errors as $error) { 87 | // Add the severity always to both text and html formats. 88 | if ($format == 'html' || $format == 'text') { 89 | $error['message'] .= ' (' . $error['severity'] . ')'; 90 | } 91 | 92 | // Prepend the line number, if available. 93 | if (($format == 'html' || $format == 'text') && isset($error['line']) && $error['line'] !== '') { 94 | $error['message'] = get_string('linenum', 'local_moodlecheck', $error['line']) . $error['message']; 95 | } 96 | if ($format == 'html') { 97 | $output .= html_writer::tag('li', $error['message'], ['class' => 'errorline']); 98 | } else { 99 | $error['message'] = strip_tags($error['message']); 100 | if ($format == 'text') { 101 | $output .= " ". $error['message']. "\n"; 102 | } else if ($format == 'xml') { 103 | $output .= ' '.html_writer::empty_tag('error', $error). "\n"; 104 | } 105 | } 106 | } 107 | if ($format == 'html') { 108 | $output .= html_writer::end_tag('ul'); 109 | $output .= html_writer::end_tag('li'); 110 | } else if ($format == 'xml') { 111 | $output .= html_writer::end_tag('file'). "\n"; 112 | } 113 | return $output; 114 | } 115 | 116 | /** 117 | * Display report summary 118 | * 119 | * @return string 120 | */ 121 | public function display_summary() { 122 | if ($this->errorcount > 0) { 123 | return html_writer::tag('h2', get_string('notificationerror', 'local_moodlecheck', $this->errorcount), 124 | ['class' => 'fail']); 125 | } else { 126 | return html_writer::tag('h2', get_string('notificationsuccess', 'local_moodlecheck'), ['class' => 'good']); 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /rules/phpdocs_basic.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Registering rules for phpdocs checking 19 | * 20 | * @package local_moodlecheck 21 | * @copyright 2012 Marina Glancy 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | 25 | defined('MOODLE_INTERNAL') || die; 26 | 27 | local_moodlecheck_registry::add_rule('noinlinephpdocs')->set_callback('local_moodlecheck_noinlinephpdocs'); 28 | local_moodlecheck_registry::add_rule('functionarguments')->set_callback('local_moodlecheck_functionarguments'); 29 | local_moodlecheck_registry::add_rule('phpdocsinvalidinlinetag')->set_callback('local_moodlecheck_phpdocsinvalidinlinetag'); 30 | local_moodlecheck_registry::add_rule('phpdocsuncurlyinlinetag')->set_callback('local_moodlecheck_phpdocsuncurlyinlinetag'); 31 | local_moodlecheck_registry::add_rule('phpdoccontentsinlinetag')->set_callback('local_moodlecheck_phpdoccontentsinlinetag'); 32 | 33 | /** 34 | * Checks that no comment starts with three or more slashes 35 | * 36 | * @param local_moodlecheck_file $file 37 | * @return array of found errors 38 | */ 39 | function local_moodlecheck_noinlinephpdocs(local_moodlecheck_file $file) { 40 | $errors = []; 41 | foreach ($file->get_all_phpdocs() as $phpdocs) { 42 | if ($phpdocs->is_inline()) { 43 | $errors[] = ['line' => $phpdocs->get_line_number($file)]; 44 | } 45 | } 46 | return $errors; 47 | } 48 | 49 | /** 50 | * Check that all the inline phpdoc tags found are valid 51 | * 52 | * @param local_moodlecheck_file $file 53 | * @return array of found errors 54 | */ 55 | function local_moodlecheck_phpdocsinvalidinlinetag(local_moodlecheck_file $file) { 56 | $errors = []; 57 | foreach ($file->get_all_phpdocs() as $phpdocs) { 58 | if ($inlinetags = $phpdocs->get_inline_tags(false)) { 59 | foreach ($inlinetags as $inlinetag) { 60 | if (!in_array($inlinetag, local_moodlecheck_phpdocs::$inlinetags)) { 61 | $errors[] = [ 62 | 'line' => $phpdocs->get_line_number($file, '@' . $inlinetag), 63 | 'tag' => '@' . $inlinetag, ]; 64 | } 65 | } 66 | } 67 | } 68 | return $errors; 69 | } 70 | 71 | /** 72 | * Check that all the valid inline tags are properly enclosed with curly brackets 73 | * @param local_moodlecheck_file $file 74 | * @return array of found errors 75 | */ 76 | function local_moodlecheck_phpdocsuncurlyinlinetag(local_moodlecheck_file $file) { 77 | $errors = []; 78 | foreach ($file->get_all_phpdocs() as $phpdocs) { 79 | if ($inlinetags = $phpdocs->get_inline_tags(false)) { 80 | $curlyinlinetags = $phpdocs->get_inline_tags(true); 81 | // The difference will tell us which ones are nor enclosed by curly brackets. 82 | foreach ($curlyinlinetags as $remove) { 83 | foreach ($inlinetags as $k => $v) { 84 | if ($v === $remove) { 85 | unset($inlinetags[$k]); 86 | break; 87 | } 88 | } 89 | } 90 | foreach ($inlinetags as $inlinetag) { 91 | if (in_array($inlinetag, local_moodlecheck_phpdocs::$inlinetags)) { 92 | $errors[] = [ 93 | 'line' => $phpdocs->get_line_number($file, ' @' . $inlinetag), 94 | 'tag' => '@' . $inlinetag, ]; 95 | } 96 | } 97 | } 98 | } 99 | return $errors; 100 | } 101 | 102 | /** 103 | * Check that all the valid inline curly tags have correct contents. 104 | * 105 | * @link https://docs.phpdoc.org/3.0/guide/references/phpdoc/inline-tags/link.html#link phpDocumentor@link 106 | * @link https://docs.phpdoc.org/3.0/guide/references/phpdoc/tags/see.html#see phpDocumentor@see 107 | * @param local_moodlecheck_file $file 108 | * @return array of found errors 109 | */ 110 | function local_moodlecheck_phpdoccontentsinlinetag(local_moodlecheck_file $file) { 111 | $errors = []; 112 | foreach ($file->get_all_phpdocs() as $phpdocs) { 113 | if ($curlyinlinetags = $phpdocs->get_inline_tags(true, true)) { 114 | foreach ($curlyinlinetags as $curlyinlinetag) { 115 | // Split into tag and URL/FQSEN. Limit of 3 because the 3rd part can be the description. 116 | list($tag, $uriorfqsen) = explode(' ', $curlyinlinetag, 3); 117 | if (in_array($tag, local_moodlecheck_phpdocs::$inlinetags)) { 118 | switch ($tag) { 119 | case 'link': 120 | // Must be a correct URL with optional description. 121 | if (!filter_var($uriorfqsen, FILTER_VALIDATE_URL)) { 122 | $errors[] = [ 123 | 'line' => $phpdocs->get_line_number($file, ' {@' . $curlyinlinetag), 124 | 'tag' => '{@' . $curlyinlinetag . '}', ]; 125 | } 126 | break; 127 | case 'see': // Must be 1-word (with some chars allowed - FQSEN only. 128 | if (str_word_count($uriorfqsen, 0, '\()-_:>$012345789') !== 1) { 129 | $errors[] = [ 130 | 'line' => $phpdocs->get_line_number($file, ' {@' . $curlyinlinetag), 131 | 'tag' => '{@' . $curlyinlinetag . '}', ]; 132 | } 133 | break; 134 | } 135 | } 136 | } 137 | } 138 | } 139 | return $errors; 140 | } 141 | 142 | /** 143 | * Checks that all functions have proper arguments in phpdocs 144 | * 145 | * @param local_moodlecheck_file $file 146 | * @return array of found errors 147 | */ 148 | function local_moodlecheck_functionarguments(local_moodlecheck_file $file) { 149 | $errors = []; 150 | 151 | foreach ($file->get_functions() as $function) { 152 | if ($function->phpdocs !== false) { 153 | $documentedarguments = $function->phpdocs->get_params(); 154 | $match = (count($documentedarguments) == count($function->arguments)); 155 | for ($i = 0; $match && $i < count($documentedarguments); $i++) { 156 | if (count($documentedarguments[$i]) < 2) { 157 | // Must be at least type and parameter name. 158 | $match = false; 159 | } else { 160 | $expectedtype = local_moodlecheck_normalise_function_type((string) $function->arguments[$i][0]); 161 | $expectedparam = (string)$function->arguments[$i][1]; 162 | $documentedtype = local_moodlecheck_normalise_function_type((string) $documentedarguments[$i][0]); 163 | $documentedparam = $documentedarguments[$i][1]; 164 | 165 | $typematch = $expectedtype === $documentedtype; 166 | $parammatch = $expectedparam === $documentedparam; 167 | if ($typematch && $parammatch) { 168 | continue; 169 | } 170 | 171 | // Documented types can be a collection (| separated). 172 | foreach (explode('|', $documentedtype) as $documentedtype) { 173 | // Ignore null. They cannot match any type in function. 174 | if (trim($documentedtype) === 'null') { 175 | continue; 176 | } 177 | 178 | if (strlen($expectedtype) && $expectedtype !== $documentedtype) { 179 | // It could be a type hinted array. 180 | if ($expectedtype !== 'array' || substr($documentedtype, -2) !== '[]') { 181 | $match = false; 182 | } 183 | } else if ($documentedtype === 'type') { 184 | $match = false; 185 | } else if ($expectedparam !== $documentedparam) { 186 | $match = false; 187 | } 188 | } 189 | } 190 | } 191 | $documentedreturns = $function->phpdocs->get_params('return'); 192 | for ($i = 0; $match && $i < count($documentedreturns); $i++) { 193 | if (empty($documentedreturns[$i][0]) || $documentedreturns[$i][0] == 'type') { 194 | $match = false; 195 | } 196 | } 197 | if (!$match) { 198 | $errors[] = [ 199 | 'line' => $function->phpdocs->get_line_number($file, '@param'), 200 | 'function' => $function->fullname, ]; 201 | } 202 | } 203 | } 204 | return $errors; 205 | } 206 | 207 | /** 208 | * Normalise function type to be able to compare it. 209 | * 210 | * @param string $typelist 211 | * @return string 212 | */ 213 | function local_moodlecheck_normalise_function_type(string $typelist): string { 214 | // Normalise a nullable type to `null|type` as these are just shorthands. 215 | $typelist = str_replace( 216 | '?', 217 | 'null|', 218 | $typelist 219 | ); 220 | 221 | // PHP 8 treats namespaces as single token. So we are going to undo this here 222 | // and continue returning only the final part of the namespace. Someday we'll 223 | // move to use full namespaces here, but not for now (we are doing the same, 224 | // in other parts of the code, when processing phpdoc blocks). 225 | $types = explode('|', $typelist); 226 | 227 | // Namespaced typehint, potentially sub-namespaced. 228 | // We need to strip namespacing as this area just isn't that smart. 229 | $types = array_map( 230 | function($type) { 231 | if (strpos((string)$type, '\\') !== false) { 232 | $type = substr($type, strrpos($type, '\\') + 1); 233 | } 234 | return $type; 235 | }, 236 | $types 237 | ); 238 | sort($types); 239 | 240 | return implode('|', $types); 241 | } 242 | -------------------------------------------------------------------------------- /settings.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Add page to admin menu. 19 | * 20 | * @package local_moodlecheck 21 | * @copyright 2012 Marina Glancy 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | 25 | defined('MOODLE_INTERNAL') || die; 26 | 27 | if ($hassiteconfig) { // Needs this condition or there is error on login page. 28 | $ADMIN->add('development', new admin_externalpage('local_moodlecheck', 29 | get_string('pluginname', 'local_moodlecheck'), 30 | new moodle_url('/local/moodlecheck/index.php'))); 31 | } 32 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .path-local-moodlecheck .filename, 2 | .path-local-moodlecheck .dirname { 3 | font-size: 1.1em; 4 | font-weight: bold; 5 | color: #547c22; 6 | } 7 | 8 | .path-local-moodlecheck .dirname { 9 | color: #408; 10 | } 11 | 12 | .path-local-moodlecheck ul.directory, 13 | .path-local-moodlecheck ul.file { 14 | margin: 0.5em; 15 | margin-left: 2em; 16 | } 17 | 18 | .path-local-moodlecheck ul.file { 19 | margin-top: 0; 20 | } 21 | 22 | .path-local-moodlecheck .fail { 23 | color: #600; 24 | } 25 | 26 | .path-local-moodlecheck .good { 27 | color: #060; 28 | } 29 | 30 | .path-local-moodlecheck form .fcheckbox label { 31 | margin-left: 1em; 32 | } 33 | .path-local-moodlecheck form .fcheckbox > span { 34 | margin-left: 2em; 35 | } 36 | .path-local-moodlecheck form .fradio label { 37 | margin-left: 1em; 38 | } 39 | -------------------------------------------------------------------------------- /tests/coverage.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Coverage information for the local_codechecker plugin. 19 | * 20 | * @package local_moodlecheck 21 | * @category test 22 | * @copyright 2023 onwards Eloy Lafuente (stronk7) {@link https://stronk7.com} 23 | * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 | */ 25 | 26 | defined('MOODLE_INTERNAL') || die(); 27 | 28 | /** 29 | * Anonymous phpunit_coverage_info returning the areas to include and exclude. 30 | */ 31 | return new class extends phpunit_coverage_info { 32 | /** @var array The list of folders relative to the plugin root to include in coverage generation. */ 33 | protected $includelistfolders = [ 34 | 'rules', 35 | ]; 36 | 37 | /** @var array The list of files relative to the plugin root to include in coverage generation. */ 38 | protected $includelistfiles = [ 39 | 'file.php', 40 | ]; 41 | 42 | /** @var array The list of folders relative to the plugin root to exclude from coverage generation. */ 43 | protected $excludelistfolders = []; 44 | 45 | /** @var array The list of files relative to the plugin root to exclude from coverage generation. */ 46 | protected $excludelistfiles = []; 47 | }; 48 | -------------------------------------------------------------------------------- /tests/fixtures/constantclass.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Unit tests for a fixture file in moodlecheck. 19 | * 20 | * @package local_moodlecheck 21 | * @copyright 2018 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | 25 | defined('MOODLE_INTERNAL') || die(); 26 | 27 | global $CFG; 28 | 29 | /** 30 | * Unit tests for a fixture class in moodlecheck. 31 | * 32 | * @package local_moodlecheck 33 | * @copyright 2018 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} 34 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 35 | */ 36 | class fixturing_classconstant extends advanced_testcase { 37 | 38 | /** 39 | * Fixture method 40 | */ 41 | public function test_fixtured() { 42 | $this->assertInstanceOf(\stdClass::class, $result); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/fixtures/empty.php: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moodlehq/moodle-local_moodlecheck/77eea9dc51bf2c5793e457bfe82d4528978ebe12/tests/fixtures/empty.php -------------------------------------------------------------------------------- /tests/fixtures/error_and_warning.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | namespace local_moodlecheck; 18 | 19 | use cm_info; 20 | use stdClass; 21 | 22 | /** 23 | * A fixture to verify phpdoc tags used in constructor property promotion. 24 | * 25 | * @package local_moodlecheck 26 | * @copyright 2023 Andrew Lyons 27 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 28 | */ 29 | class constructor_property_promotion { 30 | /** 31 | * An example of a constructor using constructor property promotion. 32 | * 33 | * @param stdClass|cm_info $cm The course module data 34 | * @param string $name The name 35 | * @param int|float $size The size 36 | * @param null|string $description The description 37 | * @param ?string $content The content 38 | */ 39 | public function __construct( 40 | private stdClass|cm_info $cm, 41 | protected string $name, 42 | protected float|int $size, 43 | protected ?string $description = null, 44 | protected ?string $content = null 45 | ) { 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/fixtures/phpdoc_constructor_property_promotion_readonly.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | namespace local_moodlecheck; 18 | 19 | use cm_info; 20 | use stdClass; 21 | 22 | /** 23 | * A fixture to verify phpdoc tags used in constructor property promotion. 24 | * 25 | * @package local_moodlecheck 26 | * @copyright 2023 Andrew Lyons 27 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 28 | */ 29 | class constructor_property_promotion { 30 | /** 31 | * An example of a constructor using constructor property promotion. 32 | * 33 | * @param stdClass|cm_info $cm The course module data 34 | * @param string $name The name 35 | * @param int|float $size The size 36 | * @param null|string $description The description 37 | * @param ?string $content The content 38 | */ 39 | public function __construct( 40 | private stdClass|cm_info $cm, 41 | /** @var string The name of the course module */ 42 | public readonly string $name, 43 | /** @var float|int The size */ 44 | public readonly float|int $size, 45 | /** @var null|string The description */ 46 | protected readonly ?string $description = null, 47 | protected ?string $content = null 48 | ) { 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/fixtures/phpdoc_method_multiline.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Class to verify that "multiline" method declarations are working ok. 19 | * 20 | * @package local_moodlecheck 21 | */ 22 | class something { 23 | 24 | /** 25 | * One function, what else. 26 | * 27 | * @param string $plugin A plugin name. 28 | * @param int $direction A direction. 29 | * @return array 30 | */ 31 | public function function_oneline(string $plugin, int $direction): array { 32 | // Do something. 33 | } 34 | 35 | /** 36 | * One function, what else. 37 | * 38 | * @param string $plugin A plugin name. 39 | * @param int $direction A direction. 40 | * @return array 41 | */ 42 | public function function_multiline1(string $plugin, 43 | int $direction): array { 44 | // Do something. 45 | } 46 | 47 | /** 48 | * One function, what else. 49 | * 50 | * @param string $plugin A plugin name. 51 | * @param int $direction A direction. 52 | * @return array 53 | */ 54 | public function function_multiline2(string $plugin, int $direction) 55 | : array { 56 | // Do something. 57 | } 58 | 59 | /** 60 | * One function, what else. 61 | * 62 | * @param string $plugin A plugin name. 63 | * @param int $direction A direction. 64 | * @return array 65 | */ 66 | public function function_multiline3( 67 | string $plugin, 68 | int $direction): array { 69 | // Do something. 70 | } 71 | 72 | /** 73 | * One function, what else. 74 | * 75 | * @param string $plugin A plugin name. 76 | * @param int $direction A direction. 77 | * @return array 78 | */ 79 | public function function_multiline4( 80 | string $plugin, 81 | int $direction 82 | ): array { 83 | // Do something. 84 | } 85 | 86 | /** 87 | * One function, what else. 88 | * 89 | * @param string $plugin A plugin name. 90 | * @param int $direction A direction. 91 | * @return array 92 | */ 93 | public function function_multiline5( 94 | string $plugin, 95 | int $direction, 96 | ): array { 97 | // Do something. 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /tests/fixtures/phpdoc_method_union_types.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | namespace local_moodlecheck; 18 | 19 | /** 20 | * A fixture to verify various phpdoc tags in a general location. 21 | * 22 | * @package local_moodlecheck 23 | * @copyright 2023 Andrew Lyons 24 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 25 | */ 26 | class union_types { 27 | /** 28 | * An example of a method on a single line using union types in both the params and return values 29 | * @param string|int $value 30 | * @return string|int 31 | */ 32 | public function method_oneline(string|int $value): string|int { 33 | // Do something. 34 | return $value; 35 | } 36 | 37 | /** 38 | * An example of a method on a single line using union types in both the params and return values 39 | * 40 | * @param string|int $value 41 | * @param int|float $othervalue 42 | * @return string|int 43 | */ 44 | public function method_oneline_multi(string|int $value, int|float $othervalue): string|int { 45 | // Do something. 46 | return $value; 47 | } 48 | 49 | /** 50 | * An example of a method on a single line using union types in both the params and return values 51 | * 52 | * @param string|int $value 53 | * @param int|float $othervalue 54 | * @return string|int 55 | */ 56 | public function method_multiline( 57 | string|int $value, 58 | int|float $othervalue, 59 | ): string|int { 60 | // Do something. 61 | return $value; 62 | } 63 | 64 | /** 65 | * An example of a method whose union values are not in the same order. 66 | 67 | * @param int|string $value 68 | * @param int|float $othervalue 69 | * @return int|string 70 | */ 71 | public function method_union_order_does_not_matter( 72 | string|int $value, 73 | float|int $othervalue, 74 | ): string|int { 75 | // Do something. 76 | return $value; 77 | } 78 | 79 | /** 80 | * An example of a method which uses strings, or an array of strings. 81 | * 82 | * @param string|string[] $arrayofstrings 83 | * @return string[]|string 84 | */ 85 | public function method_union_containing_array( 86 | string|array $arrayofstrings, 87 | ): string|array { 88 | return [ 89 | 'example', 90 | ]; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /tests/fixtures/phpdoc_tags_general.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * A fixture to verify various phpdoc tags in a general location. 19 | * 20 | * @package local_moodlecheck 21 | * @copyright 2018 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | 25 | defined('MOODLE_INTERNAL') || die(); 26 | 27 | global $CFG; 28 | 29 | /** 30 | * A fixture to verify various phpdoc tags in a general location. 31 | * 32 | * @package local_moodlecheck 33 | * @copyright 2018 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} 34 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 35 | */ 36 | class fixturing_general { 37 | 38 | /** 39 | * Some valid tags, to verify they are ok. 40 | * 41 | * @license 42 | * @throws 43 | * @deprecated 44 | * @author 45 | * @todo 46 | */ 47 | public function all_valid_tags() { 48 | echo "yay!"; 49 | } 50 | 51 | /** 52 | * Some invalid tags, to verify they are detected. 53 | * 54 | * @codingStandardsIgnoreLine 55 | * @covers 56 | * @dataProvider 57 | * @group 58 | * @small 59 | * @zzzing 60 | * @inheritdoc 61 | */ 62 | public function all_invalid_tags() { 63 | echo "yoy!"; 64 | } 65 | 66 | /** 67 | * Incomplete param annotation (type is missing). 68 | * 69 | * @param $one 70 | * @param $two 71 | */ 72 | public function incomplete_param_annotation($one, $two) { 73 | echo "yoy!"; 74 | } 75 | 76 | /** 77 | * Missing param definition. 78 | * 79 | * @param string $one 80 | * @param bool $two 81 | */ 82 | public function missing_param_defintion() { 83 | echo "yoy!"; 84 | } 85 | 86 | /** 87 | * Missing param annotation. 88 | */ 89 | public function missing_param_annotation(string $one, bool $two) { 90 | echo "yoy!"; 91 | } 92 | 93 | /** 94 | * Incomplete param definition. 95 | * 96 | * @param string $one 97 | * @param bool $two 98 | */ 99 | public function incomplete_param_definition(string $one) { 100 | echo "yoy!"; 101 | } 102 | 103 | /** 104 | * Incomplete param annotation (annotation is missing). 105 | * 106 | * @param string $one 107 | */ 108 | public function incomplete_param_annotation1(string $one, bool $two) { 109 | echo "yoy!"; 110 | } 111 | 112 | /** 113 | * Mismatch param types. 114 | * 115 | * @param string $one 116 | * @param bool $two 117 | */ 118 | public function mismatch_param_types(string $one, array $two = []) { 119 | echo "yoy!"; 120 | } 121 | 122 | /** 123 | * Mismatch param types. 124 | * 125 | * @param string|bool $one 126 | * @param bool $two 127 | */ 128 | public function mismatch_param_types1(string $one, bool $two) { 129 | echo "yoy!"; 130 | } 131 | 132 | /** 133 | * Mismatch param types. 134 | * 135 | * @param string|bool $one 136 | * @param bool $params 137 | */ 138 | public function mismatch_param_types2(string $one, ...$params) { 139 | echo "yoy!"; 140 | } 141 | 142 | /** 143 | * Mismatch param types. 144 | * 145 | * @param string $one 146 | * @param int[] $params 147 | */ 148 | public function mismatch_param_types3(string $one, int $params) { 149 | echo "yoy!"; 150 | } 151 | 152 | /** 153 | * Correct param types. 154 | * 155 | * @param string|bool $one 156 | * @param bool $two 157 | * @param array $three 158 | */ 159 | public function correct_param_types($one, bool $two, array $three) { 160 | echo "yay!"; 161 | } 162 | 163 | /** 164 | * Correct param types. 165 | * 166 | * @param string|bool $one 167 | * @param bool $two 168 | * @param array $three 169 | */ 170 | public function correct_param_types1($one, bool $two, array $three) { 171 | echo "yay!"; 172 | } 173 | 174 | /** 175 | * Correct param types. 176 | * 177 | * @param string $one 178 | * @param bool $two 179 | */ 180 | public function correct_param_types2($one, $two) { 181 | echo "yay!"; 182 | } 183 | 184 | /** 185 | * Correct param types. 186 | * 187 | * @param string|null $one 188 | * @param bool $two 189 | * @param array $three 190 | */ 191 | public function correct_param_types3(?string $one = null, bool $two, array $three) { 192 | echo "yay!"; 193 | } 194 | 195 | /** 196 | * Correct param types. 197 | * 198 | * @param string|null $one 199 | * @param bool $two 200 | * @param int[] $three 201 | */ 202 | public function correct_param_types4($one = null, bool $two, array $three) { 203 | echo "yay!"; 204 | } 205 | 206 | /** 207 | * Correct param types. 208 | * 209 | * @param string $one 210 | * @param mixed ...$params one or more params 211 | */ 212 | public function correct_param_types5(string $one, ...$params) { 213 | echo "yay!"; 214 | } 215 | 216 | /** 217 | * Incomplete return annotation (type is missing). 218 | * 219 | * @return 220 | */ 221 | public function incomplete_return_annotation() { 222 | echo "yoy!"; 223 | } 224 | 225 | /** 226 | * Correct return type. 227 | * 228 | * @return string 229 | */ 230 | public function correct_return_type(): string { 231 | return "yay!"; 232 | } 233 | 234 | /** 235 | * Namespaced types. 236 | * 237 | * @param \stdClass $data 238 | * @param \core\user $user 239 | * @return \core\user 240 | */ 241 | public function namespaced_parameter_type( 242 | \stdClass $data, 243 | \core\user $user 244 | ): \core\user { 245 | return $user; 246 | } 247 | 248 | /** 249 | * Namespaced types. 250 | * 251 | * @param null|\stdClass $data 252 | * @param null|\core\test\something|\core\some\other_thing $moredata 253 | * @return \stdClass 254 | */ 255 | public function builtin( 256 | ?\stdClass $data, 257 | ?\core\test\something|\core\some\other_thing $moredata 258 | ): \stdClass { 259 | return $user; 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /tests/fixtures/phpdoc_tags_inline.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * A fixture to verify various phpdoc tags are allowed inline. 19 | * 20 | * @package local_moodlecheck 21 | * @copyright 2020 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | 25 | defined('MOODLE_INTERNAL') || die(); 26 | 27 | global $CFG; 28 | 29 | /** 30 | * A fixture to verify various phpdoc tags can be used standalone or inline. 31 | * 32 | * @package local_moodlecheck 33 | * @copyright 2020 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} 34 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 35 | */ 36 | class fixturing_inline { 37 | 38 | /** 39 | * Some valid tags, to verify they are ok. 40 | * 41 | * @license 42 | * @throws 43 | * @deprecated 44 | * @author 45 | * @todo 46 | * @uses 47 | */ 48 | public function all_valid_tags() { 49 | echo "yay!"; 50 | } 51 | 52 | /** 53 | * Some tags that are valid standalone and inline 54 | * 55 | * @link https://moodle.org 56 | * @see has_capability() 57 | * 58 | * To know more, visit {@link https://moodle.org} for Moodle info. 59 | * To know more, take a look to {@see has_capability} about permissions. 60 | * {@link https://moodle.org Links tags with descriptions} should be fine as long as they contain valid URL. 61 | * {@see some_function See tags with descriptions} should be fine as well. 62 | * And verify that crazy {@see \so-me\com-plex\th_ing::$come->ba8by()} are ok too. 63 | */ 64 | public function correct_inline_tags() { 65 | echo "done!"; 66 | } 67 | 68 | /** 69 | * Some invalid inline tags. 70 | * 71 | * This tag {@param string Some param} cannot be used inline. 72 | * Neither {@throws exception} can. 73 | * Ideally all {@link tags need to have a valid URL}. An optional description is allowed too. 74 | * {@see https://moodle.org We do not support URLs in see tags.} See MDLSITE-6105. 75 | * And {@see $this->tagrules['url']} is not a proper structural element. 76 | * Also they aren't valid without using @see curly brackets 77 | */ 78 | public function all_invalid_tags() { 79 | echo "done!"; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /tests/fixtures/unfinished.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Fixture file providing a class. 19 | * 20 | * @package local_moodlecheck 21 | * @copyright 2018 David Mudrák 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | 25 | /** 26 | * This is a dummy class without any tags. 27 | */ 28 | class dummy_class_without_tags { 29 | } 30 | 31 | /** 32 | * I am an unfinished phpdoc block, and don't want any PHP warning happening. 33 | -------------------------------------------------------------------------------- /tests/fixtures/uses.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Fixture file with use statements, none of which should trigger warnings, despite containing "function" and "const". 19 | * 20 | * @package local_moodlecheck 21 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 22 | */ 23 | 24 | use these\dont\actually\need\to\point\to\anything; 25 | use function ns\fun_1; 26 | use function ns\fun_2 as alias; 27 | use const ns\CONST_1; 28 | use const ns\CONST_2 as ALIAS; 29 | 30 | use { 31 | function ns\fun_3, 32 | const ns\const_3 33 | }; 34 | 35 | use function ns\fun_1?> 36 | -------------------------------------------------------------------------------- /tests/moodlecheck_rules_test.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | namespace local_moodlecheck; 18 | 19 | use local_moodlecheck_path; 20 | use local_moodlecheck_registry; 21 | 22 | /** 23 | * Contains unit tests for covering "moodle" PHPDoc rules. 24 | * 25 | * @package local_moodlecheck 26 | * @category test 27 | * @copyright 2018 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} 28 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 29 | */ 30 | final class moodlecheck_rules_test extends \advanced_testcase { 31 | public function setUp(): void { 32 | global $CFG; 33 | parent::setUp(); 34 | // Add the moodlecheck machinery. 35 | require_once($CFG->dirroot . '/local/moodlecheck/locallib.php'); 36 | // Load all files from rules directory. 37 | if ($dh = opendir($CFG->dirroot . '/local/moodlecheck/rules')) { 38 | while (($file = readdir($dh)) !== false) { 39 | if ($file != '.' && $file != '..') { 40 | $pathinfo = pathinfo($file); 41 | if (isset($pathinfo['extension']) && $pathinfo['extension'] == 'php') { 42 | require_once($CFG->dirroot . '/local/moodlecheck/rules/' . $file); 43 | } 44 | } 45 | } 46 | closedir($dh); 47 | } 48 | // Load all rules. 49 | local_moodlecheck_registry::enable_all_rules(); 50 | } 51 | 52 | /** 53 | * Verify the ::class constant is not reported as phpdoc problem. 54 | * 55 | * @covers \local_moodlecheck_file::get_classes 56 | * @covers \local_moodlecheck_file::previous_nonspace_token 57 | */ 58 | public function test_constantclass(): void { 59 | global $PAGE; 60 | $output = $PAGE->get_renderer('local_moodlecheck'); 61 | $path = new local_moodlecheck_path('local/moodlecheck/tests/fixtures/constantclass.php ', null); 62 | $result = $output->display_path($path, 'xml'); 63 | 64 | // Convert results to XML Objext. 65 | $xmlresult = new \DOMDocument(); 66 | $xmlresult->loadXML($result); 67 | 68 | // Let's verify we have received a xml with file top element and 2 children. 69 | $xpath = new \DOMXpath($xmlresult); 70 | $found = $xpath->query("//file/error"); 71 | 72 | // TODO: Change to DOMNodeList::count() when php71 support is gone. 73 | $this->assertSame(0, $found->length); 74 | } 75 | 76 | /** 77 | * Ensure that token_get_all() does not return PHP Warnings. 78 | * 79 | * @covers \local_moodlecheck_file::get_tokens 80 | */ 81 | public function test_get_tokens(): void { 82 | global $PAGE; 83 | 84 | $output = $PAGE->get_renderer('local_moodlecheck'); 85 | $path = new local_moodlecheck_path('local/moodlecheck/tests/fixtures/unfinished.php ', null); 86 | 87 | $this->expectOutputString(''); 88 | $result = $output->display_path($path, 'xml'); 89 | } 90 | 91 | /** 92 | * Verify various phpdoc tags in general directories. 93 | * 94 | * @covers ::local_moodlecheck_functionarguments 95 | */ 96 | public function test_phpdoc_tags_general(): void { 97 | global $PAGE; 98 | $output = $PAGE->get_renderer('local_moodlecheck'); 99 | $path = new local_moodlecheck_path('local/moodlecheck/tests/fixtures/phpdoc_tags_general.php ', null); 100 | $result = $output->display_path($path, 'xml'); 101 | 102 | // Convert results to XML Objext. 103 | $xmlresult = new \DOMDocument(); 104 | $xmlresult->loadXML($result); 105 | 106 | // Let's verify we have received a xml with file top element and 8 children. 107 | $xpath = new \DOMXpath($xmlresult); 108 | $found = $xpath->query("//file/error"); 109 | // TODO: Change to DOMNodeList::count() when php71 support is gone. 110 | $this->assertSame(10, $found->length); 111 | 112 | // Also verify various bits by content. 113 | $this->assertStringContainsString('incomplete_param_annotation has incomplete parameters list', $result); 114 | $this->assertStringContainsString('missing_param_defintion has incomplete parameters list', $result); 115 | $this->assertStringContainsString('missing_param_annotation has incomplete parameters list', $result); 116 | $this->assertStringContainsString('incomplete_param_definition has incomplete parameters list', $result); 117 | $this->assertStringContainsString('incomplete_param_annotation1 has incomplete parameters list', $result); 118 | $this->assertStringContainsString('mismatch_param_types has incomplete parameters list', $result); 119 | $this->assertStringContainsString('mismatch_param_types1 has incomplete parameters list', $result); 120 | $this->assertStringContainsString('mismatch_param_types2 has incomplete parameters list', $result); 121 | $this->assertStringContainsString('mismatch_param_types3 has incomplete parameters list', $result); 122 | $this->assertStringContainsString('incomplete_return_annotation has incomplete parameters list', $result); 123 | $this->assertStringNotContainsString('@deprecated', $result); 124 | $this->assertStringNotContainsString('correct_param_types', $result); 125 | $this->assertStringNotContainsString('correct_return_type', $result); 126 | } 127 | 128 | /** 129 | * Verify that constructor property promotion is supported. 130 | * 131 | * @covers ::local_moodlecheck_functionarguments 132 | */ 133 | public function test_phpdoc_constructor_property_promotion(): void { 134 | global $PAGE; 135 | $output = $PAGE->get_renderer('local_moodlecheck'); 136 | $path = new local_moodlecheck_path('local/moodlecheck/tests/fixtures/phpdoc_constructor_property_promotion.php ', null); 137 | $result = $output->display_path($path, 'xml'); 138 | 139 | // Convert results to XML Objext. 140 | $xmlresult = new \DOMDocument(); 141 | $xmlresult->loadXML($result); 142 | 143 | // Let's verify we have received a xml with file top element and 8 children. 144 | $xpath = new \DOMXpath($xmlresult); 145 | $found = $xpath->query("//file/error"); 146 | 147 | // TODO: Change to DOMNodeList::count() when php71 support is gone. 148 | $this->assertSame(0, $found->length); 149 | $this->assertStringNotContainsString('constructor_property_promotion::__construct has incomplete parameters list', $result); 150 | } 151 | 152 | /** 153 | * Verify that constructor property promotion supports readonly properties. 154 | * 155 | * @covers ::local_moodlecheck_functionarguments 156 | * @requires PHP >= 8.1 157 | */ 158 | public function test_phpdoc_constructor_property_promotion_readonly(): void { 159 | global $PAGE; 160 | $output = $PAGE->get_renderer('local_moodlecheck'); 161 | $path = new local_moodlecheck_path( 162 | 'local/moodlecheck/tests/fixtures/phpdoc_constructor_property_promotion_readonly.php', 163 | null 164 | ); 165 | $result = $output->display_path($path, 'xml'); 166 | 167 | // Convert results to XML Objext. 168 | $xmlresult = new \DOMDocument(); 169 | $xmlresult->loadXML($result); 170 | 171 | // Let's verify we have received a xml with file top element and 8 children. 172 | $xpath = new \DOMXpath($xmlresult); 173 | $found = $xpath->query("//file/error"); 174 | 175 | $this->assertCount(0, $found); 176 | $this->assertStringNotContainsString('constructor_property_promotion::__construct has incomplete parameters list', $result); 177 | } 178 | 179 | /** 180 | * Verify that constructor property promotion is supported. 181 | * 182 | * @covers ::local_moodlecheck_functionarguments 183 | */ 184 | public function test_phpdoc_union_types(): void { 185 | global $PAGE; 186 | $output = $PAGE->get_renderer('local_moodlecheck'); 187 | 188 | $path = new local_moodlecheck_path('local/moodlecheck/tests/fixtures/phpdoc_method_union_types.php ', null); 189 | $result = $output->display_path($path, 'xml'); 190 | 191 | // Convert results to XML Objext. 192 | $xmlresult = new \DOMDocument(); 193 | $xmlresult->loadXML($result); 194 | 195 | // Let's verify we have received a xml with file top element and 8 children. 196 | $xpath = new \DOMXpath($xmlresult); 197 | $found = $xpath->query("//file/error"); 198 | 199 | // TODO: Change to DOMNodeList::count() when php71 support is gone. 200 | $this->assertSame(0, $found->length); 201 | $this->assertStringNotContainsString( 202 | 'constructor_property_promotion::__construct has incomplete parameters list', 203 | $result 204 | ); 205 | $this->assertStringNotContainsString( 206 | 'Phpdocs for function union_types::method_oneline has incomplete parameters list', 207 | $result 208 | ); 209 | $this->assertStringNotContainsString( 210 | 'Phpdocs for function union_types::method_oneline_multi has incomplete parameters list', 211 | $result 212 | ); 213 | $this->assertStringNotContainsString( 214 | 'Phpdocs for function union_types::method_multiline has incomplete parameters list', 215 | $result 216 | ); 217 | $this->assertStringNotContainsString( 218 | 'Phpdocs for function union_types::method_union_order_does_not_matter has incomplete parameters list', 219 | $result 220 | ); 221 | $this->assertStringNotContainsString( 222 | 'Phpdocs for function union_types::method_union_containing_array has incomplete parameters list', 223 | $result 224 | ); 225 | } 226 | 227 | /** 228 | * Verify various phpdoc tags can be used inline. 229 | * 230 | * @covers ::local_moodlecheck_phpdocsuncurlyinlinetag 231 | * @covers ::local_moodlecheck_phpdoccontentsinlinetag 232 | */ 233 | public function test_phpdoc_tags_inline(): void { 234 | global $PAGE; 235 | $output = $PAGE->get_renderer('local_moodlecheck'); 236 | $path = new local_moodlecheck_path('local/moodlecheck/tests/fixtures/phpdoc_tags_inline.php ', null); 237 | $result = $output->display_path($path, 'xml'); 238 | 239 | // Convert results to XML Objext. 240 | $xmlresult = new \DOMDocument(); 241 | $xmlresult->loadXML($result); 242 | 243 | // Let's verify we have received a xml with file top element and 8 children. 244 | $xpath = new \DOMXpath($xmlresult); 245 | $found = $xpath->query("//file/error"); 246 | // TODO: Change to DOMNodeList::count() when php71 support is gone. 247 | $this->assertSame(6, $found->length); 248 | 249 | // Also verify various bits by content. 250 | $this->assertStringContainsString('Invalid inline phpdocs tag @param found', $result); 251 | $this->assertStringContainsString('Invalid inline phpdocs tag @throws found', $result); 252 | $this->assertStringContainsString('Inline phpdocs tag {@link tags need to have a valid URL} with incorrect', $result); 253 | $this->assertStringContainsString('Inline phpdocs tag {@see $this->tagrules['url']} with incorrect', $result); 254 | $this->assertStringContainsString('Inline phpdocs tag not enclosed with curly brackets @see found', $result); 255 | $this->assertStringContainsString( 256 | 'It must match {@link [valid URL] [description (optional)]} or {@see [valid FQSEN] [description (optional)]}', 257 | $result 258 | ); 259 | $this->assertStringNotContainsString('{@link https://moodle.org}', $result); 260 | $this->assertStringNotContainsString('{@see has_capability}', $result); 261 | $this->assertStringNotContainsString('ba8by}', $result); 262 | } 263 | 264 | /** 265 | * Verify that empty files and files without PHP aren't processed 266 | * 267 | * @dataProvider empty_nophp_files_provider 268 | * @param string $path 269 | * 270 | * @covers \local_moodlecheck_file::validate 271 | */ 272 | public function test_empty_nophp_files($file): void { 273 | global $PAGE; 274 | $output = $PAGE->get_renderer('local_moodlecheck'); 275 | $path = new local_moodlecheck_path($file, null); 276 | $result = $output->display_path($path, 'xml'); 277 | 278 | // Convert results to XML Object. 279 | $xmlresult = new \DOMDocument(); 280 | $xmlresult->loadXML($result); 281 | 282 | // Let's verify we have received a xml with file top element and 1 children. 283 | $xpath = new \DOMXpath($xmlresult); 284 | $found = $xpath->query("//file/error"); 285 | // TODO: Change to DOMNodeList::count() when php71 support is gone. 286 | $this->assertSame(1, $found->length); 287 | 288 | // Also verify various bits by content. 289 | $this->assertStringContainsString('The file is empty or doesn't contain PHP code. Skipped.', $result); 290 | } 291 | 292 | /** 293 | * Data provider for test_empty_nophp_files() 294 | * 295 | * @return array 296 | */ 297 | public static function empty_nophp_files_provider(): array { 298 | return [ 299 | 'empty' => ['local/moodlecheck/tests/fixtures/empty.php'], 300 | 'nophp' => ['local/moodlecheck/tests/fixtures/nophp.php'], 301 | ]; 302 | } 303 | 304 | /** 305 | * Verify that method parameters are correctly interpreted no matter the definition style. 306 | * 307 | * @covers ::local_moodlecheck_functionarguments 308 | */ 309 | public function test_j_method_multiline(): void { 310 | $file = __DIR__ . "/fixtures/phpdoc_method_multiline.php"; 311 | 312 | global $PAGE; 313 | $output = $PAGE->get_renderer('local_moodlecheck'); 314 | $path = new local_moodlecheck_path($file, null); 315 | $result = $output->display_path($path, 'xml'); 316 | 317 | // Convert results to XML Object. 318 | $xmlresult = new \DOMDocument(); 319 | $xmlresult->loadXML($result); 320 | 321 | $xpath = new \DOMXpath($xmlresult); 322 | $found = $xpath->query('//file/error[@source="functionarguments"]'); 323 | // TODO: Change to DOMNodeList::count() when php71 support is gone. 324 | $this->assertSame(0, $found->length); // All examples in fixtures are ok. 325 | } 326 | 327 | /** 328 | * Verify that the text format shown information about the severity of the problem (error vs warning) 329 | * 330 | * @covers \local_moodlecheck_renderer 331 | */ 332 | public function test_text_format_errors_and_warnings(): void { 333 | $file = __DIR__ . "/fixtures/error_and_warning.php"; 334 | 335 | global $PAGE; 336 | $output = $PAGE->get_renderer('local_moodlecheck'); 337 | $path = new local_moodlecheck_path($file, null); 338 | $result = $output->display_path($path, 'text'); 339 | 340 | $this->assertStringContainsString('tests/fixtures/error_and_warning.php', $result); 341 | } 342 | 343 | /** 344 | * Verify that the html format shown information about the severity of the problem (error vs warning) 345 | * 346 | * @covers \local_moodlecheck_renderer 347 | */ 348 | public function test_html_format_errors_and_warnings(): void { 349 | $file = __DIR__ . "/fixtures/error_and_warning.php"; 350 | 351 | global $PAGE; 352 | $output = $PAGE->get_renderer('local_moodlecheck'); 353 | $path = new local_moodlecheck_path($file, null); 354 | $result = $output->display_path($path, 'html'); 355 | 356 | $this->assertStringContainsString('tests/fixtures/error_and_warning.php', $result); 357 | } 358 | } 359 | -------------------------------------------------------------------------------- /tests/phpdocs_basic_test.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | namespace local_moodlecheck; 18 | 19 | /** 20 | * Contains unit tests for covering "moodle" PHPDoc rules. 21 | * 22 | * @package local_moodlecheck 23 | * @category test 24 | * @copyright 2023 Andrew Lyons 25 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 26 | */ 27 | final class phpdocs_basic_test extends \advanced_testcase { 28 | public static function setUpBeforeClass(): void { 29 | global $CFG; 30 | require_once($CFG->dirroot . '/local/moodlecheck/locallib.php'); 31 | require_once($CFG->dirroot . '/local/moodlecheck/rules/phpdocs_basic.php'); 32 | } 33 | 34 | /** 35 | * Test that normalisation of the method and docblock params works as expected. 36 | * 37 | * @dataProvider local_moodlecheck_normalise_function_type_provider 38 | * @param string $inputtype The input type. 39 | * @param string $expectedtype The expected type. 40 | * @covers ::local_moodlecheck_normalise_function_type 41 | */ 42 | public function test_local_moodlecheck_normalise_function_type(string $inputtype, string $expectedtype): void { 43 | $this->assertEquals( 44 | $expectedtype, 45 | local_moodlecheck_normalise_function_type($inputtype) 46 | ); 47 | } 48 | 49 | /** 50 | * Data provider for test_local_moodlecheck_normalise_function_type. 51 | * 52 | * @return array 53 | */ 54 | public static function local_moodlecheck_normalise_function_type_provider(): array { 55 | return [ 56 | 'Simple case' => [ 57 | 'stdClass', 'stdClass', 58 | ], 59 | 60 | 'Fully-qualified stdClass' => [ 61 | '\stdClass', 'stdClass', 62 | ], 63 | 64 | 'Fully-qualified namespaced item' => [ 65 | \core_course\local\some\type_of_item::class, 66 | 'type_of_item', 67 | ], 68 | 69 | 'Unioned simple case' => [ 70 | 'stdClass|object', 'object|stdClass', 71 | ], 72 | 73 | 'Unioned fully-qualfied case' => [ 74 | '\stdClass|\object', 'object|stdClass', 75 | ], 76 | 77 | 'Unioned fully-qualfied namespaced item' => [ 78 | '\stdClass|\core_course\local\some\type_of_item', 79 | 'stdClass|type_of_item', 80 | ], 81 | 82 | 'Nullable fully-qualified type' => [ 83 | '?\core-course\local\some\type_of_item', 84 | 'null|type_of_item', 85 | ], 86 | 87 | 'Nullable fully-qualified type z-a' => [ 88 | '?\core-course\local\some\alpha_item', 89 | 'alpha_item|null', 90 | ], 91 | ]; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /version.php: -------------------------------------------------------------------------------- 1 | . 16 | 17 | /** 18 | * Version details. 19 | * 20 | * @package local_moodlecheck 21 | * @copyright 2012 Marina Glancy 22 | * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 | */ 24 | 25 | defined('MOODLE_INTERNAL') || die; 26 | 27 | $plugin->version = 2024032700; 28 | $plugin->release = '1.3.2'; 29 | $plugin->maturity = MATURITY_STABLE; 30 | $plugin->requires = 2018051700; 31 | $plugin->component = 'local_moodlecheck'; 32 | --------------------------------------------------------------------------------