Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.89% covered (success)
95.89%
70 / 73
80.00% covered (warning)
80.00%
4 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
ClassCollector
95.89% covered (success)
95.89%
70 / 73
80.00% covered (warning)
80.00%
4 / 5
47
0.00% covered (danger)
0.00%
0 / 1
 getClasses
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
6
 tryBeginExpect
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
10
 tryEndExpect
91.18% covered (success)
91.18%
31 / 34
0.00% covered (danger)
0.00%
0 / 1
27.50
 stripQuotes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 implodeTokens
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
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 * Reads PHP code and returns the FQCN of every class defined within it.
23 */
24class ClassCollector {
25
26    /**
27     * @var string Current namespace
28     */
29    protected $namespace = '';
30
31    /**
32     * @var array List of FQCN detected in this pass
33     */
34    protected $classes;
35
36    /**
37     * @var array|null Token from token_get_all() that started an expect sequence
38     */
39    protected $startToken;
40
41    /**
42     * @var array[]|string[] List of tokens that are members of the current expect sequence
43     */
44    protected $tokens;
45
46    /**
47     * @var array|null Class alias with target/name fields
48     */
49    protected $alias;
50
51    /**
52     * @param string $code PHP code (including <?php) to detect class names from
53     * @return array List of FQCN detected within the tokens
54     */
55    public function getClasses( $code ) {
56        $this->namespace = '';
57        $this->classes = [];
58        $this->startToken = null;
59        $this->alias = null;
60        $this->tokens = [];
61
62        // HACK: The PHP tokenizer is slow (T225730).
63        // Speed it up by reducing the input to the three kinds of statement we care about:
64        // - namespace X;
65        // - [final] [abstract] class X … {}
66        // - class_alias( … );
67        $lines = [];
68        $matches = null;
69        preg_match_all(
70            // phpcs:ignore Generic.Files.LineLength.TooLong
71            '#^\t*(?:namespace |(final )?(abstract )?(class|interface|trait) |class_alias\()[^;{]+[;{]\s*\}?#m',
72            $code,
73            $matches
74        );
75        if ( isset( $matches[0][0] ) ) {
76            foreach ( $matches[0] as $match ) {
77                $match = trim( $match );
78                if ( str_ends_with( $match, '{' ) ) {
79                    // Keep it balanced
80                    $match .= '}';
81                }
82                $lines[] = $match;
83            }
84        }
85        $code = '<?php ' . implode( "\n", $lines ) . "\n";
86
87        foreach ( token_get_all( $code ) as $token ) {
88            if ( $this->startToken === null ) {
89                $this->tryBeginExpect( $token );
90            } else {
91                $this->tryEndExpect( $token );
92            }
93        }
94
95        return $this->classes;
96    }
97
98    /**
99     * Determine if $token begins the next expect sequence.
100     *
101     * @param array $token
102     */
103    protected function tryBeginExpect( $token ) {
104        if ( is_string( $token ) ) {
105            return;
106        }
107        // Note: When changing class name discovery logic,
108        // AutoLoaderStructureTest.php may also need to be updated.
109        switch ( $token[0] ) {
110            case T_NAMESPACE:
111            case T_CLASS:
112            case T_INTERFACE:
113            case T_TRAIT:
114            case T_DOUBLE_COLON:
115            case T_NEW:
116                $this->startToken = $token;
117                break;
118            case T_STRING:
119                if ( $token[1] === 'class_alias' ) {
120                    $this->startToken = $token;
121                    $this->alias = [];
122                }
123        }
124    }
125
126    /**
127     * Accepts the next token in an expect sequence
128     *
129     * @param array|string $token
130     */
131    protected function tryEndExpect( $token ) {
132        // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
133        switch ( $this->startToken[0] ) {
134            case T_DOUBLE_COLON:
135                // Skip over T_CLASS after T_DOUBLE_COLON because this is something like
136                // "ClassName::class" that evaluates to a fully qualified class name. It
137                // doesn't define a new class.
138                $this->startToken = null;
139                break;
140            case T_NEW:
141                // Skip over T_CLASS after T_NEW because this is an anonymous class.
142                if ( !is_array( $token ) || $token[0] !== T_WHITESPACE ) {
143                    $this->startToken = null;
144                }
145                break;
146            case T_NAMESPACE:
147                if ( $token === ';' || $token === '{' ) {
148                    $this->namespace = $this->implodeTokens() . '\\';
149                } else {
150                    $this->tokens[] = $token;
151                }
152                break;
153
154            case T_STRING:
155                if ( $this->alias !== null ) {
156                    // Flow 1 - Two string literals:
157                    // - T_STRING  class_alias
158                    // - '('
159                    // - T_CONSTANT_ENCAPSED_STRING 'TargetClass'
160                    // - ','
161                    // - T_WHITESPACE
162                    // - T_CONSTANT_ENCAPSED_STRING 'AliasName'
163                    // - ')'
164                    // Flow 2 - Use of ::class syntax for first parameter
165                    // - T_STRING  class_alias
166                    // - '('
167                    // - T_STRING TargetClass
168                    // - T_DOUBLE_COLON ::
169                    // - T_CLASS class
170                    // - ','
171                    // - T_WHITESPACE
172                    // - T_CONSTANT_ENCAPSED_STRING 'AliasName'
173                    // - ')'
174                    if ( $token === '(' ) {
175                        // Start of a function call to class_alias()
176                        $this->alias = [ 'target' => false, 'name' => false ];
177                    } elseif ( $token === ',' ) {
178                        // Record that we're past the first parameter
179                        if ( $this->alias['target'] === false ) {
180                            $this->alias['target'] = true;
181                        }
182                    } elseif ( is_array( $token ) && $token[0] === T_CONSTANT_ENCAPSED_STRING ) {
183                        if ( $this->alias['target'] === true ) {
184                            // We already saw a first argument, this must be the second.
185                            // Strip quotes from the string literal.
186                            $this->alias['name'] = self::stripQuotes( $token[1] );
187                        }
188                    } elseif ( $token === ')' ) {
189                        // End of function call
190                        $this->classes[] = $this->alias['name'];
191                        $this->alias = null;
192                        $this->startToken = null;
193                    } elseif ( !is_array( $token ) || (
194                            $token[0] !== T_STRING &&
195                            $token[0] !== T_DOUBLE_COLON &&
196                            $token[0] !== T_CLASS &&
197                            $token[0] !== T_WHITESPACE
198                        ) ) {
199                        // Ignore this call to class_alias() - compat/Timestamp.php
200                        $this->alias = null;
201                        $this->startToken = null;
202                    }
203                }
204                break;
205
206            case T_CLASS:
207            case T_INTERFACE:
208            case T_TRAIT:
209                $this->tokens[] = $token;
210                if ( is_array( $token ) && $token[0] === T_STRING ) {
211                    $this->classes[] = $this->namespace . $this->implodeTokens();
212                }
213        }
214    }
215
216    /**
217     * Decode a quoted PHP string, interpreting escape sequences, like eval($str).
218     * The implementation is half-baked, but the character set allowed in class
219     * names is pretty small. This could be replaced by a call to a fully-baked
220     * utility function.
221     *
222     * @param string $str
223     * @return string
224     */
225    private static function stripQuotes( $str ) {
226        return str_replace( '\\\\', '\\', substr( $str, 1, -1 ) );
227    }
228
229    /**
230     * Returns the string representation of the tokens within the
231     * current expect sequence and resets the sequence.
232     *
233     * @return string
234     */
235    protected function implodeTokens() {
236        $content = [];
237        foreach ( $this->tokens as $token ) {
238            $content[] = is_string( $token ) ? $token : $token[1];
239        }
240
241        $this->tokens = [];
242        $this->startToken = null;
243
244        return trim( implode( '', $content ), " \n\t" );
245    }
246}