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