lineStream = new LineStream(); $this->currentLineNumber = -1; $this->lines = $this->splitLines($source); while (true) { $this->currentLineNumber++; if (!array_key_exists($this->currentLineNumber, $this->lines)) { break; } $this->currentLineString = trim($this->lines[$this->currentLineNumber]['line']); $nextChar = substr($this->currentLineString, 0, 1); if ($nextChar === '') { continue; } $nextTwoChars = substr($this->currentLineString, 0, 2); if ($nextChar === '#' || $nextTwoChars === '//') { continue; } if ($nextTwoChars === '/*') { // @todo: This is one of multiple places where multiline "/*" comments are parsed in this tokenizer. Other // places are cluttered in detail methods. It might be more straight to have an early scanning // phase through all lines to remove comments up front, to not wire especially the multiline comment // parsing to single places, and throw away commented lines early. This isn't trivial though, since // for instance "foo = bar /* not a comment */" then needs to be sorted out, too. Having an early // "kick comments" loop however might be quicker in the end and would make the main parsing // methods more concise and probably more bullet proof. // Also note there are currently not-unit-tested edge cases, that will currently not parse as // (maybe) expected. In the example below, "foo2 = bar2" is ignored. This is an issue with the // LosslessTokenizer as well, probably, and we may rather want to declare this as invalid syntax?! // foo = bar /* comment start // comment end */ foo2 = bar2 $this->ignoreUntilEndOfMultilineComment(); continue; } if ($nextChar === '[') { $this->createConditionLine(); } elseif ($nextChar === '}') { $this->lineStream->append((new BlockCloseLine())); } elseif (str_starts_with($this->currentLineString, '@import')) { $this->parseImportLine(); } elseif (str_starts_with($this->currentLineString, 'parseIdentifier(); } } return $this->lineStream; } private function splitLines($source): array { $vanillaLines = explode(chr(10), $source); return array_map( fn(int $lineNumber, string $vanillaLine): array => [ 'line' => rtrim($vanillaLine, "\r"), ], array_keys($vanillaLines), $vanillaLines ); } private function ignoreUntilEndOfMultilineComment(): void { while (true) { if (str_contains($this->currentLineString, '*/')) { return; } if (!array_key_exists($this->currentLineNumber + 1, $this->lines)) { return; } $this->currentLineNumber++; $this->currentLineString = trim($this->lines[$this->currentLineNumber]['line']); } } /** * Create a condition line from token stream of this line. */ private function createConditionLine(): void { $upperCaseLine = strtoupper($this->currentLineString); if (str_starts_with($upperCaseLine, '[ELSE]')) { $this->lineStream->append((new ConditionElseLine())); $this->currentLineString = trim(substr($this->currentLineString, 6)); if (str_starts_with($this->currentLineString, '/*')) { $this->ignoreUntilEndOfMultilineComment(); } return; } if (str_starts_with($upperCaseLine, '[END]')) { $this->lineStream->append((new ConditionStopLine())); $this->currentLineString = trim(substr($this->currentLineString, 5)); if (str_starts_with($this->currentLineString, '/*')) { $this->ignoreUntilEndOfMultilineComment(); } return; } if (str_starts_with($upperCaseLine, '[GLOBAL]')) { $this->lineStream->append((new ConditionStopLine())); $this->currentLineString = trim(substr($this->currentLineString, 8)); if (str_starts_with($this->currentLineString, '/*')) { $this->ignoreUntilEndOfMultilineComment(); } return; } $conditionBody = ''; $conditionBodyCharCount = 0; $conditionBodyChars = mb_str_split(substr($this->currentLineString, 1), 1, 'UTF-8'); $bracketCount = 1; while (true) { $nextChar = $conditionBodyChars[$conditionBodyCharCount] ?? null; if ($nextChar === null) { // end of chars return; } if ($nextChar === '[') { $bracketCount++; $conditionBody .= $nextChar; $conditionBodyCharCount++; continue; } if ($nextChar === ']') { $bracketCount--; if ($bracketCount === 0) { if ($conditionBodyCharCount) { $conditionBodyToken = new Token(TokenType::T_VALUE, $conditionBody); $this->lineStream->append((new ConditionLine())->setValueToken($conditionBodyToken)); $conditionBodyCharCount++; break; } $conditionBodyCharCount++; break; } $conditionBody .= $nextChar; $conditionBodyCharCount++; continue; } $conditionBody .= $nextChar; $conditionBodyCharCount++; } $this->currentLineString = trim(mb_substr($this->currentLineString, $conditionBodyCharCount + 1)); if (str_starts_with($this->currentLineString, '/*')) { $this->ignoreUntilEndOfMultilineComment(); } } private function parseBlockStart(): void { $this->currentLineString = trim(substr($this->currentLineString, 1)); if (str_starts_with($this->currentLineString, '}')) { // Edge case: foo = { } in one line. Note content within {} is not parsed, everything behind { ends up as comment. $this->lineStream->append((new IdentifierBlockOpenLine())->setIdentifierTokenStream($this->identifierStream)); $this->lineStream->append((new BlockCloseLine())); return; } $this->lineStream->append((new IdentifierBlockOpenLine())->setIdentifierTokenStream($this->identifierStream)); } private function parseImportLine(): void { $this->currentLineString = trim(substr($this->currentLineString, 7)); // Next char should be the opening tick or doubletick, otherwise treat it as ignored comment $nextChar = substr($this->currentLineString, 0, 1); if ($nextChar !== '\'' && $nextChar !== '"') { return; } $importBody = ''; $importBodyCharCount = 0; $importBodyChars = mb_str_split(substr($this->currentLineString, 1), 1, 'UTF-8'); while (true) { $nextChar = $importBodyChars[$importBodyCharCount] ?? null; if ($nextChar === null) { // end of chars if ($importBodyCharCount) { $importBodyToken = (new Token(TokenType::T_VALUE, $importBody)); $this->lineStream->append((new ImportLine())->setValueToken($importBodyToken)); return; } return; } if ($nextChar === '\'' || $nextChar === '"') { if ($importBodyCharCount) { $importBodyToken = new Token(TokenType::T_VALUE, $importBody); $this->lineStream->append((new ImportLine())->setValueToken($importBodyToken)); break; } break; } $importBody .= $nextChar; $importBodyCharCount++; } $this->currentLineString = trim(mb_substr($this->currentLineString, $importBodyCharCount + 2)); if (str_starts_with($this->currentLineString, '/*')) { $this->ignoreUntilEndOfMultilineComment(); } } private function parseIdentifier(): void { $splitLine = mb_str_split($this->currentLineString, 1, 'UTF-8'); $currentPosition = $this->parseIdentifierUntilStopChar($splitLine); if (!$currentPosition) { return; } $this->currentLineString = trim(mb_substr($this->currentLineString, $currentPosition)); $nextChar = substr($this->currentLineString, 0, 1); $nextTwoChars = $nextChar . substr($this->currentLineString, 1, 1); if ($nextTwoChars === '=<') { $this->parseOperatorReference(); return; } if ($nextChar === '=') { $this->parseOperatorAssignment(); return; } if ($nextChar === '{') { $this->parseBlockStart(); return; } if ($nextChar === '>') { $this->parseOperatorUnset(); return; } if ($nextChar === '<') { $this->parseOperatorCopy(); return; } if ($nextChar === '(') { $this->parseOperatorMultilineAssignment(); return; } if ($nextTwoChars === ':=') { $this->parseOperatorFunction(); } if ($nextTwoChars === '/*') { $this->ignoreUntilEndOfMultilineComment(); } } private function parseOperatorUnset(): void { $this->lineStream->append((new IdentifierUnsetLine())->setIdentifierTokenStream($this->identifierStream)); $this->currentLineString = trim(trim(trim($this->currentLineString), '>')); if (str_starts_with($this->currentLineString, '/*')) { $this->ignoreUntilEndOfMultilineComment(); } } private function parseOperatorAssignment(): void { $this->currentLineString = trim(substr($this->currentLineString, 1)); $this->valueStream = $this->parseValueForConstants(new TokenStream(), $this->currentLineString); $this->lineStream->append((new IdentifierAssignmentLine())->setIdentifierTokenStream($this->identifierStream)->setValueTokenStream($this->valueStream)); } private function parseOperatorMultilineAssignment(): void { $this->valueStream = new TokenStream(); $this->currentLineString = substr($this->currentLineString, 1); // True if we're currently in the line with the opening '(' $isFirstLine = true; // True if the first line has a first value token: "foo ( thisIsTheFirstValueToken" $valueOnFirstLine = false; // True if the line after '(' is parsed $isSecondLine = false; while (true) { if (str_starts_with(ltrim($this->currentLineString), ')')) { $this->currentLineString = trim(substr($this->currentLineString, 1)); if (!$this->valueStream->isEmpty()) { $this->lineStream->append((new IdentifierAssignmentLine())->setIdentifierTokenStream($this->identifierStream)->setValueTokenStream($this->valueStream)); } if (str_starts_with($this->currentLineString, '/*')) { $this->ignoreUntilEndOfMultilineComment(); } return; } if ($isFirstLine && str_ends_with($this->currentLineString, ')')) { $this->currentLineString = substr($this->currentLineString, 0, -1); if (strlen($this->currentLineString) > 1) { $this->valueStream = $this->parseValueForConstants($this->valueStream, $this->currentLineString); $this->lineStream->append((new IdentifierAssignmentLine())->setIdentifierTokenStream($this->identifierStream)->setValueTokenStream($this->valueStream)); return; } return; } if ($isFirstLine && strlen($this->currentLineString)) { $this->valueStream = $this->parseValueForConstants($this->valueStream, $this->currentLineString); $valueOnFirstLine = true; } if (($isFirstLine && $valueOnFirstLine) || (!$isFirstLine && !$isSecondLine) ) { $this->valueStream->append(new Token(TokenType::T_NEWLINE, "\n")); } if (!$isFirstLine && strlen($this->currentLineString)) { $this->valueStream = $this->parseValueForConstants($this->valueStream, $this->currentLineString); } if (!array_key_exists($this->currentLineNumber + 1, $this->lines)) { return; } if ($isFirstLine) { $isSecondLine = true; } else { $isSecondLine = false; } $isFirstLine = false; $valueOnFirstLine = false; $this->currentLineNumber++; $this->currentLineString = $this->lines[$this->currentLineNumber]['line']; } } private function parseOperatorCopy(): void { $this->currentLineString = trim(substr($this->currentLineString, 1)); $identifierStream = $this->identifierStream; $charsHandled = $this->parseIdentifierAtEndOfLine(); $referenceStream = $this->identifierStream; if ($referenceStream->isEmpty()) { return; } $this->lineStream->append( (new IdentifierCopyLine()) ->setIdentifierTokenStream($identifierStream) ->setValueTokenStream($referenceStream) ); $this->currentLineString = trim(mb_substr($this->currentLineString, $charsHandled)); if (str_starts_with($this->currentLineString, '/*')) { $this->ignoreUntilEndOfMultilineComment(); } } private function parseOperatorReference(): void { $this->currentLineString = trim(substr($this->currentLineString, 2)); $identifierStream = $this->identifierStream; $charsHandled = $this->parseIdentifierAtEndOfLine(); $referenceStream = $this->identifierStream; if ($referenceStream->isEmpty()) { return; } $this->lineStream->append( (new IdentifierReferenceLine()) ->setIdentifierTokenStream($identifierStream) ->setValueTokenStream($referenceStream) ); $this->currentLineString = trim(mb_substr($this->currentLineString, $charsHandled)); if (str_starts_with($this->currentLineString, '/*')) { $this->ignoreUntilEndOfMultilineComment(); } } private function parseIdentifierAtEndOfLine(): int { $this->identifierStream = new IdentifierTokenStream(); $isRelative = false; $splitLine = mb_str_split($this->currentLineString, 1, 'UTF-8'); $char = $splitLine[0] ?? null; if ($char === null) { return 0; } $nextTwoChars = $char . ($splitLine[1] ?? ''); if ($char === '.') { // A relative right side: foo.bar < .foo (note the dot!). we identifierStream->setRelative() and // get rid of the dot for the rest of the processing. $isRelative = true; array_shift($splitLine); $this->currentLineString = substr($this->currentLineString, 1); } if ($char === '#') { return 1; } if ($nextTwoChars === '//') { return 2; } if ($nextTwoChars === '/*') { $this->ignoreUntilEndOfMultilineComment(); return 0; } return $this->parseIdentifierUntilStopChar($splitLine, $isRelative); } private function parseIdentifierUntilStopChar(array $splitLine, bool $isRelative = false): int { $this->identifierStream = new IdentifierTokenStream(); if ($isRelative) { $this->identifierStream->setRelative(); } $currentPosition = 0; $currentIdentifierBody = ''; $currentIdentifierCharCount = 0; while (true) { $nextChar = $splitLine[$currentPosition] ?? null; if ($nextChar === null) { if ($currentIdentifierCharCount) { $identifierToken = new IdentifierToken(TokenType::T_IDENTIFIER, $currentIdentifierBody); $this->identifierStream->append($identifierToken); } return $currentPosition; } $nextTwoChars = $nextChar . ($splitLine[$currentPosition + 1] ?? null); if ($currentPosition > 0 && ($nextChar === ' ' || $nextChar === "\t" || $nextChar === '=' || $nextChar === '<' || $nextChar === '>' || $nextChar === '{' || $nextTwoChars === ':=' || $nextChar === '(') ) { if ($currentIdentifierCharCount) { $identifierToken = new IdentifierToken(TokenType::T_IDENTIFIER, $currentIdentifierBody); $this->identifierStream->append($identifierToken); } break; } if ($nextTwoChars === '\\.') { // A quoted dot is part of *this* identifier $currentIdentifierBody .= '.'; $currentPosition += 2; $currentIdentifierCharCount++; } elseif ($nextChar === '.') { if ($currentIdentifierCharCount) { $identifierToken = new IdentifierToken(TokenType::T_IDENTIFIER, $currentIdentifierBody); $this->identifierStream->append($identifierToken); $currentIdentifierCharCount = 0; $currentIdentifierBody = ''; } $currentPosition++; } else { $currentIdentifierBody .= $nextChar; $currentIdentifierCharCount++; $currentPosition++; } } return $currentPosition; } private function parseOperatorFunction(): void { $this->currentLineString = trim(substr($this->currentLineString, 2)); if ($this->currentLineString === '') { return; } $functionName = ''; $functionNameCharCount = 0; $functionChars = mb_str_split($this->currentLineString, 1, 'UTF-8'); while (true) { $nextChar = $functionChars[$functionNameCharCount] ?? null; if ($nextChar === null) { // end of chars return; } if ($nextChar === '(') { if ($functionNameCharCount) { $functionNameToken = new Token(TokenType::T_FUNCTION_NAME, $functionName); $functionNameCharCount++; break; } return; } $functionName .= $nextChar; $functionNameCharCount++; } $functionBodyStartPosition = $functionNameCharCount; $functionBodyPart = ''; $functionBodyCharCount = 0; $functionValueStream = new TokenStream(); $parenthesesLevel = 0; while (true) { $nextChar = $functionChars[$functionBodyStartPosition + $functionBodyCharCount] ?? null; if ($nextChar === null) { return; } if ($nextChar === '(') { // In case of a function call like "appendString(something(somethingelse))" // we shall only stop processing when the last bracket was evaluated. $parenthesesLevel++; } if ($nextChar === ')') { if ($parenthesesLevel > 0) { $parenthesesLevel--; // Continue collecting characters from the (...) argument stream. // Also, ")" will be appended, thus intentionally no "break" occurs. } else { if ($functionBodyCharCount) { $functionValueStream = $this->parseValueForConstants($functionValueStream, $functionBodyPart); $functionBodyCharCount++; } break; } } $functionBodyPart .= $nextChar; $functionBodyCharCount++; } $this->lineStream->append( (new IdentifierFunctionLine()) ->setIdentifierTokenStream($this->identifierStream) ->setFunctionNameToken($functionNameToken) ->setFunctionValueTokenStream($functionValueStream) ); // Check for multiline comment $this->currentLineString = implode('', array_slice($functionChars, $functionBodyStartPosition + $functionBodyCharCount + 1)); if (mb_strlen($this->currentLineString) >= 1 && str_starts_with($this->currentLineString, '/*')) { $this->ignoreUntilEndOfMultilineComment(); } } private function parseValueForConstants(TokenStreamInterface $valueStream, string $value): TokenStreamInterface { if (!str_contains($value, '{$')) { $valueStream->append(new Token(TokenType::T_VALUE, $value)); return $valueStream; } $splitLine = mb_str_split($value, 1, 'UTF-8'); $isInConstant = false; $currentPosition = 0; $currentString = ''; $currentStringLength = 0; while (true) { $char = $splitLine[$currentPosition] ?? null; if ($char === null) { if ($currentStringLength) { $valueToken = new Token(TokenType::T_VALUE, $currentString); $valueStream->append($valueToken); } break; } $nextTwoChars = $char . ($splitLine[$currentPosition + 1] ?? ''); if ($nextTwoChars === '{$') { $isInConstant = true; if ($currentStringLength) { $valueToken = new Token(TokenType::T_VALUE, $currentString); $valueStream->append($valueToken); } $currentString = '{$'; $currentPosition += 2; continue; } if ($isInConstant && $char === '}') { $valueToken = new Token(TokenType::T_CONSTANT, $currentString . '}'); if (!$valueStream instanceof ConstantAwareTokenStream) { $valueStream = (new ConstantAwareTokenStream())->setAll($valueStream->getAll()); } $valueStream->append($valueToken); $currentPosition++; $currentString = ''; $currentStringLength = 0; $isInConstant = false; continue; } $currentPosition++; $currentStringLength++; $currentString .= $char; } return $valueStream; } }