From e1984e81e27417b2568c9d442f07c2d5b03e5dfe Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 30 Mar 2026 22:56:00 +0400 Subject: [PATCH 1/6] test: Add acceptance tests --- .github/workflows/testing.yml | 27 +- composer.json | 6 +- dload.xml | 14 + phpunit.xml.dist | 3 + tests/Acceptance/TomlTestComplianceTest.php | 501 ++++++++++++++++++++ tests/Acceptance/toml-test-decoder.php | 196 ++++++++ tests/Acceptance/toml-test-encoder.php | 70 +++ 7 files changed, 812 insertions(+), 5 deletions(-) create mode 100644 dload.xml create mode 100644 tests/Acceptance/TomlTestComplianceTest.php create mode 100644 tests/Acceptance/toml-test-decoder.php create mode 100644 tests/Acceptance/toml-test-encoder.php 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..9e53fd2 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.10" }, "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..309201b 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -17,6 +17,9 @@ tests/Arch + + tests/Acceptance + diff --git a/tests/Acceptance/TomlTestComplianceTest.php b/tests/Acceptance/TomlTestComplianceTest.php new file mode 100644 index 0000000..5ec3005 --- /dev/null +++ b/tests/Acceptance/TomlTestComplianceTest.php @@ -0,0 +1,501 @@ + + */ + public static function provideDecoderTestCases(): \Generator + { + $binary = \str_replace('/', DIRECTORY_SEPARATOR, self::TOML_TEST_BINARY); + + if (!\file_exists($binary)) { + return; + } + + $process = \proc_open( + [$binary, 'list', '-toml', self::DECODER_TOML_VERSION], + [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 === '' || \str_starts_with($name, 'encoder/') || isset($seen[$name])) { + continue; + } + $seen[$name] = true; + + yield $name => [$name]; + } + } + + #[DataProvider('provideDecoderTestCases')] + public function testDecoderCase(string $testName): void + { + $result = $this->runSingleTest($testName); + + if ($result === null) { + if (self::isKnownFailure($testName)) { + self::fail("Known failure '{$testName}' now passes โ€” remove it from KNOWN_FAILURES."); + } + + $this->addToAssertionCount(1); + + return; + } + + // Test failed + if (self::isKnownFailure($testName)) { + self::markTestIncomplete($result); + } + + self::fail($result); + } + + public function testEncoderCompliance(): void + { + $result = $this->runTomlTestSuite(self::ENCODER_TOML_VERSION); + + self::assertSame(0, $result['exit_code'], "Encoder compliance failed:\n" . $result['output']); + } + + protected function setUp(): void + { + if (!\file_exists(self::TOML_TEST_BINARY)) { + self::markTestSkipped('toml-test binary not found at ' . self::TOML_TEST_BINARY); + } + } + + /** + * Runs a single toml-test case via -run and -json. + * + * @return string|null Failure details or null if passed. + */ + private function runSingleTest(string $testName): ?string + { + $binary = \str_replace('/', DIRECTORY_SEPARATOR, self::TOML_TEST_BINARY); + + $process = \proc_open( + [ + $binary, 'test', + '-toml', self::DECODER_TOML_VERSION, + '-color', 'never', + '-json', + '-run', $testName, + '-decoder', PHP_BINARY . ' ' . self::DECODER, + ], + [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; + } + + // Build readable output with full context + $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; + } + + private static function isKnownFailure(string $path): bool + { + foreach (self::KNOWN_FAILURES as $pattern) { + if (\fnmatch($pattern, $path)) { + return true; + } + } + + return false; + } + + /** + * Runs the full toml-test suite with encoder, skipping known failures. + * + * @return array{exit_code: int, output: string} + */ + private function runTomlTestSuite(string $tomlVersion): array + { + $binary = \str_replace('/', DIRECTORY_SEPARATOR, self::TOML_TEST_BINARY); + + $args = [ + $binary, 'test', + '-toml', $tomlVersion, + '-color', 'never', + '-decoder', PHP_BINARY . ' ' . self::DECODER, + '-encoder', PHP_BINARY . ' ' . self::ENCODER, + ]; + + foreach (self::KNOWN_FAILURES as $skip) { + $args[] = '-skip'; + $args[] = $skip; + } + + $process = \proc_open( + $args, + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + ); + + $stdout = \stream_get_contents($pipes[1]); + $stderr = \stream_get_contents($pipes[2]); + \fclose($pipes[1]); + \fclose($pipes[2]); + $exitCode = \proc_close($process); + + return ['exit_code' => $exitCode, 'output' => $stdout . $stderr]; + } +} diff --git a/tests/Acceptance/toml-test-decoder.php b/tests/Acceptance/toml-test-decoder.php new file mode 100644 index 0000000..d34c5ec --- /dev/null +++ b/tests/Acceptance/toml-test-decoder.php @@ -0,0 +1,196 @@ +#!/usr/bin/env php +getMessage() . "\n"); + exit(1); +} + +function documentToTagged(Document $doc): array +{ + $result = []; + + 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(array &$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 { + $current[$segment] ??= []; + $current = &$current[$segment]; + } + } +} + +function addTaggedTable(array &$result, Table $table): void +{ + $current = &$result; + + foreach ($table->name->segments as $segment) { + $current[$segment] ??= []; + $current = &$current[$segment]; + } + + foreach ($table->entries as $entry) { + if ($entry->isKeyValue() && $entry->key !== null && $entry->value !== null) { + addTaggedEntry($current, $entry->key, $entry->value); + } + } +} + +function addTaggedTableArray(array &$result, TableArray $tableArray): void +{ + $current = &$result; + $segments = $tableArray->name->segments; + $lastIndex = \count($segments) - 1; + + foreach ($segments as $i => $segment) { + if ($i === $lastIndex) { + $current[$segment] ??= []; + $current[$segment][] = []; + $current = &$current[$segment][\count($current[$segment]) - 1]; + } else { + $current[$segment] ??= []; + if (\is_array($current[$segment]) && \array_is_list($current[$segment]) && $current[$segment] !== []) { + $current = &$current[$segment][\count($current[$segment]) - 1]; + } else { + $current = &$current[$segment]; + } + } + } + + 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(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 { + // Ensure float representation always has a decimal point + $str = (string) $val; + 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', + }; + + // Normalize raw: replace space separator with T + $raw = $value->raw; + $raw = \preg_replace('/^(\d{4}-\d{2}-\d{2})[ t]/', '$1T', $raw); + + return ['type' => $type, 'value' => $raw]; +} + +function inlineTableToTagged(InlineTableValue $value): array +{ + $result = []; + + foreach ($value->pairs as $key => $val) { + // Handle dotted keys within inline tables + if (\str_contains($key, '.')) { + $segments = \explode('.', $key); + $current = &$result; + $lastIndex = \count($segments) - 1; + + foreach ($segments as $i => $segment) { + if ($i === $lastIndex) { + $current[$segment] = valueToTagged($val); + } else { + $current[$segment] ??= []; + $current = &$current[$segment]; + } + } + } else { + $result[$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..d1c1e6a --- /dev/null +++ b/tests/Acceptance/toml-test-encoder.php @@ -0,0 +1,70 @@ +#!/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(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 \DateTimeImmutable($value), + 'datetime-local' => new \DateTimeImmutable($value), + 'date-local' => new \DateTimeImmutable($value), + 'time-local' => $value, + 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, + }; +} From cb4236a318e2023308c80508102872d6c8756357 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 31 Mar 2026 00:09:28 +0400 Subject: [PATCH 2/6] fix: Improve bare keys parsing --- src/Parser/Lexer.php | 2 +- src/Parser/Parser.php | 46 ++++++++++-- src/Parser/TokenType.php | 14 ++++ tests/Acceptance/TomlTestComplianceTest.php | 78 +-------------------- tests/Acceptance/toml-test-decoder.php | 78 +++++++++++++-------- tests/Unit/TomlParseToArrayTest.php | 18 +++++ 6 files changed, 126 insertions(+), 110 deletions(-) diff --git a/src/Parser/Lexer.php b/src/Parser/Lexer.php index 695dab3..345228b 100644 --- a/src/Parser/Lexer.php +++ b/src/Parser/Lexer.php @@ -390,7 +390,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; } diff --git a/src/Parser/Parser.php b/src/Parser/Parser.php index c36b7a5..0751dd0 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 { @@ -161,6 +161,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 +185,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 +209,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(); @@ -421,4 +458,5 @@ private function isAtEnd(): bool { return $this->position >= \count($this->tokens) or $this->current()->type === TokenType::Eof; } + } 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 index 5ec3005..aa0bdc9 100644 --- a/tests/Acceptance/TomlTestComplianceTest.php +++ b/tests/Acceptance/TomlTestComplianceTest.php @@ -32,71 +32,27 @@ final class TomlTestComplianceTest extends TestCase private const KNOWN_FAILURES = [ // --- valid tests: decoder produces wrong result --- - // Numeric/special bare keys: lexer treats digits, nan, inf as value tokens - 'valid/key/alphanum', - 'valid/key/escapes', - 'valid/key/like-date', - 'valid/key/numeric-01', - 'valid/key/numeric-02', - 'valid/key/numeric-03', - 'valid/key/numeric-04', - 'valid/key/numeric-05', - 'valid/key/numeric-06', - 'valid/key/numeric-07', - 'valid/key/numeric-08', - 'valid/key/special-word', - 'valid/key/start', - 'valid/key/zero', - 'valid/float/inf-and-nan', - - // Empty document: PHP empty array JSON-encodes as [] not {} - 'valid/empty-crlf', - 'valid/empty-lf', - 'valid/empty-nothing', - 'valid/empty-space', - 'valid/empty-tab', - 'valid/comment/noeol', - 'valid/comment/nonascii', + // Null byte in key: PHP stdClass cannot have \0 property + 'valid/key/quoted-unicode', // Datetime format normalization 'valid/datetime/datetime', - 'valid/datetime/leap-year', 'valid/datetime/milliseconds', 'valid/datetime/no-seconds', 'valid/datetime/timezone', // Comment handling edge cases - 'valid/comment/after-literal-no-ws', 'valid/comment/everywhere', 'valid/comment/tricky', - // Table: implicit creation, super-tables, dotted key interactions - 'valid/table/array-empty', + // Table: array-table-array, array-subtables interactions 'valid/table/array-table-array', - 'valid/table/empty', - 'valid/table/keyword', - 'valid/table/keyword-with-values', - 'valid/table/names', - 'valid/table/names-with-values', - 'valid/table/no-eol', - 'valid/table/sub-empty', - 'valid/table/whitespace', - 'valid/table/without-super', 'valid/array/array-subtables', - 'valid/array/nested-inline-table', - 'valid/array/open-parent-table', - - // Inline table edge cases - 'valid/inline-table/empty', - 'valid/inline-table/nest', - 'valid/inline-table/newline', - 'valid/inline-table/newline-comment', // String edge cases 'valid/string/ends-in-whitespace-escape', 'valid/string/multiline', 'valid/string/multiline-empty', - 'valid/string/multiline-escaped-crlf', 'valid/string/multiline-quotes', 'valid/string/raw-multiline', @@ -105,22 +61,13 @@ final class TomlTestComplianceTest extends TestCase 'valid/float/max-int', // Spec examples - 'valid/spec-1.1.0/common-11', 'valid/spec-1.1.0/common-16', 'valid/spec-1.1.0/common-19', 'valid/spec-1.1.0/common-24', 'valid/spec-1.1.0/common-27', 'valid/spec-1.1.0/common-29', - 'valid/spec-1.1.0/common-3', 'valid/spec-1.1.0/common-31', 'valid/spec-1.1.0/common-34', - 'valid/spec-1.1.0/common-37', - 'valid/spec-1.1.0/common-40', - 'valid/spec-1.1.0/common-41', - 'valid/spec-1.1.0/common-42', - 'valid/spec-1.1.0/common-43', - 'valid/spec-1.1.0/common-51', - 'valid/spec-1.1.0/common-52', 'valid/spec-example-1', 'valid/spec-example-1-compact', @@ -205,23 +152,14 @@ final class TomlTestComplianceTest extends TestCase // Table/key redefinition rules 'invalid/table/append-with-dotted-keys-01', 'invalid/table/append-with-dotted-keys-02', - 'invalid/table/append-with-dotted-keys-03', 'invalid/table/append-with-dotted-keys-05', - 'invalid/table/append-with-dotted-keys-06', - 'invalid/table/append-with-dotted-keys-07', - 'invalid/table/duplicate-key-02', - 'invalid/table/duplicate-key-03', 'invalid/table/duplicate-key-04', 'invalid/table/duplicate-key-05', - 'invalid/table/duplicate-key-06', 'invalid/table/duplicate-key-07', 'invalid/table/duplicate-key-08', - 'invalid/table/duplicate-key-10', 'invalid/table/llbrace', 'invalid/table/overwrite-array-in-parent', 'invalid/table/overwrite-bool-with-array', - 'invalid/table/overwrite-with-deep-table', - 'invalid/table/redefine-01', 'invalid/table/redefine-02', 'invalid/table/redefine-03', 'invalid/table/rrbrace', @@ -230,17 +168,12 @@ final class TomlTestComplianceTest extends TestCase 'invalid/inline-table/duplicate-key-01', 'invalid/inline-table/duplicate-key-02', 'invalid/inline-table/duplicate-key-03', - 'invalid/inline-table/duplicate-key-04', 'invalid/inline-table/overwrite-01', 'invalid/inline-table/overwrite-02', 'invalid/inline-table/overwrite-03', - 'invalid/inline-table/overwrite-04', 'invalid/inline-table/overwrite-05', - 'invalid/inline-table/overwrite-06', - 'invalid/inline-table/overwrite-07', 'invalid/inline-table/overwrite-08', 'invalid/inline-table/overwrite-09', - 'invalid/inline-table/overwrite-10', // Datetime validation 'invalid/datetime/day-zero', @@ -295,13 +228,8 @@ final class TomlTestComplianceTest extends TestCase // Key validation 'invalid/key/after-array', 'invalid/key/after-table', - 'invalid/key/after-value', - 'invalid/key/dotted-redefine-table-01', - 'invalid/key/dotted-redefine-table-02', - 'invalid/key/no-eol-01', // Array of tables - 'invalid/array/extend-defined-aot', 'invalid/array/extending-table', 'invalid/array/tables-01', 'invalid/array/tables-02', diff --git a/tests/Acceptance/toml-test-decoder.php b/tests/Acceptance/toml-test-decoder.php index d34c5ec..553d4ce 100644 --- a/tests/Acceptance/toml-test-decoder.php +++ b/tests/Acceptance/toml-test-decoder.php @@ -34,9 +34,9 @@ exit(1); } -function documentToTagged(Document $doc): array +function documentToTagged(Document $doc): \stdClass { - $result = []; + $result = new \stdClass(); foreach ($doc->nodes as $node) { match (true) { @@ -50,36 +50,46 @@ function documentToTagged(Document $doc): array return $result; } -function addTaggedEntry(array &$result, Key $key, Value $value): void +function addTaggedEntry(\stdClass $result, Key $key, Value $value): void { $tagged = valueToTagged($value); if ($key->isSimple()) { - $result[$key->getFirstSegment()] = $tagged; + $result->{$key->getFirstSegment()} = $tagged; return; } - $current = &$result; + $current = $result; $segments = $key->segments; $lastIndex = \count($segments) - 1; foreach ($segments as $i => $segment) { if ($i === $lastIndex) { - $current[$segment] = $tagged; + $current->$segment = $tagged; } else { - $current[$segment] ??= []; - $current = &$current[$segment]; + if (!isset($current->$segment)) { + $current->$segment = new \stdClass(); + } + $current = $current->$segment; } } } -function addTaggedTable(array &$result, Table $table): void +function addTaggedTable(\stdClass $result, Table $table): void { - $current = &$result; + $current = $result; foreach ($table->name->segments as $segment) { - $current[$segment] ??= []; - $current = &$current[$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) { @@ -89,23 +99,29 @@ function addTaggedTable(array &$result, Table $table): void } } -function addTaggedTableArray(array &$result, TableArray $tableArray): void +function addTaggedTableArray(\stdClass $result, TableArray $tableArray): void { - $current = &$result; + $current = $result; $segments = $tableArray->name->segments; $lastIndex = \count($segments) - 1; foreach ($segments as $i => $segment) { if ($i === $lastIndex) { - $current[$segment] ??= []; - $current[$segment][] = []; - $current = &$current[$segment][\count($current[$segment]) - 1]; + if (!isset($current->$segment)) { + $current->$segment = []; + } + $newEntry = new \stdClass(); + $current->$segment[] = $newEntry; + $current = $newEntry; } else { - $current[$segment] ??= []; - if (\is_array($current[$segment]) && \array_is_list($current[$segment]) && $current[$segment] !== []) { - $current = &$current[$segment][\count($current[$segment]) - 1]; + if (!isset($current->$segment)) { + $current->$segment = new \stdClass(); + } + $val = $current->$segment; + if (\is_array($val) && $val !== []) { + $current = $val[\count($val) - 1]; } else { - $current = &$current[$segment]; + $current = $val; } } } @@ -168,27 +184,29 @@ function datetimeToTagged(DateTimeValue $value): array return ['type' => $type, 'value' => $raw]; } -function inlineTableToTagged(InlineTableValue $value): array +function inlineTableToTagged(InlineTableValue $value): \stdClass { - $result = []; + $result = new \stdClass(); foreach ($value->pairs as $key => $val) { // Handle dotted keys within inline tables - if (\str_contains($key, '.')) { - $segments = \explode('.', $key); - $current = &$result; + 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); + $current->$segment = valueToTagged($val); } else { - $current[$segment] ??= []; - $current = &$current[$segment]; + if (!isset($current->$segment)) { + $current->$segment = new \stdClass(); + } + $current = $current->$segment; } } } else { - $result[$key] = valueToTagged($val); + $result->{(string) $key} = valueToTagged($val); } } diff --git a/tests/Unit/TomlParseToArrayTest.php b/tests/Unit/TomlParseToArrayTest.php index 691a1b7..1c48a7c 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 From 2b45b0be8b04040bbfebc91e178a991c8bab253a Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 31 Mar 2026 01:21:08 +0400 Subject: [PATCH 3/6] fix: Better spec 1.1 support --- composer.json | 2 +- src/Encoder/ArrayToDocumentConverter.php | 1 - src/Node/Document.php | 8 +- src/Node/Key.php | 10 ++ src/Node/Value/DateTimeValue.php | 21 +++- src/Node/Value/InlineTableValue.php | 3 +- src/Node/Value/LocalTimeValue.php | 6 +- src/Parser/Lexer.php | 60 +++++++++- src/Parser/Parser.php | 21 ++-- tests/Acceptance/TomlTestComplianceTest.php | 111 +++++++----------- tests/Acceptance/toml-test-decoder.php | 11 +- tests/Acceptance/toml-test-encoder.php | 2 +- tests/Unit/Encoder/ValueFactoryTest.php | 4 +- tests/Unit/TomlEncodeTest.php | 2 +- tests/Unit/TomlParseTest.php | 30 +++++ tests/Unit/TomlParseToArrayTest.php | 124 +++++++++++++++++++- 16 files changed, 317 insertions(+), 99 deletions(-) diff --git a/composer.json b/composer.json index 9e53fd2..ea0f770 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,7 @@ "spiral/code-style": "^2.3.0", "ta-tikoma/phpunit-architecture-test": "^0.8.5", "vimeo/psalm": "^6.13", - "internal/dload": "^1.10" + "internal/dload": "^1.11" }, "minimum-stability": "dev", "prefer-stable": true, 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/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..421e9ae 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; 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 345228b..69575bb 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), @@ -347,8 +373,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; + } + } + 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(); @@ -491,7 +542,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 0751dd0..8bef01b 100644 --- a/src/Parser/Parser.php +++ b/src/Parser/Parser.php @@ -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}"); } @@ -317,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); @@ -371,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(); @@ -458,5 +466,4 @@ private function isAtEnd(): bool { return $this->position >= \count($this->tokens) or $this->current()->type === TokenType::Eof; } - } diff --git a/tests/Acceptance/TomlTestComplianceTest.php b/tests/Acceptance/TomlTestComplianceTest.php index aa0bdc9..5ddf488 100644 --- a/tests/Acceptance/TomlTestComplianceTest.php +++ b/tests/Acceptance/TomlTestComplianceTest.php @@ -16,12 +16,13 @@ #[Group('acceptance')] final class TomlTestComplianceTest extends TestCase { - private const TOML_TEST_BINARY = __DIR__ . '/../../runtime/toml-test.exe'; + private const TOML_TEST_DIR = __DIR__ . '/../../runtime'; private const DECODER = __DIR__ . '/toml-test-decoder.php'; private const ENCODER = __DIR__ . '/toml-test-encoder.php'; /** Decoder parses TOML 1.1, encoder outputs TOML 1.0. */ private const DECODER_TOML_VERSION = '1.1'; + private const ENCODER_TOML_VERSION = '1.0'; /** @@ -29,48 +30,11 @@ final class TomlTestComplianceTest extends TestCase * * Each entry should be removed as the corresponding issue is fixed. */ - private const KNOWN_FAILURES = [ - // --- valid tests: decoder produces wrong result --- - + /** Known decoder failures. Each entry should be removed as the issue is fixed. */ + private const KNOWN_DECODER_FAILURES = [ // Null byte in key: PHP stdClass cannot have \0 property 'valid/key/quoted-unicode', - // Datetime format normalization - 'valid/datetime/datetime', - 'valid/datetime/milliseconds', - 'valid/datetime/no-seconds', - 'valid/datetime/timezone', - - // Comment handling edge cases - 'valid/comment/everywhere', - 'valid/comment/tricky', - - // Table: array-table-array, array-subtables interactions - 'valid/table/array-table-array', - 'valid/array/array-subtables', - - // String edge cases - 'valid/string/ends-in-whitespace-escape', - 'valid/string/multiline', - 'valid/string/multiline-empty', - 'valid/string/multiline-quotes', - 'valid/string/raw-multiline', - - // Float precision - 'valid/float/long', - 'valid/float/max-int', - - // Spec examples - 'valid/spec-1.1.0/common-16', - 'valid/spec-1.1.0/common-19', - 'valid/spec-1.1.0/common-24', - 'valid/spec-1.1.0/common-27', - 'valid/spec-1.1.0/common-29', - 'valid/spec-1.1.0/common-31', - 'valid/spec-1.1.0/common-34', - 'valid/spec-example-1', - 'valid/spec-example-1-compact', - // --- invalid tests: parser does not reject invalid input --- // Control character validation @@ -206,17 +170,6 @@ final class TomlTestComplianceTest extends TestCase 'invalid/local-time/second-over', 'invalid/local-time/trailing-dot', - // String validation - 'invalid/string/multiline-bad-escape-04', - 'invalid/string/multiline-lit-no-close-01', - 'invalid/string/multiline-lit-no-close-02', - 'invalid/string/multiline-lit-no-close-03', - 'invalid/string/multiline-lit-no-close-04', - 'invalid/string/multiline-no-close-01', - 'invalid/string/multiline-no-close-02', - 'invalid/string/multiline-no-close-03', - 'invalid/string/multiline-no-close-04', - // Encoding validation 'invalid/encoding/bad-codepoint', 'invalid/encoding/bad-utf8-in-comment', @@ -241,6 +194,11 @@ final class TomlTestComplianceTest extends TestCase 'invalid/spec-1.1.0/common-50-0', ]; + /** Known encoder round-trip failures. */ + private const KNOWN_ENCODER_FAILURES = [ + 'encoder/*', + ]; + /** * Provides all test case names from toml-test list. * @@ -248,7 +206,7 @@ final class TomlTestComplianceTest extends TestCase */ public static function provideDecoderTestCases(): \Generator { - $binary = \str_replace('/', DIRECTORY_SEPARATOR, self::TOML_TEST_BINARY); + $binary = self::tomlTestBinary(); if (!\file_exists($binary)) { return; @@ -306,18 +264,36 @@ public function testDecoderCase(string $testName): void public function testEncoderCompliance(): void { - $result = $this->runTomlTestSuite(self::ENCODER_TOML_VERSION); + $result = $this->runTomlTestSuite(self::ENCODER_TOML_VERSION, skipInvalid: true); self::assertSame(0, $result['exit_code'], "Encoder compliance failed:\n" . $result['output']); } protected function setUp(): void { - if (!\file_exists(self::TOML_TEST_BINARY)) { - self::markTestSkipped('toml-test binary not found at ' . self::TOML_TEST_BINARY); + 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; + } + + private static function isKnownFailure(string $path): bool + { + foreach (self::KNOWN_DECODER_FAILURES as $pattern) { + if (\fnmatch($pattern, $path)) { + return true; + } + } + + return false; + } + /** * Runs a single toml-test case via -run and -json. * @@ -325,7 +301,7 @@ protected function setUp(): void */ private function runSingleTest(string $testName): ?string { - $binary = \str_replace('/', DIRECTORY_SEPARATOR, self::TOML_TEST_BINARY); + $binary = self::tomlTestBinary(); $process = \proc_open( [ @@ -379,25 +355,14 @@ private function runSingleTest(string $testName): ?string return null; } - private static function isKnownFailure(string $path): bool - { - foreach (self::KNOWN_FAILURES as $pattern) { - if (\fnmatch($pattern, $path)) { - return true; - } - } - - return false; - } - /** * Runs the full toml-test suite with encoder, skipping known failures. * * @return array{exit_code: int, output: string} */ - private function runTomlTestSuite(string $tomlVersion): array + private function runTomlTestSuite(string $tomlVersion, bool $skipInvalid = false): array { - $binary = \str_replace('/', DIRECTORY_SEPARATOR, self::TOML_TEST_BINARY); + $binary = self::tomlTestBinary(); $args = [ $binary, 'test', @@ -407,7 +372,13 @@ private function runTomlTestSuite(string $tomlVersion): array '-encoder', PHP_BINARY . ' ' . self::ENCODER, ]; - foreach (self::KNOWN_FAILURES as $skip) { + $skipPatterns = [...self::KNOWN_DECODER_FAILURES]; + + if ($skipInvalid) { + \array_push($skipPatterns, 'invalid/*', ...self::KNOWN_ENCODER_FAILURES); + } + + foreach ($skipPatterns as $skip) { $args[] = '-skip'; $args[] = $skip; } diff --git a/tests/Acceptance/toml-test-decoder.php b/tests/Acceptance/toml-test-decoder.php index 553d4ce..a7978af 100644 --- a/tests/Acceptance/toml-test-decoder.php +++ b/tests/Acceptance/toml-test-decoder.php @@ -142,7 +142,7 @@ function valueToTagged(Value $value): mixed $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(fn(Value $el): mixed => valueToTagged($el), $value->elements), + $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), }; @@ -159,8 +159,9 @@ function floatToTagged(FloatValue $value): array } 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 - $str = (string) $val; if (!\str_contains($str, '.') && !\str_contains($str, 'E') && !\str_contains($str, 'e')) { $str .= '.0'; } @@ -177,11 +178,7 @@ function datetimeToTagged(DateTimeValue $value): array DateTimeType::LocalDate => 'date-local', }; - // Normalize raw: replace space separator with T - $raw = $value->raw; - $raw = \preg_replace('/^(\d{4}-\d{2}-\d{2})[ t]/', '$1T', $raw); - - return ['type' => $type, 'value' => $raw]; + return ['type' => $type, 'value' => $value->raw]; } function inlineTableToTagged(InlineTableValue $value): \stdClass diff --git a/tests/Acceptance/toml-test-encoder.php b/tests/Acceptance/toml-test-encoder.php index d1c1e6a..fb7e617 100644 --- a/tests/Acceptance/toml-test-encoder.php +++ b/tests/Acceptance/toml-test-encoder.php @@ -32,7 +32,7 @@ function taggedToPhp(mixed $value): mixed // Array of tagged values (TOML array) if (\array_is_list($value)) { - return \array_map(fn(mixed $item): mixed => taggedToPhp($item), $value); + return \array_map(static fn(mixed $item): mixed => taggedToPhp($item), $value); } // Table: recursively convert 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..bed7c4a 100644 --- a/tests/Unit/TomlEncodeTest.php +++ b/tests/Unit/TomlEncodeTest.php @@ -378,7 +378,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 1c48a7c..8b4c55d 100644 --- a/tests/Unit/TomlParseToArrayTest.php +++ b/tests/Unit/TomlParseToArrayTest.php @@ -130,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', @@ -283,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 // ============================================ @@ -341,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 // ============================================ @@ -540,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 // ============================================ From c6ebdc456445153c0589b9569802e54194e0bb03 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 31 Mar 2026 09:32:04 +0400 Subject: [PATCH 4/6] test: Run encoder tests via data provider --- tests/Acceptance/TomlTestComplianceTest.php | 285 +++++++++++++------- 1 file changed, 189 insertions(+), 96 deletions(-) diff --git a/tests/Acceptance/TomlTestComplianceTest.php b/tests/Acceptance/TomlTestComplianceTest.php index 5ddf488..b76f00f 100644 --- a/tests/Acceptance/TomlTestComplianceTest.php +++ b/tests/Acceptance/TomlTestComplianceTest.php @@ -25,11 +25,6 @@ final class TomlTestComplianceTest extends TestCase private const ENCODER_TOML_VERSION = '1.0'; - /** - * Known failures from toml-test suite. Supports fnmatch patterns. - * - * Each entry should be removed as the corresponding issue is fixed. - */ /** Known decoder failures. Each entry should be removed as the issue is fixed. */ private const KNOWN_DECODER_FAILURES = [ // Null byte in key: PHP stdClass cannot have \0 property @@ -194,17 +189,146 @@ final class TomlTestComplianceTest extends TestCase 'invalid/spec-1.1.0/common-50-0', ]; - /** Known encoder round-trip failures. */ + /** Known encoder round-trip failures. Each entry should be removed as the issue is fixed. */ + /** Known encoder round-trip failures. Each entry should be removed as the issue is fixed. */ private const KNOWN_ENCODER_FAILURES = [ - 'encoder/*', + 'encoder/array/array', + 'encoder/array/nested-inline-table', + 'encoder/array/open-parent-table', + 'encoder/comment/tricky', + 'encoder/datetime/local', + 'encoder/datetime/local-time', + 'encoder/datetime/milliseconds', + 'encoder/float/exponent', + 'encoder/float/long', + 'encoder/float/max-int', + 'encoder/float/zero', + 'encoder/inline-table/empty', + 'encoder/inline-table/nest', + 'encoder/inline-table/spaces', + 'encoder/key/alphanum', + 'encoder/key/escapes', + 'encoder/key/numeric-01', + 'encoder/key/numeric-02', + 'encoder/key/numeric-04', + 'encoder/key/numeric-05', + 'encoder/key/numeric-06', + 'encoder/key/numeric-08', + 'encoder/key/quoted-dots', + 'encoder/key/quoted-unicode', + 'encoder/key/special-chars', + 'encoder/key/special-word', + 'encoder/key/start', + 'encoder/key/zero', + 'encoder/multibyte', + 'encoder/spec-1.0.0/array-of-tables-0', + 'encoder/spec-1.0.0/float-0', + 'encoder/spec-1.0.0/float-1', + 'encoder/spec-1.0.0/keys-0', + 'encoder/spec-1.0.0/keys-1', + 'encoder/spec-1.0.0/keys-3', + 'encoder/spec-1.0.0/keys-7', + 'encoder/spec-1.0.0/local-date-time-0', + 'encoder/spec-1.0.0/local-time-0', + 'encoder/spec-1.0.0/offset-date-time-0', + 'encoder/spec-1.0.0/string-7', + 'encoder/spec-1.0.0/table-0', + 'encoder/spec-1.0.0/table-2', + 'encoder/spec-1.0.0/table-3', + 'encoder/spec-1.0.0/table-4', + 'encoder/spec-1.0.0/table-5', + 'encoder/spec-1.0.0/table-6', + 'encoder/string/escapes', + 'encoder/string/multiline-escaped-crlf', + 'encoder/string/multiline-quotes', + 'encoder/string/quoted-unicode', + 'encoder/string/raw-multiline', + 'encoder/string/unicode-escape', + 'encoder/table/array-empty', + 'encoder/table/empty', + 'encoder/table/empty-name', + 'encoder/table/keyword', + 'encoder/table/names', + 'encoder/table/names-with-values', + 'encoder/table/no-eol', + 'encoder/table/sub-empty', + 'encoder/table/whitespace', + 'encoder/table/without-super', ]; + /** Encoder failures that only reproduce on Windows (datetime timezone handling, CRLF). */ + private const KNOWN_ENCODER_FAILURES_WINDOWS = [ + 'encoder/comment/everywhere', + 'encoder/datetime/edge', + 'encoder/datetime/leap-year', + 'encoder/datetime/local-date', + 'encoder/spec-1.0.0/local-date-0', + 'encoder/spec-1.0.0/table-7', + ]; + + /** @return \Generator */ + 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 + { + $knownFailures = \DIRECTORY_SEPARATOR === '\\' + ? [...self::KNOWN_ENCODER_FAILURES, ...self::KNOWN_ENCODER_FAILURES_WINDOWS] + : self::KNOWN_ENCODER_FAILURES; + + $this->runComplianceCase( + $testName, + $knownFailures, + '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; + } + /** - * Provides all test case names from toml-test list. + * 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 */ - public static function provideDecoderTestCases(): \Generator + /** + * @param list $prefixes + */ + private static function listTestCases(string $tomlVersion, array $prefixes, ?string $prefix = null): \Generator { $binary = self::tomlTestBinary(); @@ -213,7 +337,7 @@ public static function provideDecoderTestCases(): \Generator } $process = \proc_open( - [$binary, 'list', '-toml', self::DECODER_TOML_VERSION], + [$binary, 'list', '-toml', $tomlVersion], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, ); @@ -230,23 +354,48 @@ public static function provideDecoderTestCases(): \Generator $seen = []; foreach (\explode("\n", \trim($stdout)) as $line) { $name = \preg_replace('/\.(toml|json)$/', '', \trim($line)); - if ($name === '' || \str_starts_with($name, 'encoder/') || isset($seen[$name])) { + 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; - yield $name => [$name]; + $testName = $prefix !== null + ? $prefix . \substr($name, \strlen('valid/')) + : $name; + + yield $testName => [$testName]; } } - #[DataProvider('provideDecoderTestCases')] - public function testDecoderCase(string $testName): void - { - $result = $this->runSingleTest($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 (self::isKnownFailure($testName)) { - self::fail("Known failure '{$testName}' now passes โ€” remove it from KNOWN_FAILURES."); + if ($isKnown) { + self::fail("Known failure '{$testName}' now passes โ€” remove it from {$listName}."); } $this->addToAssertionCount(1); @@ -254,38 +403,19 @@ public function testDecoderCase(string $testName): void return; } - // Test failed - if (self::isKnownFailure($testName)) { + if ($isKnown) { self::markTestIncomplete($result); } self::fail($result); } - public function testEncoderCompliance(): void - { - $result = $this->runTomlTestSuite(self::ENCODER_TOML_VERSION, skipInvalid: true); - - self::assertSame(0, $result['exit_code'], "Encoder compliance failed:\n" . $result['output']); - } - - 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; - } - - private static function isKnownFailure(string $path): bool + /** + * @param list $patterns + */ + private static function matchesAny(string $path, array $patterns): bool { - foreach (self::KNOWN_DECODER_FAILURES as $pattern) { + foreach ($patterns as $pattern) { if (\fnmatch($pattern, $path)) { return true; } @@ -299,19 +429,26 @@ private static function isKnownFailure(string $path): bool * * @return string|null Failure details or null if passed. */ - private function runSingleTest(string $testName): ?string + 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( - [ - $binary, 'test', - '-toml', self::DECODER_TOML_VERSION, - '-color', 'never', - '-json', - '-run', $testName, - '-decoder', PHP_BINARY . ' ' . self::DECODER, - ], + $args, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, ); @@ -328,7 +465,6 @@ private function runSingleTest(string $testName): ?string continue; } - // Build readable output with full context $lines = ["FAIL {$test['path']}"]; $lines[] = ''; $lines[] = $test['failure']; @@ -354,47 +490,4 @@ private function runSingleTest(string $testName): ?string return null; } - - /** - * Runs the full toml-test suite with encoder, skipping known failures. - * - * @return array{exit_code: int, output: string} - */ - private function runTomlTestSuite(string $tomlVersion, bool $skipInvalid = false): array - { - $binary = self::tomlTestBinary(); - - $args = [ - $binary, 'test', - '-toml', $tomlVersion, - '-color', 'never', - '-decoder', PHP_BINARY . ' ' . self::DECODER, - '-encoder', PHP_BINARY . ' ' . self::ENCODER, - ]; - - $skipPatterns = [...self::KNOWN_DECODER_FAILURES]; - - if ($skipInvalid) { - \array_push($skipPatterns, 'invalid/*', ...self::KNOWN_ENCODER_FAILURES); - } - - foreach ($skipPatterns as $skip) { - $args[] = '-skip'; - $args[] = $skip; - } - - $process = \proc_open( - $args, - [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], - $pipes, - ); - - $stdout = \stream_get_contents($pipes[1]); - $stderr = \stream_get_contents($pipes[2]); - \fclose($pipes[1]); - \fclose($pipes[2]); - $exitCode = \proc_close($process); - - return ['exit_code' => $exitCode, 'output' => $stdout . $stderr]; - } } From 3ffdbd831cd5d7ef3be5327af60da153adf64ba1 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 31 Mar 2026 10:19:35 +0400 Subject: [PATCH 5/6] test: Fix date-local encoder --- phpunit.xml.dist | 2 +- src/Encoder/ValueFactory.php | 1 + tests/Acceptance/TomlTestComplianceTest.php | 15 +----- tests/Acceptance/toml-test-encoder.php | 18 +++++-- tests/Unit/TomlEncodeTest.php | 59 +++++++++++++++++++++ 5 files changed, 76 insertions(+), 19 deletions(-) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 309201b..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" 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/tests/Acceptance/TomlTestComplianceTest.php b/tests/Acceptance/TomlTestComplianceTest.php index b76f00f..cfed31d 100644 --- a/tests/Acceptance/TomlTestComplianceTest.php +++ b/tests/Acceptance/TomlTestComplianceTest.php @@ -192,13 +192,9 @@ final class TomlTestComplianceTest extends TestCase /** Known encoder round-trip failures. Each entry should be removed as the issue is fixed. */ /** Known encoder round-trip failures. Each entry should be removed as the issue is fixed. */ private const KNOWN_ENCODER_FAILURES = [ - 'encoder/array/array', 'encoder/array/nested-inline-table', 'encoder/array/open-parent-table', 'encoder/comment/tricky', - 'encoder/datetime/local', - 'encoder/datetime/local-time', - 'encoder/datetime/milliseconds', 'encoder/float/exponent', 'encoder/float/long', 'encoder/float/max-int', @@ -228,9 +224,6 @@ final class TomlTestComplianceTest extends TestCase 'encoder/spec-1.0.0/keys-1', 'encoder/spec-1.0.0/keys-3', 'encoder/spec-1.0.0/keys-7', - 'encoder/spec-1.0.0/local-date-time-0', - 'encoder/spec-1.0.0/local-time-0', - 'encoder/spec-1.0.0/offset-date-time-0', 'encoder/spec-1.0.0/string-7', 'encoder/spec-1.0.0/table-0', 'encoder/spec-1.0.0/table-2', @@ -256,14 +249,8 @@ final class TomlTestComplianceTest extends TestCase 'encoder/table/without-super', ]; - /** Encoder failures that only reproduce on Windows (datetime timezone handling, CRLF). */ + /** Encoder failures that only reproduce on Windows. */ private const KNOWN_ENCODER_FAILURES_WINDOWS = [ - 'encoder/comment/everywhere', - 'encoder/datetime/edge', - 'encoder/datetime/leap-year', - 'encoder/datetime/local-date', - 'encoder/spec-1.0.0/local-date-0', - 'encoder/spec-1.0.0/table-7', ]; /** @return \Generator */ diff --git a/tests/Acceptance/toml-test-encoder.php b/tests/Acceptance/toml-test-encoder.php index fb7e617..eec8f79 100644 --- a/tests/Acceptance/toml-test-encoder.php +++ b/tests/Acceptance/toml-test-encoder.php @@ -5,6 +5,10 @@ require __DIR__ . '/../../vendor/autoload.php'; +use Internal\Toml\Node\Position; +use Internal\Toml\Node\Value\DateTimeType; +use Internal\Toml\Node\Value\DateTimeValue; +use Internal\Toml\Node\Value\LocalTimeValue; use Internal\Toml\Toml; $input = \file_get_contents('php://stdin'); @@ -51,10 +55,16 @@ function convertTaggedValue(string $type, string $value): mixed 'integer' => (int) $value, 'float' => convertFloat($value), 'bool' => $value === 'true', - 'datetime' => new \DateTimeImmutable($value), - 'datetime-local' => new \DateTimeImmutable($value), - 'date-local' => new \DateTimeImmutable($value), - 'time-local' => $value, + '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}"), }; } diff --git a/tests/Unit/TomlEncodeTest.php b/tests/Unit/TomlEncodeTest.php index bed7c4a..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 // ============================================ From da2a855ca59d8e02e7c58c658f3e275b9795f1c6 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 31 Mar 2026 15:10:13 +0400 Subject: [PATCH 6/6] chore: cleanup --- src/Node/Key.php | 10 +-------- src/Parser/Lexer.php | 25 +++++++++------------ tests/Acceptance/TomlTestComplianceTest.php | 10 +-------- 3 files changed, 12 insertions(+), 33 deletions(-) diff --git a/src/Node/Key.php b/src/Node/Key.php index 421e9ae..cd9ce4c 100644 --- a/src/Node/Key.php +++ b/src/Node/Key.php @@ -56,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/Parser/Lexer.php b/src/Parser/Lexer.php index 69575bb..43a5213 100644 --- a/src/Parser/Lexer.php +++ b/src/Parser/Lexer.php @@ -322,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), @@ -343,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())) { @@ -359,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())) { @@ -389,7 +384,7 @@ private function isLineEndingBackslash(): bool $offset++; continue; } - return false; + return false; // includes $ch === '' (end of input) } } diff --git a/tests/Acceptance/TomlTestComplianceTest.php b/tests/Acceptance/TomlTestComplianceTest.php index cfed31d..021b716 100644 --- a/tests/Acceptance/TomlTestComplianceTest.php +++ b/tests/Acceptance/TomlTestComplianceTest.php @@ -189,7 +189,6 @@ final class TomlTestComplianceTest extends TestCase 'invalid/spec-1.1.0/common-50-0', ]; - /** Known encoder round-trip failures. Each entry should be removed as the issue is fixed. */ /** Known encoder round-trip failures. Each entry should be removed as the issue is fixed. */ private const KNOWN_ENCODER_FAILURES = [ 'encoder/array/nested-inline-table', @@ -249,9 +248,6 @@ final class TomlTestComplianceTest extends TestCase 'encoder/table/without-super', ]; - /** Encoder failures that only reproduce on Windows. */ - private const KNOWN_ENCODER_FAILURES_WINDOWS = [ - ]; /** @return \Generator */ public static function provideDecoderTestCases(): \Generator @@ -278,13 +274,9 @@ public function testDecoderCase(string $testName): void #[DataProvider('provideEncoderTestCases')] public function testEncoderCase(string $testName): void { - $knownFailures = \DIRECTORY_SEPARATOR === '\\' - ? [...self::KNOWN_ENCODER_FAILURES, ...self::KNOWN_ENCODER_FAILURES_WINDOWS] - : self::KNOWN_ENCODER_FAILURES; - $this->runComplianceCase( $testName, - $knownFailures, + self::KNOWN_ENCODER_FAILURES, 'KNOWN_ENCODER_FAILURES', encoder: true, );