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