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
// ============================================