Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 106
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
AutoloadGenerator
0.00% covered (danger)
0.00%
0 / 106
0.00% covered (danger)
0.00%
0 / 13
1640
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 setExcludePaths
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 setPsr4Namespaces
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 shouldExclude
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 forceClassPath
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 readFile
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
42
 readDir
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 generateJsonAutoload
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 generatePHPAutoload
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
30
 getAutoload
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getTargetFileinfo
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 normalizePathSeparator
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 initMediaWikiDefault
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21use MediaWiki\Json\FormatJson;
22
23/**
24 * Accepts a list of files and directories to search for
25 * php files and generates $wgAutoloadLocalClasses or $wgAutoloadClasses
26 * lines for all detected classes. These lines are written out
27 * to an autoload.php file in the projects provided basedir.
28 *
29 * Usage:
30 *
31 *     $gen = new AutoloadGenerator( __DIR__ );
32 *     $gen->readDir( __DIR__ . '/includes' );
33 *     $gen->readFile( __DIR__ . '/foo.php' )
34 *     $gen->getAutoload();
35 */
36class AutoloadGenerator {
37    private const FILETYPE_JSON = 'json';
38    private const FILETYPE_PHP = 'php';
39
40    /**
41     * @var string Root path of the project being scanned for classes
42     */
43    protected $basepath;
44
45    /**
46     * @var ClassCollector Helper class extracts class names from php files
47     */
48    protected $collector;
49
50    /**
51     * @var array Map of file shortpath to list of FQCN detected within file
52     */
53    protected $classes = [];
54
55    /**
56     * @var string The global variable to write output to
57     */
58    protected $variableName = 'wgAutoloadClasses';
59
60    /**
61     * @var array Map of FQCN to relative path(from self::$basepath)
62     */
63    protected $overrides = [];
64
65    /**
66     * Directories that should be excluded
67     *
68     * @var string[]
69     */
70    protected $excludePaths = [];
71
72    /**
73     * Configured PSR4 namespaces
74     *
75     * @var string[] namespace => path
76     */
77    protected $psr4Namespaces = [];
78
79    /**
80     * @param string $basepath Root path of the project being scanned for classes
81     * @param array|string $flags
82     *
83     *  local - If this flag is set $wgAutoloadLocalClasses will be build instead
84     *          of $wgAutoloadClasses
85     */
86    public function __construct( $basepath, $flags = [] ) {
87        if ( !is_array( $flags ) ) {
88            $flags = [ $flags ];
89        }
90        $this->basepath = self::normalizePathSeparator( realpath( $basepath ) );
91        $this->collector = new ClassCollector;
92        if ( in_array( 'local', $flags ) ) {
93            $this->variableName = 'wgAutoloadLocalClasses';
94        }
95    }
96
97    /**
98     * Directories that should be excluded
99     *
100     * @since 1.31
101     * @param string[] $paths
102     */
103    public function setExcludePaths( array $paths ) {
104        foreach ( $paths as $path ) {
105            $this->excludePaths[] = self::normalizePathSeparator( $path );
106        }
107    }
108
109    /**
110     * Unlike self::setExcludePaths(), this will only skip outputting the
111     * autoloader entry when the namespace matches the path.
112     *
113     * @since 1.32
114     * @deprecated since 1.40 - PSR-4 classes are now included in the generated classmap
115     * @param string[] $namespaces Associative array mapping namespace to path
116     */
117    public function setPsr4Namespaces( array $namespaces ) {
118        foreach ( $namespaces as $ns => $path ) {
119            $ns = rtrim( $ns, '\\' ) . '\\';
120            $this->psr4Namespaces[$ns] = rtrim( self::normalizePathSeparator( $path ), '/' );
121        }
122    }
123
124    /**
125     * Whether the file should be excluded
126     *
127     * @param string $path File path
128     * @return bool
129     */
130    private function shouldExclude( $path ) {
131        foreach ( $this->excludePaths as $dir ) {
132            if ( str_starts_with( $path, $dir ) ) {
133                return true;
134            }
135        }
136
137        return false;
138    }
139
140    /**
141     * Force a class to be autoloaded from a specific path, regardless of where
142     * or if it was detected.
143     *
144     * @param string $fqcn FQCN to force the location of
145     * @param string $inputPath Full path to the file containing the class
146     * @throws InvalidArgumentException
147     */
148    public function forceClassPath( $fqcn, $inputPath ) {
149        $path = self::normalizePathSeparator( realpath( $inputPath ) );
150        if ( !$path ) {
151            throw new InvalidArgumentException( "Invalid path: $inputPath" );
152        }
153        if ( !str_starts_with( $path, $this->basepath ) ) {
154            throw new InvalidArgumentException( "Path is not within basepath: $inputPath" );
155        }
156        $shortpath = substr( $path, strlen( $this->basepath ) );
157        $this->overrides[$fqcn] = $shortpath;
158    }
159
160    /**
161     * @param string $inputPath Path to a php file to find classes within
162     * @throws InvalidArgumentException
163     */
164    public function readFile( $inputPath ) {
165        // NOTE: do NOT expand $inputPath using realpath(). It is perfectly
166        // reasonable for LocalSettings.php and similar files to be symlinks
167        // to files that are outside of $this->basepath.
168        $inputPath = self::normalizePathSeparator( $inputPath );
169        $len = strlen( $this->basepath );
170        if ( !str_starts_with( $inputPath, $this->basepath ) ) {
171            throw new InvalidArgumentException( "Path is not within basepath: $inputPath" );
172        }
173        if ( $this->shouldExclude( $inputPath ) ) {
174            return;
175        }
176        $fileContents = file_get_contents( $inputPath );
177
178        // Skip files that declare themselves excluded
179        if ( preg_match( '!^// *NO_AUTOLOAD!m', $fileContents ) ) {
180            return;
181        }
182        // Skip files that use CommandLineInc since these execute file-scope
183        // code when included
184        if ( preg_match(
185            '/(require|require_once)[ (].*(CommandLineInc.php|commandLine.inc)/',
186            $fileContents )
187        ) {
188            return;
189        }
190
191        $result = $this->collector->getClasses( $fileContents );
192
193        if ( $result ) {
194            $shortpath = substr( $inputPath, $len );
195            $this->classes[$shortpath] = $result;
196        }
197    }
198
199    /**
200     * @param string $dir Path to a directory to recursively search for php files
201     */
202    public function readDir( $dir ) {
203        $it = new RecursiveDirectoryIterator(
204            self::normalizePathSeparator( realpath( $dir ) ) );
205        $it = new RecursiveIteratorIterator( $it );
206
207        foreach ( $it as $path => $file ) {
208            if ( pathinfo( $path, PATHINFO_EXTENSION ) === 'php' ) {
209                $this->readFile( $path );
210            }
211        }
212    }
213
214    /**
215     * Updates the AutoloadClasses field at the given
216     * filename.
217     *
218     * @param string $filename Filename of JSON
219     *  extension/skin registration file
220     * @return string Updated Json of the file given as the $filename parameter
221     */
222    protected function generateJsonAutoload( $filename ) {
223        $key = 'AutoloadClasses';
224        $json = FormatJson::decode( file_get_contents( $filename ), true );
225        unset( $json[$key] );
226        // Inverting the key-value pairs so that they become of the
227        // format class-name : path when they get converted into json.
228        foreach ( $this->classes as $path => $contained ) {
229            foreach ( $contained as $fqcn ) {
230                // Using substr to remove the leading '/'
231                $json[$key][$fqcn] = substr( $path, 1 );
232            }
233        }
234        foreach ( $this->overrides as $path => $fqcn ) {
235            // Using substr to remove the leading '/'
236            $json[$key][$fqcn] = substr( $path, 1 );
237        }
238
239        // Sorting the list of autoload classes.
240        ksort( $json[$key] );
241
242        // Return the whole JSON file
243        return FormatJson::encode( $json, "\t", FormatJson::ALL_OK ) . "\n";
244    }
245
246    /**
247     * Generates a PHP file setting up autoload information.
248     *
249     * @param string $commandName Command name to include in comment
250     * @param string $filename of PHP file to put autoload information in.
251     * @return string
252     */
253    protected function generatePHPAutoload( $commandName, $filename ) {
254        // No existing JSON file found; update/generate PHP file
255        $content = [];
256
257        // We need to generate a line each rather than exporting the
258        // full array so __DIR__ can be prepended to all the paths
259        $format = "%s => __DIR__ . %s,";
260        foreach ( $this->classes as $path => $contained ) {
261            $exportedPath = var_export( $path, true );
262            foreach ( $contained as $fqcn ) {
263                $content[$fqcn] = sprintf(
264                    $format,
265                    var_export( $fqcn, true ),
266                    $exportedPath
267                );
268            }
269        }
270
271        foreach ( $this->overrides as $fqcn => $path ) {
272            $content[$fqcn] = sprintf(
273                $format,
274                var_export( $fqcn, true ),
275                var_export( $path, true )
276            );
277        }
278
279        // sort for stable output
280        ksort( $content );
281
282        // extensions using this generator are appending to the existing
283        // autoload.
284        if ( $this->variableName === 'wgAutoloadClasses' ) {
285            $op = '+=';
286        } else {
287            $op = '=';
288        }
289
290        $output = implode( "\n\t", $content );
291        return <<<EOD
292<?php
293// This file is generated by $commandName, do not adjust manually
294// phpcs:disable Generic.Files.LineLength
295global \${$this->variableName};
296
297\${$this->variableName} {$op} [
298    {$output}
299];
300
301EOD;
302    }
303
304    /**
305     * Returns all known classes as a string, which can be used to put into a target
306     * file (e.g. extension.json, skin.json or autoload.php)
307     *
308     * @param string $commandName Value used in file comment to direct
309     *  developers towards the appropriate way to update the autoload.
310     * @return string
311     */
312    public function getAutoload( $commandName = 'AutoloadGenerator' ) {
313        // We need to check whether an extension.json or skin.json exists or not, and
314        // incase it doesn't, update the autoload.php file.
315
316        $fileinfo = $this->getTargetFileinfo();
317
318        if ( $fileinfo['type'] === self::FILETYPE_JSON ) {
319            return $this->generateJsonAutoload( $fileinfo['filename'] );
320        }
321
322        return $this->generatePHPAutoload( $commandName, $fileinfo['filename'] );
323    }
324
325    /**
326     * Returns the filename of the extension.json of skin.json, if there's any, or
327     * otherwise the path to the autoload.php file in an array as the "filename"
328     * key and with the type (AutoloadGenerator::FILETYPE_JSON or AutoloadGenerator::FILETYPE_PHP)
329     * of the file as the "type" key.
330     *
331     * @return array
332     */
333    public function getTargetFileinfo() {
334        if ( file_exists( $this->basepath . '/extension.json' ) ) {
335            return [
336                'filename' => $this->basepath . '/extension.json',
337                'type' => self::FILETYPE_JSON
338            ];
339        }
340        if ( file_exists( $this->basepath . '/skin.json' ) ) {
341            return [
342                'filename' => $this->basepath . '/skin.json',
343                'type' => self::FILETYPE_JSON
344            ];
345        }
346
347        return [
348            'filename' => $this->basepath . '/autoload.php',
349            'type' => self::FILETYPE_PHP
350        ];
351    }
352
353    /**
354     * Ensure that Unix-style path separators ("/") are used in the path.
355     *
356     * @param string $path
357     * @return string
358     */
359    protected static function normalizePathSeparator( $path ) {
360        return str_replace( '\\', '/', $path );
361    }
362
363    /**
364     * Initialize the source files and directories which are used for the MediaWiki default
365     * autoloader in {mw-base-dir}/autoload.php including:
366     *  * includes/
367     *  * languages/
368     *  * maintenance/
369     *  * mw-config/
370     *  * any `*.php` file in the base directory
371     */
372    public function initMediaWikiDefault() {
373        foreach ( [ 'includes', 'languages', 'maintenance', 'mw-config' ] as $dir ) {
374            $this->readDir( $this->basepath . '/' . $dir );
375        }
376        foreach ( glob( $this->basepath . '/*.php' ) as $file ) {
377            $this->readFile( $file );
378        }
379    }
380}