1<?php
2/*
3 * This file is part of php-token-stream.
4 *
5 * (c) Sebastian Bergmann <sebastian@phpunit.de>
6 *
7 * For the full copyright and license information, please view the LICENSE
8 * file that was distributed with this source code.
9 */
10
11/**
12 * A stream of PHP tokens.
13 */
14class PHP_Token_Stream implements ArrayAccess, Countable, SeekableIterator
15{
16    /**
17     * @var array
18     */
19    protected static $customTokens = [
20        '(' => 'PHP_Token_OPEN_BRACKET',
21        ')' => 'PHP_Token_CLOSE_BRACKET',
22        '[' => 'PHP_Token_OPEN_SQUARE',
23        ']' => 'PHP_Token_CLOSE_SQUARE',
24        '{' => 'PHP_Token_OPEN_CURLY',
25        '}' => 'PHP_Token_CLOSE_CURLY',
26        ';' => 'PHP_Token_SEMICOLON',
27        '.' => 'PHP_Token_DOT',
28        ',' => 'PHP_Token_COMMA',
29        '=' => 'PHP_Token_EQUAL',
30        '<' => 'PHP_Token_LT',
31        '>' => 'PHP_Token_GT',
32        '+' => 'PHP_Token_PLUS',
33        '-' => 'PHP_Token_MINUS',
34        '*' => 'PHP_Token_MULT',
35        '/' => 'PHP_Token_DIV',
36        '?' => 'PHP_Token_QUESTION_MARK',
37        '!' => 'PHP_Token_EXCLAMATION_MARK',
38        ':' => 'PHP_Token_COLON',
39        '"' => 'PHP_Token_DOUBLE_QUOTES',
40        '@' => 'PHP_Token_AT',
41        '&' => 'PHP_Token_AMPERSAND',
42        '%' => 'PHP_Token_PERCENT',
43        '|' => 'PHP_Token_PIPE',
44        '$' => 'PHP_Token_DOLLAR',
45        '^' => 'PHP_Token_CARET',
46        '~' => 'PHP_Token_TILDE',
47        '`' => 'PHP_Token_BACKTICK'
48    ];
49
50    /**
51     * @var string
52     */
53    protected $filename;
54
55    /**
56     * @var array
57     */
58    protected $tokens = [];
59
60    /**
61     * @var int
62     */
63    protected $position = 0;
64
65    /**
66     * @var array
67     */
68    protected $linesOfCode = ['loc' => 0, 'cloc' => 0, 'ncloc' => 0];
69
70    /**
71     * @var array
72     */
73    protected $classes;
74
75    /**
76     * @var array
77     */
78    protected $functions;
79
80    /**
81     * @var array
82     */
83    protected $includes;
84
85    /**
86     * @var array
87     */
88    protected $interfaces;
89
90    /**
91     * @var array
92     */
93    protected $traits;
94
95    /**
96     * @var array
97     */
98    protected $lineToFunctionMap = [];
99
100    /**
101     * Constructor.
102     *
103     * @param string $sourceCode
104     */
105    public function __construct($sourceCode)
106    {
107        if (is_file($sourceCode)) {
108            $this->filename = $sourceCode;
109            $sourceCode     = file_get_contents($sourceCode);
110        }
111
112        $this->scan($sourceCode);
113    }
114
115    /**
116     * Destructor.
117     */
118    public function __destruct()
119    {
120        $this->tokens = [];
121    }
122
123    /**
124     * @return string
125     */
126    public function __toString()
127    {
128        $buffer = '';
129
130        foreach ($this as $token) {
131            $buffer .= $token;
132        }
133
134        return $buffer;
135    }
136
137    /**
138     * @return string
139     */
140    public function getFilename()
141    {
142        return $this->filename;
143    }
144
145    /**
146     * Scans the source for sequences of characters and converts them into a
147     * stream of tokens.
148     *
149     * @param string $sourceCode
150     */
151    protected function scan($sourceCode)
152    {
153        $id        = 0;
154        $line      = 1;
155        $tokens    = token_get_all($sourceCode);
156        $numTokens = count($tokens);
157
158        $lastNonWhitespaceTokenWasDoubleColon = false;
159
160        for ($i = 0; $i < $numTokens; ++$i) {
161            $token = $tokens[$i];
162            $skip  = 0;
163
164            if (is_array($token)) {
165                $name = substr(token_name($token[0]), 2);
166                $text = $token[1];
167
168                if ($lastNonWhitespaceTokenWasDoubleColon && $name == 'CLASS') {
169                    $name = 'CLASS_NAME_CONSTANT';
170                } elseif ($name == 'USE' && isset($tokens[$i + 2][0]) && $tokens[$i + 2][0] == T_FUNCTION) {
171                    $name = 'USE_FUNCTION';
172                    $text .= $tokens[$i + 1][1] . $tokens[$i + 2][1];
173                    $skip = 2;
174                }
175
176                $tokenClass = 'PHP_Token_' . $name;
177            } else {
178                $text       = $token;
179                $tokenClass = self::$customTokens[$token];
180            }
181
182            $this->tokens[] = new $tokenClass($text, $line, $this, $id++);
183            $lines          = substr_count($text, "\n");
184            $line += $lines;
185
186            if ($tokenClass == 'PHP_Token_HALT_COMPILER') {
187                break;
188            } elseif ($tokenClass == 'PHP_Token_COMMENT' ||
189                $tokenClass == 'PHP_Token_DOC_COMMENT') {
190                $this->linesOfCode['cloc'] += $lines + 1;
191            }
192
193            if ($name == 'DOUBLE_COLON') {
194                $lastNonWhitespaceTokenWasDoubleColon = true;
195            } elseif ($name != 'WHITESPACE') {
196                $lastNonWhitespaceTokenWasDoubleColon = false;
197            }
198
199            $i += $skip;
200        }
201
202        $this->linesOfCode['loc']   = substr_count($sourceCode, "\n");
203        $this->linesOfCode['ncloc'] = $this->linesOfCode['loc'] -
204                                      $this->linesOfCode['cloc'];
205    }
206
207    /**
208     * @return int
209     */
210    public function count()
211    {
212        return count($this->tokens);
213    }
214
215    /**
216     * @return PHP_Token[]
217     */
218    public function tokens()
219    {
220        return $this->tokens;
221    }
222
223    /**
224     * @return array
225     */
226    public function getClasses()
227    {
228        if ($this->classes !== null) {
229            return $this->classes;
230        }
231
232        $this->parse();
233
234        return $this->classes;
235    }
236
237    /**
238     * @return array
239     */
240    public function getFunctions()
241    {
242        if ($this->functions !== null) {
243            return $this->functions;
244        }
245
246        $this->parse();
247
248        return $this->functions;
249    }
250
251    /**
252     * @return array
253     */
254    public function getInterfaces()
255    {
256        if ($this->interfaces !== null) {
257            return $this->interfaces;
258        }
259
260        $this->parse();
261
262        return $this->interfaces;
263    }
264
265    /**
266     * @return array
267     */
268    public function getTraits()
269    {
270        if ($this->traits !== null) {
271            return $this->traits;
272        }
273
274        $this->parse();
275
276        return $this->traits;
277    }
278
279    /**
280     * Gets the names of all files that have been included
281     * using include(), include_once(), require() or require_once().
282     *
283     * Parameter $categorize set to TRUE causing this function to return a
284     * multi-dimensional array with categories in the keys of the first dimension
285     * and constants and their values in the second dimension.
286     *
287     * Parameter $category allow to filter following specific inclusion type
288     *
289     * @param bool   $categorize OPTIONAL
290     * @param string $category   OPTIONAL Either 'require_once', 'require',
291     *                           'include_once', 'include'.
292     *
293     * @return array
294     */
295    public function getIncludes($categorize = false, $category = null)
296    {
297        if ($this->includes === null) {
298            $this->includes = [
299              'require_once' => [],
300              'require'      => [],
301              'include_once' => [],
302              'include'      => []
303            ];
304
305            foreach ($this->tokens as $token) {
306                switch (get_class($token)) {
307                    case 'PHP_Token_REQUIRE_ONCE':
308                    case 'PHP_Token_REQUIRE':
309                    case 'PHP_Token_INCLUDE_ONCE':
310                    case 'PHP_Token_INCLUDE':
311                        $this->includes[$token->getType()][] = $token->getName();
312                        break;
313                }
314            }
315        }
316
317        if (isset($this->includes[$category])) {
318            $includes = $this->includes[$category];
319        } elseif ($categorize === false) {
320            $includes = array_merge(
321                $this->includes['require_once'],
322                $this->includes['require'],
323                $this->includes['include_once'],
324                $this->includes['include']
325            );
326        } else {
327            $includes = $this->includes;
328        }
329
330        return $includes;
331    }
332
333    /**
334     * Returns the name of the function or method a line belongs to.
335     *
336     * @return string or null if the line is not in a function or method
337     */
338    public function getFunctionForLine($line)
339    {
340        $this->parse();
341
342        if (isset($this->lineToFunctionMap[$line])) {
343            return $this->lineToFunctionMap[$line];
344        }
345    }
346
347    protected function parse()
348    {
349        $this->interfaces = [];
350        $this->classes    = [];
351        $this->traits     = [];
352        $this->functions  = [];
353        $class            = [];
354        $classEndLine     = [];
355        $trait            = false;
356        $traitEndLine     = false;
357        $interface        = false;
358        $interfaceEndLine = false;
359
360        foreach ($this->tokens as $token) {
361            switch (get_class($token)) {
362                case 'PHP_Token_HALT_COMPILER':
363                    return;
364
365                case 'PHP_Token_INTERFACE':
366                    $interface        = $token->getName();
367                    $interfaceEndLine = $token->getEndLine();
368
369                    $this->interfaces[$interface] = [
370                      'methods'   => [],
371                      'parent'    => $token->getParent(),
372                      'keywords'  => $token->getKeywords(),
373                      'docblock'  => $token->getDocblock(),
374                      'startLine' => $token->getLine(),
375                      'endLine'   => $interfaceEndLine,
376                      'package'   => $token->getPackage(),
377                      'file'      => $this->filename
378                    ];
379                    break;
380
381                case 'PHP_Token_CLASS':
382                case 'PHP_Token_TRAIT':
383                    $tmp = [
384                      'methods'   => [],
385                      'parent'    => $token->getParent(),
386                      'interfaces'=> $token->getInterfaces(),
387                      'keywords'  => $token->getKeywords(),
388                      'docblock'  => $token->getDocblock(),
389                      'startLine' => $token->getLine(),
390                      'endLine'   => $token->getEndLine(),
391                      'package'   => $token->getPackage(),
392                      'file'      => $this->filename
393                    ];
394
395                    if ($token instanceof PHP_Token_CLASS) {
396                        $class[]        = $token->getName();
397                        $classEndLine[] = $token->getEndLine();
398
399                        $this->classes[$class[count($class) - 1]] = $tmp;
400                    } else {
401                        $trait                = $token->getName();
402                        $traitEndLine         = $token->getEndLine();
403                        $this->traits[$trait] = $tmp;
404                    }
405                    break;
406
407                case 'PHP_Token_FUNCTION':
408                    $name = $token->getName();
409                    $tmp  = [
410                      'docblock'  => $token->getDocblock(),
411                      'keywords'  => $token->getKeywords(),
412                      'visibility'=> $token->getVisibility(),
413                      'signature' => $token->getSignature(),
414                      'startLine' => $token->getLine(),
415                      'endLine'   => $token->getEndLine(),
416                      'ccn'       => $token->getCCN(),
417                      'file'      => $this->filename
418                    ];
419
420                    if (empty($class) &&
421                        $trait === false &&
422                        $interface === false) {
423                        $this->functions[$name] = $tmp;
424
425                        $this->addFunctionToMap(
426                            $name,
427                            $tmp['startLine'],
428                            $tmp['endLine']
429                        );
430                    } elseif (!empty($class)) {
431                        $this->classes[$class[count($class) - 1]]['methods'][$name] = $tmp;
432
433                        $this->addFunctionToMap(
434                            $class[count($class) - 1] . '::' . $name,
435                            $tmp['startLine'],
436                            $tmp['endLine']
437                        );
438                    } elseif ($trait !== false) {
439                        $this->traits[$trait]['methods'][$name] = $tmp;
440
441                        $this->addFunctionToMap(
442                            $trait . '::' . $name,
443                            $tmp['startLine'],
444                            $tmp['endLine']
445                        );
446                    } else {
447                        $this->interfaces[$interface]['methods'][$name] = $tmp;
448                    }
449                    break;
450
451                case 'PHP_Token_CLOSE_CURLY':
452                    if (!empty($classEndLine) &&
453                        $classEndLine[count($classEndLine) - 1] == $token->getLine()) {
454                        array_pop($classEndLine);
455                        array_pop($class);
456                    } elseif ($traitEndLine !== false &&
457                        $traitEndLine == $token->getLine()) {
458                        $trait        = false;
459                        $traitEndLine = false;
460                    } elseif ($interfaceEndLine !== false &&
461                        $interfaceEndLine == $token->getLine()) {
462                        $interface        = false;
463                        $interfaceEndLine = false;
464                    }
465                    break;
466            }
467        }
468    }
469
470    /**
471     * @return array
472     */
473    public function getLinesOfCode()
474    {
475        return $this->linesOfCode;
476    }
477
478    /**
479     */
480    public function rewind()
481    {
482        $this->position = 0;
483    }
484
485    /**
486     * @return bool
487     */
488    public function valid()
489    {
490        return isset($this->tokens[$this->position]);
491    }
492
493    /**
494     * @return int
495     */
496    public function key()
497    {
498        return $this->position;
499    }
500
501    /**
502     * @return PHP_Token
503     */
504    public function current()
505    {
506        return $this->tokens[$this->position];
507    }
508
509    /**
510     */
511    public function next()
512    {
513        $this->position++;
514    }
515
516    /**
517     * @param int $offset
518     *
519     * @return bool
520     */
521    public function offsetExists($offset)
522    {
523        return isset($this->tokens[$offset]);
524    }
525
526    /**
527     * @param int $offset
528     *
529     * @return mixed
530     *
531     * @throws OutOfBoundsException
532     */
533    public function offsetGet($offset)
534    {
535        if (!$this->offsetExists($offset)) {
536            throw new OutOfBoundsException(
537                sprintf(
538                    'No token at position "%s"',
539                    $offset
540                )
541            );
542        }
543
544        return $this->tokens[$offset];
545    }
546
547    /**
548     * @param int   $offset
549     * @param mixed $value
550     */
551    public function offsetSet($offset, $value)
552    {
553        $this->tokens[$offset] = $value;
554    }
555
556    /**
557     * @param int $offset
558     *
559     * @throws OutOfBoundsException
560     */
561    public function offsetUnset($offset)
562    {
563        if (!$this->offsetExists($offset)) {
564            throw new OutOfBoundsException(
565                sprintf(
566                    'No token at position "%s"',
567                    $offset
568                )
569            );
570        }
571
572        unset($this->tokens[$offset]);
573    }
574
575    /**
576     * Seek to an absolute position.
577     *
578     * @param int $position
579     *
580     * @throws OutOfBoundsException
581     */
582    public function seek($position)
583    {
584        $this->position = $position;
585
586        if (!$this->valid()) {
587            throw new OutOfBoundsException(
588                sprintf(
589                    'No token at position "%s"',
590                    $this->position
591                )
592            );
593        }
594    }
595
596    /**
597     * @param string $name
598     * @param int    $startLine
599     * @param int    $endLine
600     */
601    private function addFunctionToMap($name, $startLine, $endLine)
602    {
603        for ($line = $startLine; $line <= $endLine; $line++) {
604            $this->lineToFunctionMap[$line] = $name;
605        }
606    }
607}
608