1: <?php declare(strict_types = 1);
2:
3: namespace PHPStan\PhpDocParser\Parser;
4:
5: use LogicException;
6: use PHPStan\PhpDocParser\Ast\Comment;
7: use PHPStan\PhpDocParser\Lexer\Lexer;
8: use function array_pop;
9: use function assert;
10: use function count;
11: use function in_array;
12: use function preg_match_all;
13: use function strlen;
14: use function strpos;
15: use function substr;
16:
17: class TokenIterator
18: {
19:
20: /** @var list<array{string, int, int}> */
21: private array $tokens;
22:
23: private int $index;
24:
25: /** @var list<Comment> */
26: private array $comments = [];
27:
28: /** @var list<array{int, list<Comment>}> */
29: private array $savePoints = [];
30:
31: /** @var list<int> */
32: private array $skippedTokenTypes = [Lexer::TOKEN_HORIZONTAL_WS];
33:
34: private ?string $newline = null;
35:
36: /** @var list<int>|null */
37: private ?array $offsets = null;
38:
39: /** @var array<string, int>|null the last index of a token containing "/name>", by the name */
40: private ?array $endTagIndexes = null;
41:
42: /**
43: * @param list<array{string, int, int}> $tokens
44: */
45: public function __construct(array $tokens, int $index = 0)
46: {
47: $this->tokens = $tokens;
48: $this->index = $index;
49:
50: $this->skipIrrelevantTokens();
51: }
52:
53: /**
54: * @return list<array{string, int, int}>
55: */
56: public function getTokens(): array
57: {
58: return $this->tokens;
59: }
60:
61: public function getContentBetween(int $startPos, int $endPos): string
62: {
63: if ($startPos < 0 || $endPos > count($this->tokens)) {
64: throw new LogicException();
65: }
66:
67: $content = '';
68: for ($i = $startPos; $i < $endPos; $i++) {
69: $content .= $this->tokens[$i][Lexer::VALUE_OFFSET];
70: }
71:
72: return $content;
73: }
74:
75: public function getTokenCount(): int
76: {
77: return count($this->tokens);
78: }
79:
80: public function currentTokenValue(): string
81: {
82: return $this->tokens[$this->index][Lexer::VALUE_OFFSET];
83: }
84:
85: public function currentTokenType(): int
86: {
87: return $this->tokens[$this->index][Lexer::TYPE_OFFSET];
88: }
89:
90: public function currentTokenOffset(): int
91: {
92: if ($this->offsets === null) {
93: $offset = 0;
94: $this->offsets = [];
95: foreach ($this->tokens as $token) {
96: $this->offsets[] = $offset;
97: $offset += strlen($token[Lexer::VALUE_OFFSET]);
98: }
99: }
100:
101: return $this->offsets[$this->index];
102: }
103:
104: public function currentTokenLine(): int
105: {
106: return $this->tokens[$this->index][Lexer::LINE_OFFSET];
107: }
108:
109: public function currentTokenIndex(): int
110: {
111: return $this->index;
112: }
113:
114: public function endIndexOfLastRelevantToken(): int
115: {
116: $endIndex = $this->currentTokenIndex();
117: $endIndex--;
118: while (in_array($this->tokens[$endIndex][Lexer::TYPE_OFFSET], $this->skippedTokenTypes, true)) {
119: if (!isset($this->tokens[$endIndex - 1])) {
120: break;
121: }
122: $endIndex--;
123: }
124:
125: return $endIndex;
126: }
127:
128: public function isCurrentTokenValue(string $tokenValue): bool
129: {
130: return $this->tokens[$this->index][Lexer::VALUE_OFFSET] === $tokenValue;
131: }
132:
133: public function isCurrentTokenType(int ...$tokenType): bool
134: {
135: return in_array($this->tokens[$this->index][Lexer::TYPE_OFFSET], $tokenType, true);
136: }
137:
138: public function isPrecededByHorizontalWhitespace(): bool
139: {
140: return ($this->tokens[$this->index - 1][Lexer::TYPE_OFFSET] ?? -1) === Lexer::TOKEN_HORIZONTAL_WS;
141: }
142:
143: /**
144: * @throws ParserException
145: */
146: public function consumeTokenType(int $tokenType): void
147: {
148: if ($this->tokens[$this->index][Lexer::TYPE_OFFSET] !== $tokenType) {
149: $this->throwError($tokenType);
150: }
151:
152: if ($tokenType === Lexer::TOKEN_PHPDOC_EOL) {
153: if ($this->newline === null) {
154: $this->detectNewline();
155: }
156: }
157:
158: $this->next();
159: }
160:
161: /**
162: * @throws ParserException
163: */
164: public function consumeTokenValue(int $tokenType, string $tokenValue): void
165: {
166: if ($this->tokens[$this->index][Lexer::TYPE_OFFSET] !== $tokenType || $this->tokens[$this->index][Lexer::VALUE_OFFSET] !== $tokenValue) {
167: $this->throwError($tokenType, $tokenValue);
168: }
169:
170: $this->next();
171: }
172:
173: /** @phpstan-impure */
174: public function tryConsumeTokenValue(string $tokenValue): bool
175: {
176: if ($this->tokens[$this->index][Lexer::VALUE_OFFSET] !== $tokenValue) {
177: return false;
178: }
179:
180: $this->next();
181:
182: return true;
183: }
184:
185: /**
186: * @return list<Comment>
187: */
188: public function flushComments(): array
189: {
190: $res = $this->comments;
191: $this->comments = [];
192: return $res;
193: }
194:
195: /** @phpstan-impure */
196: public function tryConsumeTokenType(int $tokenType): bool
197: {
198: if ($this->tokens[$this->index][Lexer::TYPE_OFFSET] !== $tokenType) {
199: return false;
200: }
201:
202: if ($tokenType === Lexer::TOKEN_PHPDOC_EOL) {
203: if ($this->newline === null) {
204: $this->detectNewline();
205: }
206: }
207:
208: $this->next();
209:
210: return true;
211: }
212:
213: /**
214: * @deprecated Use skipNewLineTokensAndConsumeComments instead (when parsing a type)
215: */
216: public function skipNewLineTokens(): void
217: {
218: if (!$this->isCurrentTokenType(Lexer::TOKEN_PHPDOC_EOL)) {
219: return;
220: }
221:
222: do {
223: $foundNewLine = $this->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL);
224: } while ($foundNewLine === true);
225: }
226:
227: public function skipNewLineTokensAndConsumeComments(): void
228: {
229: if ($this->currentTokenType() === Lexer::TOKEN_COMMENT) {
230: $this->comments[] = new Comment($this->currentTokenValue(), $this->currentTokenLine(), $this->currentTokenIndex());
231: $this->next();
232: }
233:
234: if (!$this->isCurrentTokenType(Lexer::TOKEN_PHPDOC_EOL)) {
235: return;
236: }
237:
238: do {
239: $foundNewLine = $this->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL);
240: if ($this->currentTokenType() !== Lexer::TOKEN_COMMENT) {
241: continue;
242: }
243:
244: $this->comments[] = new Comment($this->currentTokenValue(), $this->currentTokenLine(), $this->currentTokenIndex());
245: $this->next();
246: } while ($foundNewLine === true);
247: }
248:
249: private function detectNewline(): void
250: {
251: $value = $this->currentTokenValue();
252: if (substr($value, 0, 2) === "\r\n") {
253: $this->newline = "\r\n";
254: } elseif (substr($value, 0, 1) === "\n") {
255: $this->newline = "\n";
256: }
257: }
258:
259: public function getSkippedHorizontalWhiteSpaceIfAny(): string
260: {
261: if ($this->index > 0 && $this->tokens[$this->index - 1][Lexer::TYPE_OFFSET] === Lexer::TOKEN_HORIZONTAL_WS) {
262: return $this->tokens[$this->index - 1][Lexer::VALUE_OFFSET];
263: }
264:
265: return '';
266: }
267:
268: /** @phpstan-impure */
269: public function joinUntil(int ...$tokenType): string
270: {
271: $s = '';
272: while (!in_array($this->tokens[$this->index][Lexer::TYPE_OFFSET], $tokenType, true)) {
273: $s .= $this->tokens[$this->index++][Lexer::VALUE_OFFSET];
274: }
275: return $s;
276: }
277:
278: public function next(): void
279: {
280: $this->index++;
281: $this->skipIrrelevantTokens();
282: }
283:
284: private function skipIrrelevantTokens(): void
285: {
286: if (!isset($this->tokens[$this->index])) {
287: return;
288: }
289:
290: while (in_array($this->tokens[$this->index][Lexer::TYPE_OFFSET], $this->skippedTokenTypes, true)) {
291: if (!isset($this->tokens[$this->index + 1])) {
292: break;
293: }
294: $this->index++;
295: }
296: }
297:
298: public function addEndOfLineToSkippedTokens(): void
299: {
300: $this->skippedTokenTypes = [Lexer::TOKEN_HORIZONTAL_WS, Lexer::TOKEN_PHPDOC_EOL];
301: }
302:
303: public function removeEndOfLineFromSkippedTokens(): void
304: {
305: $this->skippedTokenTypes = [Lexer::TOKEN_HORIZONTAL_WS];
306: }
307:
308: /** @phpstan-impure */
309: public function forwardToTheEnd(): void
310: {
311: $lastToken = count($this->tokens) - 1;
312: $this->index = $lastToken;
313: }
314:
315: public function pushSavePoint(): void
316: {
317: $this->savePoints[] = [$this->index, $this->comments];
318: }
319:
320: public function dropSavePoint(): void
321: {
322: array_pop($this->savePoints);
323: }
324:
325: public function rollback(): void
326: {
327: $savepoint = array_pop($this->savePoints);
328: assert($savepoint !== null);
329: [$this->index, $this->comments] = $savepoint;
330: }
331:
332: /**
333: * @throws ParserException
334: */
335: private function throwError(int $expectedTokenType, ?string $expectedTokenValue = null): void
336: {
337: throw new ParserException(
338: $this->currentTokenValue(),
339: $this->currentTokenType(),
340: $this->currentTokenOffset(),
341: $expectedTokenType,
342: $expectedTokenValue,
343: $this->currentTokenLine(),
344: );
345: }
346:
347: /**
348: * Check whether the position is directly preceded by a certain token type.
349: *
350: * During this check TOKEN_HORIZONTAL_WS and TOKEN_PHPDOC_EOL are skipped
351: */
352: public function hasTokenImmediatelyBefore(int $pos, int $expectedTokenType): bool
353: {
354: $tokens = $this->tokens;
355: $pos--;
356: for (; $pos >= 0; $pos--) {
357: $token = $tokens[$pos];
358: $type = $token[Lexer::TYPE_OFFSET];
359: if ($type === $expectedTokenType) {
360: return true;
361: }
362: if (!in_array($type, [
363: Lexer::TOKEN_HORIZONTAL_WS,
364: Lexer::TOKEN_PHPDOC_EOL,
365: ], true)) {
366: break;
367: }
368: }
369: return false;
370: }
371:
372: /**
373: * Check whether the position is directly followed by a certain token type.
374: *
375: * During this check TOKEN_HORIZONTAL_WS and TOKEN_PHPDOC_EOL are skipped
376: */
377: public function hasTokenImmediatelyAfter(int $pos, int $expectedTokenType): bool
378: {
379: $tokens = $this->tokens;
380: $pos++;
381: for ($c = count($tokens); $pos < $c; $pos++) {
382: $token = $tokens[$pos];
383: $type = $token[Lexer::TYPE_OFFSET];
384: if ($type === $expectedTokenType) {
385: return true;
386: }
387: if (!in_array($type, [
388: Lexer::TOKEN_HORIZONTAL_WS,
389: Lexer::TOKEN_PHPDOC_EOL,
390: ], true)) {
391: break;
392: }
393: }
394:
395: return false;
396: }
397:
398: /**
399: * Whether a token at the current index or after it contains "/$name>",
400: * which every end tag of an HTML tag of that name does.
401: *
402: * The tokens are searched once, so that asking about every "<" of a
403: * PHPDoc does not walk to its end every time.
404: *
405: * @internal
406: */
407: public function hasEndTagCandidateAhead(string $name): bool
408: {
409: if ($this->endTagIndexes === null) {
410: $this->endTagIndexes = [];
411: foreach ($this->tokens as $index => $token) {
412: $value = $token[Lexer::VALUE_OFFSET];
413: if (strpos($value, '/') === false || preg_match_all('~/([^/>]++)>~', $value, $matches) === 0) {
414: continue;
415: }
416:
417: foreach ($matches[1] as $candidate) {
418: $this->endTagIndexes[$candidate] = $index;
419: }
420: }
421: }
422:
423: return ($this->endTagIndexes[$name] ?? -1) >= $this->index;
424: }
425:
426: public function getDetectedNewline(): ?string
427: {
428: return $this->newline;
429: }
430:
431: /**
432: * Whether the given position is immediately surrounded by parenthesis.
433: */
434: public function hasParentheses(int $startPos, int $endPos): bool
435: {
436: return $this->hasTokenImmediatelyBefore($startPos, Lexer::TOKEN_OPEN_PARENTHESES)
437: && $this->hasTokenImmediatelyAfter($endPos, Lexer::TOKEN_CLOSE_PARENTHESES);
438: }
439:
440: }
441: