$tokens */ public function __construct( private array $tokens = [] ) { } /** * Get iterator for the collection */ public function getIterator(): Traversable { return new ArrayIterator($this->tokens); } /** * Count tokens in collection */ public function count(): int { return count($this->tokens); } /** * Get all tokens as array * @return array */ public function toArray(): array { return $this->tokens; } /** * Get token at index */ public function get(int $index): ?Token { return $this->tokens[$index] ?? null; } /** * Filter tokens by predicate */ public function filter(callable $predicate): self { return new self(array_values(array_filter($this->tokens, $predicate))); } /** * Filter by token type */ public function filterByType(TokenType ...$types): self { return $this->filter(fn (Token $token) => in_array($token->type, $types, true)); } /** * Filter by PHP token ID */ public function filterById(int ...$ids): self { return $this->filter(fn (Token $token) => in_array($token->id, $ids, true)); } /** * Get only structural tokens */ public function getStructural(): self { return $this->filter(fn (Token $token) => $token->isStructural()); } /** * Get only identifiers */ public function getIdentifiers(): self { return $this->filter(fn (Token $token) => $token->isIdentifier()); } /** * Get tokens in line range */ public function getInLineRange(int $startLine, int $endLine): self { return $this->filter( fn (Token $token) => $token->line >= $startLine && $token->line <= $endLine ); } /** * Get tokens at specific line */ public function getAtLine(int $line): self { return $this->filter(fn (Token $token) => $token->line === $line); } /** * Find first token matching predicate */ public function findFirst(callable $predicate): ?Token { foreach ($this->tokens as $token) { if ($predicate($token)) { return $token; } } return null; } /** * Find first token of type */ public function findFirstOfType(TokenType $type): ?Token { return $this->findFirst(fn (Token $token) => $token->type === $type); } /** * Map tokens to another form */ public function map(callable $mapper): array { return array_map($mapper, $this->tokens); } /** * Extract all values */ public function getValues(): array { return $this->map(fn (Token $token) => $token->value); } /** * Extract all clean values */ public function getCleanValues(): array { return $this->map(fn (Token $token) => $token->getCleanValue()); } /** * Get tokens grouped by type * @return array> */ public function groupByType(): array { $groups = []; foreach ($this->tokens as $token) { $type = $token->type->value; if (! isset($groups[$type])) { $groups[$type] = []; } $groups[$type][] = $token; } return $groups; } /** * Get tokens grouped by line * @return array> */ public function groupByLine(): array { $groups = []; foreach ($this->tokens as $token) { // Split whitespace tokens that span multiple lines if ($token->type === TokenType::WHITESPACE && str_contains($token->value, "\n")) { $splitTokens = $this->splitWhitespaceAtNewlines($token); foreach ($splitTokens as $splitToken) { if (! isset($groups[$splitToken->line])) { $groups[$splitToken->line] = []; } $groups[$splitToken->line][] = $splitToken; } } else { if (! isset($groups[$token->line])) { $groups[$token->line] = []; } $groups[$token->line][] = $token; } } return $groups; } /** * Split a whitespace token at newline boundaries * * When a whitespace token spans multiple lines like "\n ", the leading spaces * after the newline belong to the NEXT line, not the current one. * * Example: Token with value "\n " on line 13: * - "\n" belongs to line 13 (ends the line) * - " " belongs to line 14 (starts the next line) * * @return Token[] */ private function splitWhitespaceAtNewlines(Token $token): array { $parts = preg_split('/(\r\n|\n|\r)/', $token->value, -1, PREG_SPLIT_DELIM_CAPTURE); $result = []; // Calculate starting line: token.line is where it ENDS $startLine = $token->line - substr_count($token->value, "\n"); $currentLine = $startLine; for ($i = 0; $i < count($parts); $i++) { $part = $parts[$i]; // Skip empty parts if ($part === '') { continue; } // Check if this is a newline delimiter $isNewline = in_array($part, ["\r\n", "\n", "\r"], true); if ($isNewline) { // Newline belongs to current line (ends it) $result[] = new Token( type: TokenType::WHITESPACE, value: $part, line: $currentLine, position: $token->position, id: $token->id, context: $token->context ); $currentLine++; } else { // Non-newline whitespace: assign to current line // (which is the line AFTER the newline if we just processed one) $result[] = new Token( type: TokenType::WHITESPACE, value: $part, line: $currentLine, position: $token->position, id: $token->id, context: $token->context ); } } return $result; } /** * Check if collection is empty */ public function isEmpty(): bool { return empty($this->tokens); } /** * Get first token */ public function first(): ?Token { return $this->tokens[0] ?? null; } /** * Get last token */ public function last(): ?Token { return empty($this->tokens) ? null : $this->tokens[count($this->tokens) - 1]; } /** * Slice collection */ public function slice(int $offset, ?int $length = null): self { return new self(array_slice($this->tokens, $offset, $length)); } /** * Merge with another collection */ public function merge(self $other): self { return new self(array_merge($this->tokens, $other->tokens)); } /** * Convert to string (concatenate all values) */ public function toString(): string { return implode('', $this->getValues()); } }