1<?php declare(strict_types=1); 2 3namespace PhpParser\Internal; 4 5use PhpParser\Token; 6 7/** 8 * Provides operations on token streams, for use by pretty printer. 9 * 10 * @internal 11 */ 12class TokenStream { 13 /** @var Token[] Tokens (in PhpToken::tokenize() format) */ 14 private array $tokens; 15 /** @var int[] Map from position to indentation */ 16 private array $indentMap; 17 18 /** 19 * Create token stream instance. 20 * 21 * @param Token[] $tokens Tokens in PhpToken::tokenize() format 22 */ 23 public function __construct(array $tokens, int $tabWidth) { 24 $this->tokens = $tokens; 25 $this->indentMap = $this->calcIndentMap($tabWidth); 26 } 27 28 /** 29 * Whether the given position is immediately surrounded by parenthesis. 30 * 31 * @param int $startPos Start position 32 * @param int $endPos End position 33 */ 34 public function haveParens(int $startPos, int $endPos): bool { 35 return $this->haveTokenImmediatelyBefore($startPos, '(') 36 && $this->haveTokenImmediatelyAfter($endPos, ')'); 37 } 38 39 /** 40 * Whether the given position is immediately surrounded by braces. 41 * 42 * @param int $startPos Start position 43 * @param int $endPos End position 44 */ 45 public function haveBraces(int $startPos, int $endPos): bool { 46 return ($this->haveTokenImmediatelyBefore($startPos, '{') 47 || $this->haveTokenImmediatelyBefore($startPos, T_CURLY_OPEN)) 48 && $this->haveTokenImmediatelyAfter($endPos, '}'); 49 } 50 51 /** 52 * Check whether the position is directly preceded by a certain token type. 53 * 54 * During this check whitespace and comments are skipped. 55 * 56 * @param int $pos Position before which the token should occur 57 * @param int|string $expectedTokenType Token to check for 58 * 59 * @return bool Whether the expected token was found 60 */ 61 public function haveTokenImmediatelyBefore(int $pos, $expectedTokenType): bool { 62 $tokens = $this->tokens; 63 $pos--; 64 for (; $pos >= 0; $pos--) { 65 $token = $tokens[$pos]; 66 if ($token->is($expectedTokenType)) { 67 return true; 68 } 69 if (!$token->isIgnorable()) { 70 break; 71 } 72 } 73 return false; 74 } 75 76 /** 77 * Check whether the position is directly followed by a certain token type. 78 * 79 * During this check whitespace and comments are skipped. 80 * 81 * @param int $pos Position after which the token should occur 82 * @param int|string $expectedTokenType Token to check for 83 * 84 * @return bool Whether the expected token was found 85 */ 86 public function haveTokenImmediatelyAfter(int $pos, $expectedTokenType): bool { 87 $tokens = $this->tokens; 88 $pos++; 89 for ($c = \count($tokens); $pos < $c; $pos++) { 90 $token = $tokens[$pos]; 91 if ($token->is($expectedTokenType)) { 92 return true; 93 } 94 if (!$token->isIgnorable()) { 95 break; 96 } 97 } 98 return false; 99 } 100 101 /** @param int|string|(int|string)[] $skipTokenType */ 102 public function skipLeft(int $pos, $skipTokenType): int { 103 $tokens = $this->tokens; 104 105 $pos = $this->skipLeftWhitespace($pos); 106 if ($skipTokenType === \T_WHITESPACE) { 107 return $pos; 108 } 109 110 if (!$tokens[$pos]->is($skipTokenType)) { 111 // Shouldn't happen. The skip token MUST be there 112 throw new \Exception('Encountered unexpected token'); 113 } 114 $pos--; 115 116 return $this->skipLeftWhitespace($pos); 117 } 118 119 /** @param int|string|(int|string)[] $skipTokenType */ 120 public function skipRight(int $pos, $skipTokenType): int { 121 $tokens = $this->tokens; 122 123 $pos = $this->skipRightWhitespace($pos); 124 if ($skipTokenType === \T_WHITESPACE) { 125 return $pos; 126 } 127 128 if (!$tokens[$pos]->is($skipTokenType)) { 129 // Shouldn't happen. The skip token MUST be there 130 throw new \Exception('Encountered unexpected token'); 131 } 132 $pos++; 133 134 return $this->skipRightWhitespace($pos); 135 } 136 137 /** 138 * Return first non-whitespace token position smaller or equal to passed position. 139 * 140 * @param int $pos Token position 141 * @return int Non-whitespace token position 142 */ 143 public function skipLeftWhitespace(int $pos): int { 144 $tokens = $this->tokens; 145 for (; $pos >= 0; $pos--) { 146 if (!$tokens[$pos]->isIgnorable()) { 147 break; 148 } 149 } 150 return $pos; 151 } 152 153 /** 154 * Return first non-whitespace position greater or equal to passed position. 155 * 156 * @param int $pos Token position 157 * @return int Non-whitespace token position 158 */ 159 public function skipRightWhitespace(int $pos): int { 160 $tokens = $this->tokens; 161 for ($count = \count($tokens); $pos < $count; $pos++) { 162 if (!$tokens[$pos]->isIgnorable()) { 163 break; 164 } 165 } 166 return $pos; 167 } 168 169 /** @param int|string|(int|string)[] $findTokenType */ 170 public function findRight(int $pos, $findTokenType): int { 171 $tokens = $this->tokens; 172 for ($count = \count($tokens); $pos < $count; $pos++) { 173 if ($tokens[$pos]->is($findTokenType)) { 174 return $pos; 175 } 176 } 177 return -1; 178 } 179 180 /** 181 * Whether the given position range contains a certain token type. 182 * 183 * @param int $startPos Starting position (inclusive) 184 * @param int $endPos Ending position (exclusive) 185 * @param int|string $tokenType Token type to look for 186 * @return bool Whether the token occurs in the given range 187 */ 188 public function haveTokenInRange(int $startPos, int $endPos, $tokenType): bool { 189 $tokens = $this->tokens; 190 for ($pos = $startPos; $pos < $endPos; $pos++) { 191 if ($tokens[$pos]->is($tokenType)) { 192 return true; 193 } 194 } 195 return false; 196 } 197 198 public function haveTagInRange(int $startPos, int $endPos): bool { 199 return $this->haveTokenInRange($startPos, $endPos, \T_OPEN_TAG) 200 || $this->haveTokenInRange($startPos, $endPos, \T_CLOSE_TAG); 201 } 202 203 /** 204 * Get indentation before token position. 205 * 206 * @param int $pos Token position 207 * 208 * @return int Indentation depth (in spaces) 209 */ 210 public function getIndentationBefore(int $pos): int { 211 return $this->indentMap[$pos]; 212 } 213 214 /** 215 * Get the code corresponding to a token offset range, optionally adjusted for indentation. 216 * 217 * @param int $from Token start position (inclusive) 218 * @param int $to Token end position (exclusive) 219 * @param int $indent By how much the code should be indented (can be negative as well) 220 * 221 * @return string Code corresponding to token range, adjusted for indentation 222 */ 223 public function getTokenCode(int $from, int $to, int $indent): string { 224 $tokens = $this->tokens; 225 $result = ''; 226 for ($pos = $from; $pos < $to; $pos++) { 227 $token = $tokens[$pos]; 228 $id = $token->id; 229 $text = $token->text; 230 if ($id === \T_CONSTANT_ENCAPSED_STRING || $id === \T_ENCAPSED_AND_WHITESPACE) { 231 $result .= $text; 232 } else { 233 // TODO Handle non-space indentation 234 if ($indent < 0) { 235 $result .= str_replace("\n" . str_repeat(" ", -$indent), "\n", $text); 236 } elseif ($indent > 0) { 237 $result .= str_replace("\n", "\n" . str_repeat(" ", $indent), $text); 238 } else { 239 $result .= $text; 240 } 241 } 242 } 243 return $result; 244 } 245 246 /** 247 * Precalculate the indentation at every token position. 248 * 249 * @return int[] Token position to indentation map 250 */ 251 private function calcIndentMap(int $tabWidth): array { 252 $indentMap = []; 253 $indent = 0; 254 foreach ($this->tokens as $i => $token) { 255 $indentMap[] = $indent; 256 257 if ($token->id === \T_WHITESPACE) { 258 $content = $token->text; 259 $newlinePos = \strrpos($content, "\n"); 260 if (false !== $newlinePos) { 261 $indent = $this->getIndent(\substr($content, $newlinePos + 1), $tabWidth); 262 } elseif ($i === 1 && $this->tokens[0]->id === \T_OPEN_TAG && 263 $this->tokens[0]->text[\strlen($this->tokens[0]->text) - 1] === "\n") { 264 // Special case: Newline at the end of opening tag followed by whitespace. 265 $indent = $this->getIndent($content, $tabWidth); 266 } 267 } 268 } 269 270 // Add a sentinel for one past end of the file 271 $indentMap[] = $indent; 272 273 return $indentMap; 274 } 275 276 private function getIndent(string $ws, int $tabWidth): int { 277 $spaces = \substr_count($ws, " "); 278 $tabs = \substr_count($ws, "\t"); 279 assert(\strlen($ws) === $spaces + $tabs); 280 return $spaces + $tabs * $tabWidth; 281 } 282} 283