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