diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 58373a6..72fdad7 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -1,6 +1,6 @@ --- -name: ๐Ÿงช Unit testing +name: ๐Ÿงช on: # yamllint disable-line rule:truthy pull_request: @@ -16,7 +16,7 @@ jobs: runs-on: ${{ matrix.os }} concurrency: cancel-in-progress: true - group: code-coverage-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ matrix.php-version }}-${{ matrix.dependencies }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ matrix.php-version }}-${{ matrix.dependencies }} strategy: fail-fast: true matrix: @@ -37,6 +37,11 @@ jobs: ini-values: error_reporting=E_ALL coverage: xdebug + - name: Configure environment + run: | + export COMPOSER_ROOT_VERSION=$(/usr/bin/jq --null-input --raw-output 'first(inputs["."])' resources/version.json) + echo COMPOSER_ROOT_VERSION=$COMPOSER_ROOT_VERSION >> $GITHUB_ENV + - name: ๐Ÿ› ๏ธ Setup problem matchers run: | echo "::add-matcher::${{ runner.tool_cache }}/php.json" @@ -65,10 +70,13 @@ jobs: runs-on: ${{ matrix.os }} concurrency: cancel-in-progress: true - group: testing-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ matrix.php-version }}-${{ matrix.dependencies }} + group: ${{ matrix.type }}-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ matrix.php-version }}-${{ matrix.dependencies }} strategy: fail-fast: false matrix: + type: + - unit + - acc os: - ubuntu-latest php-version: @@ -76,10 +84,16 @@ jobs: - '8.2' - '8.3' - '8.4' + - '8.5' dependencies: - lowest - locked - highest + include: + - php-version: "8.5" + os: ubuntu-latest + type: "arch" + dependencies: "locked" steps: - name: ๐Ÿ“ฆ Check out the codebase uses: actions/checkout@v5 @@ -90,6 +104,11 @@ jobs: php-version: ${{ matrix.php-version }} ini-values: error_reporting=E_ALL + - name: Configure environment + run: | + export COMPOSER_ROOT_VERSION=$(/usr/bin/jq --null-input --raw-output 'first(inputs["."])' resources/version.json) + echo COMPOSER_ROOT_VERSION=$COMPOSER_ROOT_VERSION >> $GITHUB_ENV + - name: ๐Ÿ› ๏ธ Setup problem matchers run: | echo "::add-matcher::${{ runner.tool_cache }}/php.json" @@ -104,4 +123,4 @@ jobs: dependency-versions: ${{ matrix.dependencies }} - name: ๐Ÿงช Run tests - run: composer test + run: composer test:${{ matrix.type }} diff --git a/composer.json b/composer.json index 44b8e1f..ea0f770 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,8 @@ "phpunit/phpunit": "^10.5", "spiral/code-style": "^2.3.0", "ta-tikoma/phpunit-architecture-test": "^0.8.5", - "vimeo/psalm": "^6.13" + "vimeo/psalm": "^6.13", + "internal/dload": "^1.11" }, "minimum-stability": "dev", "prefer-stable": true, @@ -41,6 +42,9 @@ } }, "scripts": { + "post-update-cmd": [ + "dload get --no-interaction" + ], "cs:diff": "php-cs-fixer fix --dry-run -v --diff", "cs:fix": "php-cs-fixer fix -v", "infect": [ diff --git a/dload.xml b/dload.xml new file mode 100644 index 0000000..94625be --- /dev/null +++ b/dload.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist index bcd4792..6b5deba 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -4,7 +4,7 @@ bootstrap="vendor/autoload.php" colors="true" cacheResultFile="runtime/phpunit/result.cache" - failOnWarning="true" + failOnWarning="false" failOnRisky="true" stderr="true" beStrictAboutOutputDuringTests="true" @@ -17,6 +17,9 @@ tests/Arch + + tests/Acceptance + diff --git a/src/Encoder/ArrayToDocumentConverter.php b/src/Encoder/ArrayToDocumentConverter.php index 72d9b02..4510aa2 100644 --- a/src/Encoder/ArrayToDocumentConverter.php +++ b/src/Encoder/ArrayToDocumentConverter.php @@ -61,7 +61,6 @@ public function convert(array $data): Document * ['github.token' => ['key' => 'val']] * * @param array $data - * @param string $prefix * @return array */ private function flattenData(array $data, string $prefix = ''): array diff --git a/src/Encoder/ValueFactory.php b/src/Encoder/ValueFactory.php index 72324d4..edb93ec 100644 --- a/src/Encoder/ValueFactory.php +++ b/src/Encoder/ValueFactory.php @@ -28,6 +28,7 @@ final class ValueFactory public static function create(mixed $value): Value { return match (true) { + $value instanceof Value => $value, \is_string($value) => self::createString($value), \is_int($value) => self::createInteger($value), \is_float($value) => self::createFloat($value), diff --git a/src/Node/Document.php b/src/Node/Document.php index 8122c96..c696ff6 100644 --- a/src/Node/Document.php +++ b/src/Node/Document.php @@ -206,7 +206,13 @@ private function addTable(array &$result, Table $table): void foreach ($table->name->segments as $segment) { $current[$segment] ??= []; - $current = &$current[$segment]; + + // If the segment is an array-of-tables, navigate into the last element + if (\is_array($current[$segment]) and \array_is_list($current[$segment]) and $current[$segment] !== []) { + $current = &$current[$segment][\count($current[$segment]) - 1]; + } else { + $current = &$current[$segment]; + } } foreach ($table->entries as $entry) { diff --git a/src/Node/Key.php b/src/Node/Key.php index 52bbca2..cd9ce4c 100644 --- a/src/Node/Key.php +++ b/src/Node/Key.php @@ -19,6 +19,16 @@ public function __construct( parent::__construct($position); } + /** + * Quotes a key segment if it contains special characters. + */ + public static function quoteIfNeeded(string $segment): string + { + return self::needsQuoting($segment) + ? '"' . \addcslashes($segment, "\"\\\n\r\t") . '"' + : $segment; + } + public function isSimple(): bool { return \count($this->segments) === 1; @@ -46,15 +56,7 @@ public function getLastSegment(): string public function __toString(): string { - $result = []; - - foreach ($this->segments as $segment) { - $result[] = self::needsQuoting($segment) - ? '"' . \addcslashes($segment, "\"\\\n\r\t") . '"' - : $segment; - } - - return \implode('.', $result); + return \implode('.', \array_map(self::quoteIfNeeded(...), $this->segments)); } /** diff --git a/src/Node/Value/DateTimeValue.php b/src/Node/Value/DateTimeValue.php index e7ba254..a037f77 100644 --- a/src/Node/Value/DateTimeValue.php +++ b/src/Node/Value/DateTimeValue.php @@ -11,13 +11,16 @@ */ final class DateTimeValue extends Value { + public readonly string $raw; + public function __construct( public readonly \DateTimeImmutable $value, public readonly DateTimeType $type, - public readonly string $raw, + string $raw, Position $position, ) { parent::__construct($position); + $this->raw = self::normalize($raw); } public function toPhpValue(): \DateTimeImmutable @@ -29,4 +32,20 @@ public function __toString(): string { return $this->raw; } + + /** + * Normalizes the raw datetime string: + * - Replaces space/t separator with T + * - Injects :00 seconds when omitted + */ + private static function normalize(string $raw): string + { + // Replace space or lowercase 't' separator with 'T' + $raw = \preg_replace('/^(\d{4}-\d{2}-\d{2})[ t]/', '$1T', $raw); + + // Inject :00 seconds when omitted (HH:MM followed by offset or end) + $raw = \preg_replace('/(T)(\d{2}:\d{2})([Z+\-]|$)/', '$1$2:00$3', $raw); + + return $raw; + } } diff --git a/src/Node/Value/InlineTableValue.php b/src/Node/Value/InlineTableValue.php index cac84a3..515c651 100644 --- a/src/Node/Value/InlineTableValue.php +++ b/src/Node/Value/InlineTableValue.php @@ -5,6 +5,7 @@ namespace Internal\Toml\Node\Value; use Internal\Toml\Node\Entry; +use Internal\Toml\Node\Key; use Internal\Toml\Node\MultiLineNode; use Internal\Toml\Node\Position; @@ -60,7 +61,7 @@ public function __toString(): string $parts = []; foreach ($this->pairs as $key => $value) { - $parts[] = $key . ' = ' . (string) $value; + $parts[] = Key::quoteIfNeeded($key) . ' = ' . (string) $value; } return '{' . \implode(', ', $parts) . '}'; diff --git a/src/Node/Value/LocalTimeValue.php b/src/Node/Value/LocalTimeValue.php index be6b6ed..28072b3 100644 --- a/src/Node/Value/LocalTimeValue.php +++ b/src/Node/Value/LocalTimeValue.php @@ -11,11 +11,15 @@ */ final class LocalTimeValue extends Value { + public readonly string $value; + public function __construct( - public readonly string $value, // "07:32:00.999999" + string $value, // "07:32:00.999999" Position $position, ) { parent::__construct($position); + // Normalize: inject :00 seconds when omitted (HH:MM โ†’ HH:MM:00) + $this->value = \preg_match('/^\d{2}:\d{2}$/', $value) ? $value . ':00' : $value; } public function toPhpValue(): string diff --git a/src/Parser/Lexer.php b/src/Parser/Lexer.php index 695dab3..43a5213 100644 --- a/src/Parser/Lexer.php +++ b/src/Parser/Lexer.php @@ -252,12 +252,33 @@ private function scanMultilineString(string $quote, int $start, int $startColumn $this->column = 1; } + $closed = false; while ($this->current() !== '') { - // Check for closing triple quotes + // Check for closing triple quotes (with up to 2 extra quotes as content) if ($this->current() === $quote and $this->peek() === $quote and $this->peek(2) === $quote) { + // Count consecutive quotes + $quoteCount = 0; + $pos = $this->position; + while ($pos < $this->length and $this->input[$pos] === $quote) { + $quoteCount++; + $pos++; + } + + if ($quoteCount >= 6) { + throw new SyntaxException("Too many consecutive quotes at line {$this->line}, column {$this->column}"); + } + + // Extra quotes (1-2) before the closing """ are part of the content + $extraQuotes = $quoteCount - 3; + for ($i = 0; $i < $extraQuotes; $i++) { + $value .= $this->advance(); + } + + // Consume closing """ $this->advance(); $this->advance(); $this->advance(); + $closed = true; break; } @@ -272,7 +293,8 @@ private function scanMultilineString(string $quote, int $start, int $startColumn $this->column = 1; } elseif ($isBasic and $this->current() === '\\') { // Line ending backslash in multiline basic strings - if ($this->peek() === "\n" or $this->peek() === "\r") { + // Backslash can be followed by optional whitespace then newline + if ($this->isLineEndingBackslash()) { $this->advance(); // Skip backslash $this->scanLineEndingBackslash(); continue; @@ -283,6 +305,10 @@ private function scanMultilineString(string $quote, int $start, int $startColumn } } + if (!$closed) { + throw new SyntaxException("Unterminated multiline string starting at line {$startLine}, column {$startColumn}"); + } + return new Token( TokenType::MultilineString, \substr($this->input, $start, $this->position - $start), @@ -296,18 +322,17 @@ private function scanMultilineString(string $quote, int $start, int $startColumn private function scanEscapeSequence(): string { $this->advance(); // Skip backslash - - $char = $this->current(); + $char = $this->advance(); // Consume escape character return match ($char) { - 'b' => "\x08" . ($this->advance() and ''), - 't' => "\t" . ($this->advance() and ''), - 'n' => "\n" . ($this->advance() and ''), - 'f' => "\f" . ($this->advance() and ''), - 'r' => "\r" . ($this->advance() and ''), - 'e' => "\x1B" . ($this->advance() and ''), - '"' => '"' . ($this->advance() and ''), - '\\' => '\\' . ($this->advance() and ''), + 'b' => "\x08", + 't' => "\t", + 'n' => "\n", + 'f' => "\f", + 'r' => "\r", + 'e' => "\x1B", + '"' => '"', + '\\' => '\\', 'x' => $this->scanHexEscape(), 'u' => $this->scanUnicodeEscape(4), 'U' => $this->scanUnicodeEscape(8), @@ -317,8 +342,6 @@ private function scanEscapeSequence(): string private function scanUnicodeEscape(int $length): string { - $this->advance(); // Skip 'u' or 'U' - $hex = ''; for ($i = 0; $i < $length; $i++) { if (!\ctype_xdigit($this->current())) { @@ -333,8 +356,6 @@ private function scanUnicodeEscape(int $length): string private function scanHexEscape(): string { - $this->advance(); // Skip 'x' - $hex = ''; for ($i = 0; $i < 2; $i++) { if (!\ctype_xdigit($this->current())) { @@ -347,8 +368,33 @@ private function scanHexEscape(): string return \mb_chr($codepoint, 'UTF-8'); } + /** + * Checks if the current backslash starts a line-ending escape. + * A line-ending backslash may be followed by optional whitespace before the newline. + */ + private function isLineEndingBackslash(): bool + { + $offset = 1; // Start after the backslash + while (true) { + $ch = $this->peek($offset); + if ($ch === "\n" or $ch === "\r") { + return true; + } + if ($ch === ' ' or $ch === "\t") { + $offset++; + continue; + } + return false; // includes $ch === '' (end of input) + } + } + private function scanLineEndingBackslash(): void { + // Skip optional whitespace before the newline + while ($this->current() === ' ' or $this->current() === "\t") { + $this->advance(); + } + // Skip newline if ($this->current() === "\r" and $this->peek() === "\n") { $this->advance(); @@ -390,7 +436,7 @@ private function scanValueOrKey(): Token return $this->scanDateTimeOrNumber(); } catch (SyntaxException $e) { // If it's an invalid date/time error, re-throw it - if (\str_contains($e->getMessage(), 'Invalid date') or \str_contains($e->getMessage(), 'Invalid time')) { + if (\str_contains($e->getMessage(), "Invalid date '") or \str_contains($e->getMessage(), 'Invalid time')) { throw $e; } @@ -491,7 +537,10 @@ private function scanDateTimeOrNumber(): Token } // Local date without time - if ($this->current() !== 'T' and $this->current() !== ' ' and $this->current() !== 't') { + // Space is a datetime separator only if followed by a digit (start of time) + $isTimeSeparator = ($this->current() === 'T' or $this->current() === 't') + || ($this->current() === ' ' and \ctype_digit($this->peek())); + if (!$isTimeSeparator) { $value = \substr($this->input, $start, $this->position - $start); $datetime = \DateTimeImmutable::createFromFormat('Y-m-d', $value, new \DateTimeZone('UTC')); diff --git a/src/Parser/Parser.php b/src/Parser/Parser.php index c36b7a5..8bef01b 100644 --- a/src/Parser/Parser.php +++ b/src/Parser/Parser.php @@ -69,7 +69,7 @@ public function parse(): Document if ($token->type === TokenType::LeftBracket) { $node = $this->parseTableOrTableArray(); $nodes[] = $node; - } elseif ($token->type === TokenType::BareKey or $token->type === TokenType::QuotedKey or $token->type === TokenType::String) { + } elseif ($token->type->isBareKey() or $token->type === TokenType::String) { $entry = $this->parseKeyValuePair(); $nodes[] = $entry; } else { @@ -94,8 +94,16 @@ private function parseTableOrTableArray(): Table|TableArray $key = $this->parseKey(); // Check for table redefinition (for non-array tables) - if (!$isArray) { - $tableName = $key->__toString(); + $tableName = $key->__toString(); + if ($isArray) { + // New array-of-tables element: reset subtable tracking for this prefix + $prefix = $tableName . '.'; + foreach (\array_keys($this->seenTables) as $seen) { + if (\str_starts_with($seen, $prefix)) { + unset($this->seenTables[$seen]); + } + } + } else { if (isset($this->seenTables[$tableName])) { throw new SyntaxException("Table '{$tableName}' is already defined at line {$key->position->line}, column {$key->position->column}"); } @@ -161,6 +169,12 @@ private function parseKeyValuePair(): Entry $comment = $this->advance()->literal; } + // Key-value pairs must be followed by newline or end of input + if (!$this->isAtEnd() and !$this->check(TokenType::Newline)) { + $t = $this->current(); + throw new SyntaxException("Expected newline after value at line {$t->line}, column {$t->column}"); + } + $this->skipNewlines(); $position = new Position($key->position->line, $key->position->column, $key->position->offset); @@ -179,9 +193,14 @@ private function parseKey(): Key if ($token->type === TokenType::String) { $this->advance(); $segments[] = $token->literal; - } elseif ($token->type === TokenType::BareKey) { - $this->advance(); - $segments[] = $token->literal; + } elseif ($token->type->isBareKey()) { + $raw = $this->parseBareKeySegment(); + // Float tokens like "1.2" contain dots that are key separators + if (\str_contains($raw, '.')) { + \array_push($segments, ...\explode('.', $raw)); + } else { + $segments[] = $raw; + } } else { throw new SyntaxException("Expected key at line {$token->line}, column {$token->column}"); } @@ -198,6 +217,32 @@ private function parseKey(): Key return new Key($segments, $position); } + /** + * Parses a bare key segment, merging adjacent tokens that form a single bare key. + * + * Handles cases like "34-11" where the lexer produces Integer("34") + Integer("-11"). + */ + private function parseBareKeySegment(): string + { + $token = $this->current(); + $combined = $token->value; + $this->advance(); + + // Merge adjacent tokens that are part of the same bare key (no whitespace between them) + while (!$this->isAtEnd()) { + $next = $this->current(); + if ($next->type->isBareKey() && $token->position + \strlen($token->value) === $next->position) { + $combined .= $next->value; + $token = $next; + $this->advance(); + } else { + break; + } + } + + return $combined; + } + private function parseValue(): Value { $token = $this->current(); @@ -280,9 +325,9 @@ private function parseDateTime(): DateTimeValue|LocalTimeValue // Check if it has timezone offset $hasTimezone = \str_contains($token->value, 'Z') - or \str_contains($token->value, 'z') - or \preg_match('/[+-]\d{2}:\d{2}$/', $token->value) === 1 - or \preg_match('/[+-]\d{4}$/', $token->value) === 1; + || \str_contains($token->value, 'z') + || \preg_match('/[+-]\d{2}:\d{2}$/', $token->value) === 1 + || \preg_match('/[+-]\d{4}$/', $token->value) === 1; $type = $hasTimezone ? DateTimeType::OffsetDatetime : DateTimeType::LocalDatetime; $datetime = $token->literal instanceof \DateTimeImmutable ? $token->literal : new \DateTimeImmutable($token->value); @@ -334,7 +379,7 @@ private function parseInlineTable(): InlineTableValue $this->consume(TokenType::Equals); $value = $this->parseValue(); - $pairs[$key->__toString()] = $value; + $pairs[\implode('.', $key->segments)] = $value; $this->skipNewlinesAndCommentsDiscarding(); diff --git a/src/Parser/TokenType.php b/src/Parser/TokenType.php index 7bb10eb..9118731 100644 --- a/src/Parser/TokenType.php +++ b/src/Parser/TokenType.php @@ -30,4 +30,18 @@ enum TokenType: string case QuotedKey = 'QUOTED_KEY'; case Comment = 'COMMENT'; case Whitespace = 'WHITESPACE'; + + /** + * Whether this token type can appear as a bare key segment. + * + * The lexer may tokenize bare keys like "123", "true", "inf" as + * Integer, Boolean, Float, or Datetime. In key context, these are bare keys. + */ + public function isBareKey(): bool + { + return match ($this) { + self::BareKey, self::Integer, self::Float, self::Boolean, self::Datetime => true, + default => false, + }; + } } diff --git a/tests/Acceptance/TomlTestComplianceTest.php b/tests/Acceptance/TomlTestComplianceTest.php new file mode 100644 index 0000000..021b716 --- /dev/null +++ b/tests/Acceptance/TomlTestComplianceTest.php @@ -0,0 +1,472 @@ + */ + public static function provideDecoderTestCases(): \Generator + { + return self::listTestCases(self::DECODER_TOML_VERSION, ['valid/', 'invalid/']); + } + + /** @return \Generator */ + public static function provideEncoderTestCases(): \Generator + { + return self::listTestCases(self::ENCODER_TOML_VERSION, ['valid/'], prefix: 'encoder/'); + } + + #[DataProvider('provideDecoderTestCases')] + public function testDecoderCase(string $testName): void + { + $this->runComplianceCase( + $testName, + self::KNOWN_DECODER_FAILURES, + 'KNOWN_DECODER_FAILURES', + ); + } + + #[DataProvider('provideEncoderTestCases')] + public function testEncoderCase(string $testName): void + { + $this->runComplianceCase( + $testName, + self::KNOWN_ENCODER_FAILURES, + 'KNOWN_ENCODER_FAILURES', + encoder: true, + ); + } + + protected function setUp(): void + { + if (!\file_exists(self::tomlTestBinary())) { + self::markTestSkipped('toml-test binary not found at ' . self::tomlTestBinary()); + } + } + + private static function tomlTestBinary(): string + { + $name = \DIRECTORY_SEPARATOR === '\\' ? 'toml-test.exe' : 'toml-test'; + + return self::TOML_TEST_DIR . '/' . $name; + } + + /** + * Lists test cases from toml-test binary. + * + * @param string $tomlVersion TOML spec version + * @param string ...$prefixes Only include names starting with these prefixes + * @param ?string $prefix Replace "valid/" with this prefix in output names + * @return \Generator + */ + /** + * @param list $prefixes + */ + private static function listTestCases(string $tomlVersion, array $prefixes, ?string $prefix = null): \Generator + { + $binary = self::tomlTestBinary(); + + if (!\file_exists($binary)) { + return; + } + + $process = \proc_open( + [$binary, 'list', '-toml', $tomlVersion], + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + ); + + if (!\is_resource($process)) { + return; + } + + $stdout = \stream_get_contents($pipes[1]); + \fclose($pipes[1]); + \fclose($pipes[2]); + \proc_close($process); + + $seen = []; + foreach (\explode("\n", \trim($stdout)) as $line) { + $name = \preg_replace('/\.(toml|json)$/', '', \trim($line)); + if ($name === '' || isset($seen[$name])) { + continue; + } + + $matched = false; + foreach ($prefixes as $p) { + if (\str_starts_with($name, $p)) { + $matched = true; + break; + } + } + if (!$matched) { + continue; + } + + $seen[$name] = true; + + $testName = $prefix !== null + ? $prefix . \substr($name, \strlen('valid/')) + : $name; + + yield $testName => [$testName]; + } + } + + /** + * Runs a single test case and handles known-failure logic. + * + * @param list $knownFailures + */ + private function runComplianceCase( + string $testName, + array $knownFailures, + string $listName, + bool $encoder = false, + ): void { + $result = $this->runSingleTest($testName, $encoder); + $isKnown = self::matchesAny($testName, $knownFailures); + + if ($result === null) { + if ($isKnown) { + self::fail("Known failure '{$testName}' now passes โ€” remove it from {$listName}."); + } + + $this->addToAssertionCount(1); + + return; + } + + if ($isKnown) { + self::markTestIncomplete($result); + } + + self::fail($result); + } + + /** + * @param list $patterns + */ + private static function matchesAny(string $path, array $patterns): bool + { + foreach ($patterns as $pattern) { + if (\fnmatch($pattern, $path)) { + return true; + } + } + + return false; + } + + /** + * Runs a single toml-test case via -run and -json. + * + * @return string|null Failure details or null if passed. + */ + private function runSingleTest(string $testName, bool $encoder = false): ?string + { + $binary = self::tomlTestBinary(); + + $args = [ + $binary, 'test', + '-toml', $encoder ? self::ENCODER_TOML_VERSION : self::DECODER_TOML_VERSION, + '-color', 'never', + '-json', + '-run', $testName, + '-decoder', PHP_BINARY . ' ' . self::DECODER, + ]; + + if ($encoder) { + $args[] = '-encoder'; + $args[] = PHP_BINARY . ' ' . self::ENCODER; + } + + $process = \proc_open( + $args, + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + ); + + $stdout = \stream_get_contents($pipes[1]); + \fclose($pipes[1]); + \fclose($pipes[2]); + \proc_close($process); + + $json = \json_decode($stdout, true); + + foreach ($json['tests'] ?? [] as $test) { + if (($test['failure'] ?? '') === '') { + continue; + } + + $lines = ["FAIL {$test['path']}"]; + $lines[] = ''; + $lines[] = $test['failure']; + + if (($test['input'] ?? '') !== '') { + $lines[] = ''; + $lines[] = '--- input:'; + $lines[] = $test['input']; + } + + if (($test['output'] ?? '') !== '') { + $lines[] = '--- output:'; + $lines[] = $test['output']; + } + + if (($test['want'] ?? '') !== '') { + $lines[] = '--- want:'; + $lines[] = $test['want']; + } + + return \implode("\n", $lines); + } + + return null; + } +} diff --git a/tests/Acceptance/toml-test-decoder.php b/tests/Acceptance/toml-test-decoder.php new file mode 100644 index 0000000..a7978af --- /dev/null +++ b/tests/Acceptance/toml-test-decoder.php @@ -0,0 +1,211 @@ +#!/usr/bin/env php +getMessage() . "\n"); + exit(1); +} + +function documentToTagged(Document $doc): \stdClass +{ + $result = new \stdClass(); + + foreach ($doc->nodes as $node) { + match (true) { + $node instanceof Entry && $node->isKeyValue() => addTaggedEntry($result, $node->key, $node->value), + $node instanceof Table => addTaggedTable($result, $node), + $node instanceof TableArray => addTaggedTableArray($result, $node), + default => null, + }; + } + + return $result; +} + +function addTaggedEntry(\stdClass $result, Key $key, Value $value): void +{ + $tagged = valueToTagged($value); + + if ($key->isSimple()) { + $result->{$key->getFirstSegment()} = $tagged; + return; + } + + $current = $result; + $segments = $key->segments; + $lastIndex = \count($segments) - 1; + + foreach ($segments as $i => $segment) { + if ($i === $lastIndex) { + $current->$segment = $tagged; + } else { + if (!isset($current->$segment)) { + $current->$segment = new \stdClass(); + } + $current = $current->$segment; + } + } +} + +function addTaggedTable(\stdClass $result, Table $table): void +{ + $current = $result; + + foreach ($table->name->segments as $segment) { + if (!isset($current->$segment)) { + $current->$segment = new \stdClass(); + } + $val = $current->$segment; + if (\is_array($val) && $val !== []) { + // Navigate into last element of array-of-tables + $current = $val[\count($val) - 1]; + } else { + $current = $val; + } + } + + foreach ($table->entries as $entry) { + if ($entry->isKeyValue() && $entry->key !== null && $entry->value !== null) { + addTaggedEntry($current, $entry->key, $entry->value); + } + } +} + +function addTaggedTableArray(\stdClass $result, TableArray $tableArray): void +{ + $current = $result; + $segments = $tableArray->name->segments; + $lastIndex = \count($segments) - 1; + + foreach ($segments as $i => $segment) { + if ($i === $lastIndex) { + if (!isset($current->$segment)) { + $current->$segment = []; + } + $newEntry = new \stdClass(); + $current->$segment[] = $newEntry; + $current = $newEntry; + } else { + if (!isset($current->$segment)) { + $current->$segment = new \stdClass(); + } + $val = $current->$segment; + if (\is_array($val) && $val !== []) { + $current = $val[\count($val) - 1]; + } else { + $current = $val; + } + } + } + + foreach ($tableArray->entries as $entry) { + if ($entry->isKeyValue() && $entry->key !== null && $entry->value !== null) { + addTaggedEntry($current, $entry->key, $entry->value); + } + } +} + +function valueToTagged(Value $value): mixed +{ + return match (true) { + $value instanceof StringValue => ['type' => 'string', 'value' => $value->value], + $value instanceof IntegerValue => ['type' => 'integer', 'value' => (string) $value->value], + $value instanceof FloatValue => floatToTagged($value), + $value instanceof BooleanValue => ['type' => 'bool', 'value' => $value->value ? 'true' : 'false'], + $value instanceof DateTimeValue => datetimeToTagged($value), + $value instanceof LocalTimeValue => ['type' => 'time-local', 'value' => $value->value], + $value instanceof ArrayValue => \array_map(static fn(Value $el): mixed => valueToTagged($el), $value->elements), + $value instanceof InlineTableValue => inlineTableToTagged($value), + default => throw new \RuntimeException('Unknown value type: ' . $value::class), + }; +} + +function floatToTagged(FloatValue $value): array +{ + $val = $value->value; + + if (\is_nan($val)) { + $str = 'nan'; + } elseif (\is_infinite($val) && $val > 0) { + $str = 'inf'; + } elseif (\is_infinite($val)) { + $str = '-inf'; + } else { + // Use full IEEE 754 precision (17 significant digits) + $str = \sprintf('%.17G', $val); + // Ensure float representation always has a decimal point + if (!\str_contains($str, '.') && !\str_contains($str, 'E') && !\str_contains($str, 'e')) { + $str .= '.0'; + } + } + + return ['type' => 'float', 'value' => $str]; +} + +function datetimeToTagged(DateTimeValue $value): array +{ + $type = match ($value->type) { + DateTimeType::OffsetDatetime => 'datetime', + DateTimeType::LocalDatetime => 'datetime-local', + DateTimeType::LocalDate => 'date-local', + }; + + return ['type' => $type, 'value' => $value->raw]; +} + +function inlineTableToTagged(InlineTableValue $value): \stdClass +{ + $result = new \stdClass(); + + foreach ($value->pairs as $key => $val) { + // Handle dotted keys within inline tables + if (\str_contains((string) $key, '.')) { + $segments = \explode('.', (string) $key); + $current = $result; + $lastIndex = \count($segments) - 1; + + foreach ($segments as $i => $segment) { + if ($i === $lastIndex) { + $current->$segment = valueToTagged($val); + } else { + if (!isset($current->$segment)) { + $current->$segment = new \stdClass(); + } + $current = $current->$segment; + } + } + } else { + $result->{(string) $key} = valueToTagged($val); + } + } + + return $result; +} diff --git a/tests/Acceptance/toml-test-encoder.php b/tests/Acceptance/toml-test-encoder.php new file mode 100644 index 0000000..eec8f79 --- /dev/null +++ b/tests/Acceptance/toml-test-encoder.php @@ -0,0 +1,80 @@ +#!/usr/bin/env php +getMessage() . "\n"); + exit(1); +} + +function taggedToPhp(mixed $value): mixed +{ + if (!\is_array($value)) { + throw new \RuntimeException('Unexpected non-array value'); + } + + // Tagged value: {"type": "...", "value": "..."} + if (isset($value['type']) && isset($value['value']) && \count($value) === 2) { + return convertTaggedValue($value['type'], $value['value']); + } + + // Array of tagged values (TOML array) + if (\array_is_list($value)) { + return \array_map(static fn(mixed $item): mixed => taggedToPhp($item), $value); + } + + // Table: recursively convert + $result = []; + foreach ($value as $key => $val) { + $result[$key] = taggedToPhp($val); + } + + return $result; +} + +function convertTaggedValue(string $type, string $value): mixed +{ + return match ($type) { + 'string' => $value, + 'integer' => (int) $value, + 'float' => convertFloat($value), + 'bool' => $value === 'true', + 'datetime' => new DateTimeValue( + new \DateTimeImmutable($value), DateTimeType::OffsetDatetime, $value, new Position(0, 0, 0), + ), + 'datetime-local' => new DateTimeValue( + new \DateTimeImmutable($value), DateTimeType::LocalDatetime, $value, new Position(0, 0, 0), + ), + 'date-local' => new DateTimeValue( + new \DateTimeImmutable($value), DateTimeType::LocalDate, $value, new Position(0, 0, 0), + ), + 'time-local' => new LocalTimeValue($value, new Position(0, 0, 0)), + default => throw new \RuntimeException("Unknown type: {$type}"), + }; +} + +function convertFloat(string $value): float +{ + return match ($value) { + 'inf', '+inf' => INF, + '-inf' => -INF, + 'nan', '+nan', '-nan' => NAN, + default => (float) $value, + }; +} diff --git a/tests/Unit/Encoder/ValueFactoryTest.php b/tests/Unit/Encoder/ValueFactoryTest.php index 4c20003..d5ec3dd 100644 --- a/tests/Unit/Encoder/ValueFactoryTest.php +++ b/tests/Unit/Encoder/ValueFactoryTest.php @@ -521,9 +521,7 @@ public function jsonSerialize(): string }; $outerJsonSerializable = new class($innerJsonSerializable) implements \JsonSerializable { - public function __construct(private readonly \JsonSerializable $inner) - { - } + public function __construct(private readonly \JsonSerializable $inner) {} public function jsonSerialize(): array { diff --git a/tests/Unit/TomlEncodeTest.php b/tests/Unit/TomlEncodeTest.php index 06f5fa6..eef8b2b 100644 --- a/tests/Unit/TomlEncodeTest.php +++ b/tests/Unit/TomlEncodeTest.php @@ -197,6 +197,65 @@ public function testEncodeDateTimeWithOffsetProducesOffsetFormat(): void self::assertStringContainsString('2024-01-15T10:30:00+03:00', $toml); } + public function testEncodeDateLocalProducesDateOnly(): void + { + $data = [ + 'bestdayever' => new \Internal\Toml\Node\Value\DateTimeValue( + new \DateTimeImmutable('1987-07-05'), + \Internal\Toml\Node\Value\DateTimeType::LocalDate, + '1987-07-05', + new \Internal\Toml\Node\Position(0, 0, 0), + ), + ]; + + $toml = (string) Toml::encode($data); + + self::assertStringContainsString('bestdayever = 1987-07-05', $toml); + self::assertStringNotContainsString('T', $toml); + } + + public function testEncodeDatetimeLocalProducesNoTimezone(): void + { + $data = [ + 'dt' => new \Internal\Toml\Node\Value\DateTimeValue( + new \DateTimeImmutable('1979-05-27T07:32:00'), + \Internal\Toml\Node\Value\DateTimeType::LocalDatetime, + '1979-05-27T07:32:00', + new \Internal\Toml\Node\Position(0, 0, 0), + ), + ]; + + $toml = (string) Toml::encode($data); + + self::assertStringContainsString('dt = 1979-05-27T07:32:00', $toml); + self::assertStringNotContainsString('Z', $toml); + self::assertStringNotContainsString('+', $toml); + } + + public function testEncodeLocalTimeProducesTimeOnly(): void + { + $data = [ + 't' => new \Internal\Toml\Node\Value\LocalTimeValue( + '07:32:00', + new \Internal\Toml\Node\Position(0, 0, 0), + ), + ]; + + $toml = (string) Toml::encode($data); + + self::assertStringContainsString('t = 07:32:00', $toml); + } + + public function testEncodeDateLocalRoundTrip(): void + { + $toml = "d = 1987-07-05"; + $parsed = Toml::parseToArray($toml); + $encoded = (string) Toml::encode($parsed); + $reparsed = Toml::parseToArray($encoded); + + self::assertEquals($parsed, $reparsed); + } + // ============================================ // JsonSerializable Support Tests // ============================================ @@ -378,7 +437,7 @@ public function testEncodeFixtureFileRoundTrip(string $filename): void self::assertEquals( $originalArray, $reEncodedArray, - "Round-trip encoding/decoding changed the data structure for {$filename}" + "Round-trip encoding/decoding changed the data structure for {$filename}", ); } } diff --git a/tests/Unit/TomlParseTest.php b/tests/Unit/TomlParseTest.php index 766ff2a..6081922 100644 --- a/tests/Unit/TomlParseTest.php +++ b/tests/Unit/TomlParseTest.php @@ -415,6 +415,36 @@ public function testParseOffsetDateTimeCreatesDateTimeValueWithCorrectType(): vo self::assertSame('1979-05-27T07:32:00Z', $value->raw); } + public function testParseOffsetDateTimeWithNumericOffset(): void + { + // Arrange + $toml = 'dt = 1979-05-27T07:32:00+05:30'; + + // Act + $result = Toml::parse($toml); + + // Assert + $entry = $result->nodes[0]; + $value = $entry->value; + self::assertInstanceOf(DateTimeValue::class, $value); + self::assertSame(DateTimeType::OffsetDatetime, $value->type); + } + + public function testParseOffsetDateTimeWithNegativeOffset(): void + { + // Arrange + $toml = 'dt = 1987-07-05T17:45:56-05:00'; + + // Act + $result = Toml::parse($toml); + + // Assert + $entry = $result->nodes[0]; + $value = $entry->value; + self::assertInstanceOf(DateTimeValue::class, $value); + self::assertSame(DateTimeType::OffsetDatetime, $value->type); + } + public function testParseLocalDateTimeCreatesDateTimeValueWithCorrectType(): void { // Arrange diff --git a/tests/Unit/TomlParseToArrayTest.php b/tests/Unit/TomlParseToArrayTest.php index 691a1b7..8b4c55d 100644 --- a/tests/Unit/TomlParseToArrayTest.php +++ b/tests/Unit/TomlParseToArrayTest.php @@ -29,6 +29,24 @@ public static function provideValidKeys(): \Generator "parent.child1 = \"value1\"\nparent.child2 = \"value2\"", ['parent' => ['child1' => 'value1', 'child2' => 'value2']], ]; + + // Numeric and special bare keys + yield 'numeric bare key' => ['123 = "num"', ['123' => 'num']]; + yield 'numeric bare key with leading zeros' => ['000111 = "leading"', ['000111' => 'leading']]; + yield 'zero bare key' => ["0 = 0", ['0' => 0]]; + yield 'bare key with dash and digits' => ['34-11 = 23', ['34-11' => 23]]; + yield 'bare key looks like float' => ['10e3 = "not a float"', ['10e3' => 'not a float']]; + yield 'bare key true' => ['true = 1', ['true' => 1]]; + yield 'bare key false' => ['false = 0', ['false' => 0]]; + yield 'bare key inf' => ['inf = 1', ['inf' => 1]]; + yield 'bare key nan' => ['nan = 1', ['nan' => 1]]; + yield 'bare key mixed alpha-numeric' => ['one1two2 = "mixed"', ['one1two2' => 'mixed']]; + yield 'bare key like date' => ['2001-02-03 = 1', ['2001-02-03' => 1]]; + yield 'dotted numeric keys' => ['1.2 = true', ['1' => ['2' => true]]]; + yield 'dotted numeric keys with leading zeros' => ['01.23 = true', ['01' => ['23' => true]]]; + yield 'table with numeric name' => ["[123]\nkey = \"value\"", ['123' => ['key' => 'value']]]; + yield 'table with date-like name' => ["[2002-01-02]\nk = 10", ['2002-01-02' => ['k' => 10]]]; + yield 'dotted date-like keys' => ['a.2001-02-08 = 7', ['a' => ['2001-02-08' => 7]]]; } public static function provideBasicStrings(): \Generator @@ -112,7 +130,7 @@ public static function provideDateTimes(): \Generator ]; yield 'local time without seconds' => [ 'dt = 07:32', - ['dt' => '07:32'], + ['dt' => '07:32:00'], ]; yield 'offset date-time without seconds' => [ 'dt = 1979-05-27T07:32Z', @@ -265,6 +283,31 @@ public function testDecodeIgnoresComments(): void self::assertSame(['key' => 'value'], $result); } + public function testCommentAfterTableHeaderNoSpace(): void + { + $toml = "[[aot]]# Comment\nk = 1\n[[aot]]# Comment\nk = 2"; + $result = Toml::parseToArray($toml); + + self::assertSame(['aot' => [['k' => 1], ['k' => 2]]], $result); + } + + public function testCommentAfterValueNoSpace(): void + { + $toml = "k = 99# Comment"; + $result = Toml::parseToArray($toml); + + self::assertSame(['k' => 99], $result); + } + + public function testLocalDateWithComment(): void + { + $toml = "d = 1979-05-27 # Comment"; + $result = Toml::parseToArray($toml); + + self::assertInstanceOf(\DateTimeImmutable::class, $result['d']); + self::assertSame('1979-05-27', $result['d']->format('Y-m-d')); + } + // ============================================ // String Types Tests // ============================================ @@ -323,6 +366,78 @@ public function testDecodeMultilineLiteralString(): void self::assertSame(['str' => "line one\\n\nline two\\t\nline three"], $result); } + public function testMultilineBasicStringWhitespaceLineEndingEscape(): void + { + // Arrange โ€” backslash followed by spaces then newline trims all whitespace + $toml = "str = \"\"\"\\\n hello\"\"\""; + + // Act + $result = Toml::parseToArray($toml); + + // Assert + self::assertSame(['str' => 'hello'], $result); + } + + public function testMultilineBasicStringBackslashSpacesNewline(): void + { + // Arrange โ€” backslash + trailing spaces + newline = line continuation + $toml = "str = \"\"\"abc\\ \nhello\"\"\""; + + // Act + $result = Toml::parseToArray($toml); + + // Assert + self::assertSame(['str' => 'abchello'], $result); + } + + public function testMultilineBasicStringFourQuotes(): void + { + // Arrange โ€” four consecutive double quotes: """" โ†’ """ + one extra " + $toml = 'str = """"one quote""""'; + + // Act + $result = Toml::parseToArray($toml); + + // Assert + self::assertSame(['str' => '"one quote"'], $result); + } + + public function testMultilineBasicStringFiveQuotes(): void + { + // Arrange โ€” five consecutive double quotes: """"" โ†’ """ + two extra "" + $toml = 'str = """""two quotes"""""'; + + // Act + $result = Toml::parseToArray($toml); + + // Assert + self::assertSame(['str' => '""two quotes""'], $result); + } + + public function testMultilineLiteralStringFourQuotes(): void + { + // Arrange + $toml = "str = ''''one quote''''"; + + // Act + $result = Toml::parseToArray($toml); + + // Assert + self::assertSame(['str' => "'one quote'"], $result); + } + + public function testMultilineLiteralStringEmpty(): void + { + // Arrange โ€” empty multiline basic string + $toml = 'str = """"""'; + + // Act + $result = Toml::parseToArray($toml); + + // Assert + self::assertSame(['str' => ''], $result); + } + // ============================================ // Integer Tests // ============================================ @@ -522,6 +637,31 @@ public function testDecodeNestedArrayOfTables(): void ], $result); } + public function testDecodeArrayOfTablesWithSubtables(): void + { + // Arrange โ€” subtable should be scoped to each array-of-tables element + $toml = <<<'TOML' +[[arr]] +[arr.subtab] +val = 1 + +[[arr]] +[arr.subtab] +val = 2 +TOML; + + // Act + $result = Toml::parseToArray($toml); + + // Assert + self::assertSame([ + 'arr' => [ + ['subtab' => ['val' => 1]], + ['subtab' => ['val' => 2]], + ], + ], $result); + } + // ============================================ // Complex Examples Tests // ============================================