From 19447399c367dabe7e0383ffe8641a3cd76aa4ea Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sat, 30 May 2026 15:25:23 +0200 Subject: [PATCH 1/5] Prepare TOML 1.0 release defaults --- README.md | 2 +- docs/guide/syntax.md | 2 +- docs/reference/api.md | 15 +++++---- docs/reference/support-matrix.md | 10 +++--- src/Encoder/EncoderOptions.php | 2 +- src/Parser/Parser.php | 37 +++++++++++++++------ tests/Integration/DocumentRoundTripTest.php | 9 ++++- tests/PublicApi/VersioningTest.php | 36 ++++++++++++++++++++ 8 files changed, 87 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 6d34754..fbe647e 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ The library currently supports: See the [Support Matrix](https://php-collective.github.io/toml/reference/support-matrix) for current coverage and known gaps. -Versioned behavior is available through `TomlVersion` and `EncoderOptions(version: ...)`. The default remains TOML 1.1-compatible; use `TomlVersion::V10` when you need strict TOML 1.0 parsing or output rules. +Versioned behavior is available through `TomlVersion` and `EncoderOptions(version: ...)`. Parsing defaults to TOML 1.1-compatible input, while encoding defaults to TOML 1.0-compatible output for interoperability. Use `TomlVersion::V10` when you need strict TOML 1.0 parsing, or `EncoderOptions(version: TomlVersion::V11)` when you want TOML 1.1 output. For explicit local temporal encoding, use: diff --git a/docs/guide/syntax.md b/docs/guide/syntax.md index a698972..1a7fe8b 100644 --- a/docs/guide/syntax.md +++ b/docs/guide/syntax.md @@ -234,7 +234,7 @@ point = { x = 1, y = 2 } animal = { type.name = "pug" } ``` -TOML 1.1 also allows multiline inline tables and trailing commas: +TOML 1.1 also allows multiline inline-table layout and trailing commas: ```toml point = { diff --git a/docs/reference/api.md b/docs/reference/api.md index cd2d111..459fec8 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -77,7 +77,7 @@ if ($result->isValid()) { Use `tryParse()` when you need diagnostics and a partial AST instead of exception-driven control flow. -The default parser/decoder mode is TOML 1.1-compatible. Use `TomlVersion::V10` when you need strict TOML 1.0 rejection of 1.1-only features such as `\xHH`, `\e`, multiline inline tables, inline-table trailing commas, or local times without seconds. +The default parser/decoder mode is TOML 1.1-compatible. Use `TomlVersion::V10` when you need strict TOML 1.0 rejection of 1.1-only features such as `\xHH`, `\e`, multiline inline-table layout, inline-table trailing commas, or local times without seconds. ### encode() @@ -91,9 +91,8 @@ Encodes a PHP array to a TOML string. $toml = Toml::encode(['key' => 'value']); // key = "value" -$strict = Toml::encode( +$toml = Toml::encode( ['time' => new \PhpCollective\Toml\Value\LocalTime('07:32')], - new EncoderOptions(version: TomlVersion::V10), ); // time = 07:32:00 ``` @@ -335,18 +334,20 @@ $toml = Toml::encode( // database.port = 5432 ``` -### TOML 1.0 Mode +### TOML Version Modes + +Parsing and decoding default to TOML 1.1-compatible input. Encoding defaults to TOML 1.0-compatible output for interoperability and normalizes local times and local datetimes to include seconds where possible. -In strict TOML 1.0 mode, `encode()` normalizes local times and local datetimes to include seconds where possible. `encodeDocument()` in `DocumentFormattingMode::SourceAware` throws `EncodeException` if preserving the parsed source would keep TOML 1.1-only syntax. +`encodeDocument()` in `DocumentFormattingMode::SourceAware` throws `EncodeException` if preserving the parsed source would keep TOML 1.1-only syntax while `EncoderOptions(version: TomlVersion::V10)` is active. ## TomlVersion Controls version-specific parser and encoder behavior. - `TomlVersion::V11` - Default behavior. Accepts TOML 1.1 syntax and emits TOML 1.1-compatible output. + Default parser/decoder behavior. Accepts TOML 1.1 syntax and emits TOML 1.1-compatible output when passed to `EncoderOptions`. - `TomlVersion::V10` - Strict TOML 1.0 mode for parsing, decoding, and encoding. + Strict TOML 1.0 mode for parsing and decoding. Default encoder behavior. ## DocumentFormattingMode diff --git a/docs/reference/support-matrix.md b/docs/reference/support-matrix.md index 5e0c817..f7cfe89 100644 --- a/docs/reference/support-matrix.md +++ b/docs/reference/support-matrix.md @@ -6,7 +6,7 @@ It is intentionally narrower than a blanket "full TOML support" claim. The goal The sections below intentionally separate parsing/decoding, encoding, and round-trip editing so support claims stay scoped to the actual surface being described. -The default API behavior is TOML 1.1-compatible. Strict TOML 1.0 parsing/decoding and encoding are available through `TomlVersion::V10` and `EncoderOptions(version: TomlVersion::V10)`. +The default parser/decoder behavior is TOML 1.1-compatible. The default encoder behavior is TOML 1.0-compatible for interoperability; TOML 1.1 output is available through `EncoderOptions(version: TomlVersion::V11)`. ## Status Legend @@ -62,7 +62,7 @@ The default API behavior is TOML 1.1-compatible. Strict TOML 1.0 parsing/decodin | Nested inline tables | Supported | | | Dotted keys inside inline tables | Supported | | | Inline table trailing commas | Supported | TOML 1.1; rejected in strict TOML 1.0 mode | -| Multiline inline tables | Supported | TOML 1.1; rejected in strict TOML 1.0 mode | +| Multiline inline-table layout | Supported | TOML 1.1; rejected in strict TOML 1.0 mode. TOML 1.0 still allows multiline array and string values inside inline tables | ## Encoding @@ -121,19 +121,19 @@ Tested against [toml-test](https://github.com/toml-lang/toml-test) v2.1.0: | Test Type | Passed | Failed | Compliance | |-----------|--------|--------|------------| -| Valid | 428 | 0 | 100% | +| Valid | 214 | 0 | 100% | | Invalid | 466 | 0 | 100% | ### TOML 1.0 | Test Type | Passed | Failed | Compliance | |-----------|--------|--------|------------| -| Valid | 410 | 0 | 100% | +| Valid | 205 | 0 | 100% | | Invalid | 473 | 0 | 100% | These results were measured against the library's `bin/toml-decoder` adapter for the `toml-test` tagged JSON format. TOML 1.1 results use the default adapter mode; TOML 1.0 results use `TOML_VERSION=1.0` so the decoder runs in strict TOML 1.0 mode. -Strict TOML 1.0 mode closes the previously documented invalid-case gaps for syntax that TOML 1.1 relaxes: multiline inline tables, inline-table trailing commas, `\xHH` byte escapes, and optional seconds in local times/datetimes. +Strict TOML 1.0 mode closes the previously documented invalid-case gaps for syntax that TOML 1.1 relaxes: multiline inline-table layout, inline-table trailing commas, `\xHH` byte escapes, and optional seconds in local times/datetimes. ## Recommended Use diff --git a/src/Encoder/EncoderOptions.php b/src/Encoder/EncoderOptions.php index 582c79b..51e3b03 100644 --- a/src/Encoder/EncoderOptions.php +++ b/src/Encoder/EncoderOptions.php @@ -13,7 +13,7 @@ public function __construct( public string $newline = "\n", public DocumentFormattingMode $documentFormatting = DocumentFormattingMode::Normalized, public bool $skipNulls = false, - public TomlVersion $version = TomlVersion::V11, + public TomlVersion $version = TomlVersion::V10, public bool $integerGrouping = false, public bool $trailingComma = false, public bool $dottedKeys = false, diff --git a/src/Parser/Parser.php b/src/Parser/Parser.php index c1d325b..99e69e8 100644 --- a/src/Parser/Parser.php +++ b/src/Parser/Parser.php @@ -720,10 +720,11 @@ private function parseInlineTable(): InlineTable $closingTrivia = []; $hasTrailingComma = false; $nextLeadingTrivia = $openingTrivia; + $hasInlineTableLayoutNewline = $this->triviaContainsNewline($openingTrivia); while (!$this->check(TokenType::RightBrace) && !$this->isAtEnd()) { if (!$this->preserveTrivia) { - $this->skipTriviaInCollection(); + $hasInlineTableLayoutNewline = $this->skipTriviaInCollection() || $hasInlineTableLayoutNewline; } if ($this->check(TokenType::RightBrace)) { @@ -750,8 +751,9 @@ private function parseInlineTable(): InlineTable } $trailingTrivia = $this->preserveTrivia ? $this->collectCollectionTrivia() : []; + $hasInlineTableLayoutNewline = $this->triviaContainsNewline($trailingTrivia) || $hasInlineTableLayoutNewline; if (!$this->preserveTrivia) { - $this->skipTriviaInCollection(); + $hasInlineTableLayoutNewline = $this->skipTriviaInCollection() || $hasInlineTableLayoutNewline; } if (!$this->check(TokenType::RightBrace)) { @@ -763,8 +765,9 @@ private function parseInlineTable(): InlineTable } $nextLeadingTrivia = $this->preserveTrivia ? $this->collectCollectionTrivia() : []; + $hasInlineTableLayoutNewline = $this->triviaContainsNewline($nextLeadingTrivia) || $hasInlineTableLayoutNewline; if (!$this->preserveTrivia) { - $this->skipTriviaInCollection(); + $hasInlineTableLayoutNewline = $this->skipTriviaInCollection() || $hasInlineTableLayoutNewline; } if ($this->check(TokenType::RightBrace)) { $hasTrailingComma = true; @@ -783,7 +786,7 @@ private function parseInlineTable(): InlineTable array_pop($this->contextStack); if ($this->version === TomlVersion::V10) { - if ($this->inlineTableIsMultiline($start)) { + if ($hasInlineTableLayoutNewline) { $this->error('Multiline inline tables require TOML 1.1', $span); } @@ -804,11 +807,6 @@ private function parseInlineTable(): InlineTable ); } - private function inlineTableIsMultiline(Span $start): bool - { - return $start->line !== $this->previous()->span->line; - } - // Helper methods private function current(): Token @@ -1052,11 +1050,16 @@ private function getKeyValueTerminatorHint(Token $token): ?string return null; } - private function skipTriviaInCollection(): void + private function skipTriviaInCollection(): bool { + $hasNewline = false; + while ($this->check(TokenType::Whitespace) || $this->check(TokenType::Comment) || $this->check(TokenType::Newline)) { + $hasNewline = $this->check(TokenType::Newline) || $hasNewline; $this->advance(); } + + return $hasNewline; } /** @@ -1105,6 +1108,20 @@ private function collectCollectionTrivia(): array return $trivia; } + /** + * @param array<\PhpCollective\Toml\Ast\Trivia> $trivia + */ + private function triviaContainsNewline(array $trivia): bool + { + foreach ($trivia as $item) { + if ($item->kind === TriviaKind::Newline) { + return true; + } + } + + return false; + } + private function toTrivia(Token $token): Trivia { $kind = match ($token->type) { diff --git a/tests/Integration/DocumentRoundTripTest.php b/tests/Integration/DocumentRoundTripTest.php index d44132d..37fc22a 100644 --- a/tests/Integration/DocumentRoundTripTest.php +++ b/tests/Integration/DocumentRoundTripTest.php @@ -7,6 +7,7 @@ use PhpCollective\Toml\Encoder\DocumentFormattingMode; use PhpCollective\Toml\Encoder\EncoderOptions; use PhpCollective\Toml\Toml; +use PhpCollective\Toml\TomlVersion; use PHPUnit\Framework\TestCase; final class DocumentRoundTripTest extends TestCase @@ -145,7 +146,13 @@ public function testEncodeDocumentPreservesTabIndentedMultilineInlineTable(): vo $input = "point = {\n\tx = 1,\n\ty = 2,\n}"; $doc = Toml::parse($input, true); - $encoded = Toml::encodeDocument($doc, new EncoderOptions(documentFormatting: DocumentFormattingMode::SourceAware)); + $encoded = Toml::encodeDocument( + $doc, + new EncoderOptions( + documentFormatting: DocumentFormattingMode::SourceAware, + version: TomlVersion::V11, + ), + ); $this->assertSame($input, $encoded); } diff --git a/tests/PublicApi/VersioningTest.php b/tests/PublicApi/VersioningTest.php index e0e0faa..f67f236 100644 --- a/tests/PublicApi/VersioningTest.php +++ b/tests/PublicApi/VersioningTest.php @@ -42,6 +42,15 @@ public function testDecodeRejectsOptionalSecondsInToml10Mode(): void Toml::decode("time = 07:32\n", TomlVersion::V10); } + public function testEncodeDefaultsToToml10Behavior(): void + { + $encoded = Toml::encode([ + 'time' => new LocalTime('07:32'), + ]); + + $this->assertSame('time = 07:32:00', $encoded); + } + public function testTryParseRejectsInlineTableTrailingCommaInToml10Mode(): void { $result = Toml::tryParse('point = { x = 1, }', TomlVersion::V10); @@ -58,6 +67,33 @@ public function testTryParseRejectsMultilineInlineTableInToml10Mode(): void $this->assertSame('Multiline inline tables require TOML 1.1', $result->getErrors()[0]->message); } + public function testDecodeAllowsMultilineArrayInsideInlineTableInToml10Mode(): void + { + $input = <<<'TOML' +point = { values = [ + 1, + 2, +] } +TOML; + + $decoded = Toml::decode($input, TomlVersion::V10); + + $this->assertSame(['point' => ['values' => [1, 2]]], $decoded); + } + + public function testDecodeAllowsMultilineStringInsideInlineTableInToml10Mode(): void + { + $input = <<<'TOML' +point = { text = """ +hello +""" } +TOML; + + $decoded = Toml::decode($input, TomlVersion::V10); + + $this->assertSame(['point' => ['text' => "hello\n"]], $decoded); + } + public function testEncodeNormalizesLocalTimeForToml10Mode(): void { $encoded = Toml::encode([ From 5ac60cdb43d66c6ad8938e781733bf78d47fb5f6 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sat, 30 May 2026 15:29:09 +0200 Subject: [PATCH 2/5] Keep v1 branch focused on encoder defaults --- docs/guide/syntax.md | 2 +- docs/reference/api.md | 2 +- docs/reference/support-matrix.md | 8 +++---- src/Parser/Parser.php | 37 ++++++++---------------------- tests/PublicApi/VersioningTest.php | 27 ---------------------- 5 files changed, 16 insertions(+), 60 deletions(-) diff --git a/docs/guide/syntax.md b/docs/guide/syntax.md index 1a7fe8b..a698972 100644 --- a/docs/guide/syntax.md +++ b/docs/guide/syntax.md @@ -234,7 +234,7 @@ point = { x = 1, y = 2 } animal = { type.name = "pug" } ``` -TOML 1.1 also allows multiline inline-table layout and trailing commas: +TOML 1.1 also allows multiline inline tables and trailing commas: ```toml point = { diff --git a/docs/reference/api.md b/docs/reference/api.md index 459fec8..b2fe5b3 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -77,7 +77,7 @@ if ($result->isValid()) { Use `tryParse()` when you need diagnostics and a partial AST instead of exception-driven control flow. -The default parser/decoder mode is TOML 1.1-compatible. Use `TomlVersion::V10` when you need strict TOML 1.0 rejection of 1.1-only features such as `\xHH`, `\e`, multiline inline-table layout, inline-table trailing commas, or local times without seconds. +The default parser/decoder mode is TOML 1.1-compatible. Use `TomlVersion::V10` when you need strict TOML 1.0 rejection of 1.1-only features such as `\xHH`, `\e`, multiline inline tables, inline-table trailing commas, or local times without seconds. ### encode() diff --git a/docs/reference/support-matrix.md b/docs/reference/support-matrix.md index f7cfe89..bd4bbe7 100644 --- a/docs/reference/support-matrix.md +++ b/docs/reference/support-matrix.md @@ -62,7 +62,7 @@ The default parser/decoder behavior is TOML 1.1-compatible. The default encoder | Nested inline tables | Supported | | | Dotted keys inside inline tables | Supported | | | Inline table trailing commas | Supported | TOML 1.1; rejected in strict TOML 1.0 mode | -| Multiline inline-table layout | Supported | TOML 1.1; rejected in strict TOML 1.0 mode. TOML 1.0 still allows multiline array and string values inside inline tables | +| Multiline inline tables | Supported | TOML 1.1; rejected in strict TOML 1.0 mode | ## Encoding @@ -121,19 +121,19 @@ Tested against [toml-test](https://github.com/toml-lang/toml-test) v2.1.0: | Test Type | Passed | Failed | Compliance | |-----------|--------|--------|------------| -| Valid | 214 | 0 | 100% | +| Valid | 428 | 0 | 100% | | Invalid | 466 | 0 | 100% | ### TOML 1.0 | Test Type | Passed | Failed | Compliance | |-----------|--------|--------|------------| -| Valid | 205 | 0 | 100% | +| Valid | 410 | 0 | 100% | | Invalid | 473 | 0 | 100% | These results were measured against the library's `bin/toml-decoder` adapter for the `toml-test` tagged JSON format. TOML 1.1 results use the default adapter mode; TOML 1.0 results use `TOML_VERSION=1.0` so the decoder runs in strict TOML 1.0 mode. -Strict TOML 1.0 mode closes the previously documented invalid-case gaps for syntax that TOML 1.1 relaxes: multiline inline-table layout, inline-table trailing commas, `\xHH` byte escapes, and optional seconds in local times/datetimes. +Strict TOML 1.0 mode closes the previously documented invalid-case gaps for syntax that TOML 1.1 relaxes: multiline inline tables, inline-table trailing commas, `\xHH` byte escapes, and optional seconds in local times/datetimes. ## Recommended Use diff --git a/src/Parser/Parser.php b/src/Parser/Parser.php index 99e69e8..c1d325b 100644 --- a/src/Parser/Parser.php +++ b/src/Parser/Parser.php @@ -720,11 +720,10 @@ private function parseInlineTable(): InlineTable $closingTrivia = []; $hasTrailingComma = false; $nextLeadingTrivia = $openingTrivia; - $hasInlineTableLayoutNewline = $this->triviaContainsNewline($openingTrivia); while (!$this->check(TokenType::RightBrace) && !$this->isAtEnd()) { if (!$this->preserveTrivia) { - $hasInlineTableLayoutNewline = $this->skipTriviaInCollection() || $hasInlineTableLayoutNewline; + $this->skipTriviaInCollection(); } if ($this->check(TokenType::RightBrace)) { @@ -751,9 +750,8 @@ private function parseInlineTable(): InlineTable } $trailingTrivia = $this->preserveTrivia ? $this->collectCollectionTrivia() : []; - $hasInlineTableLayoutNewline = $this->triviaContainsNewline($trailingTrivia) || $hasInlineTableLayoutNewline; if (!$this->preserveTrivia) { - $hasInlineTableLayoutNewline = $this->skipTriviaInCollection() || $hasInlineTableLayoutNewline; + $this->skipTriviaInCollection(); } if (!$this->check(TokenType::RightBrace)) { @@ -765,9 +763,8 @@ private function parseInlineTable(): InlineTable } $nextLeadingTrivia = $this->preserveTrivia ? $this->collectCollectionTrivia() : []; - $hasInlineTableLayoutNewline = $this->triviaContainsNewline($nextLeadingTrivia) || $hasInlineTableLayoutNewline; if (!$this->preserveTrivia) { - $hasInlineTableLayoutNewline = $this->skipTriviaInCollection() || $hasInlineTableLayoutNewline; + $this->skipTriviaInCollection(); } if ($this->check(TokenType::RightBrace)) { $hasTrailingComma = true; @@ -786,7 +783,7 @@ private function parseInlineTable(): InlineTable array_pop($this->contextStack); if ($this->version === TomlVersion::V10) { - if ($hasInlineTableLayoutNewline) { + if ($this->inlineTableIsMultiline($start)) { $this->error('Multiline inline tables require TOML 1.1', $span); } @@ -807,6 +804,11 @@ private function parseInlineTable(): InlineTable ); } + private function inlineTableIsMultiline(Span $start): bool + { + return $start->line !== $this->previous()->span->line; + } + // Helper methods private function current(): Token @@ -1050,16 +1052,11 @@ private function getKeyValueTerminatorHint(Token $token): ?string return null; } - private function skipTriviaInCollection(): bool + private function skipTriviaInCollection(): void { - $hasNewline = false; - while ($this->check(TokenType::Whitespace) || $this->check(TokenType::Comment) || $this->check(TokenType::Newline)) { - $hasNewline = $this->check(TokenType::Newline) || $hasNewline; $this->advance(); } - - return $hasNewline; } /** @@ -1108,20 +1105,6 @@ private function collectCollectionTrivia(): array return $trivia; } - /** - * @param array<\PhpCollective\Toml\Ast\Trivia> $trivia - */ - private function triviaContainsNewline(array $trivia): bool - { - foreach ($trivia as $item) { - if ($item->kind === TriviaKind::Newline) { - return true; - } - } - - return false; - } - private function toTrivia(Token $token): Trivia { $kind = match ($token->type) { diff --git a/tests/PublicApi/VersioningTest.php b/tests/PublicApi/VersioningTest.php index f67f236..f64f286 100644 --- a/tests/PublicApi/VersioningTest.php +++ b/tests/PublicApi/VersioningTest.php @@ -67,33 +67,6 @@ public function testTryParseRejectsMultilineInlineTableInToml10Mode(): void $this->assertSame('Multiline inline tables require TOML 1.1', $result->getErrors()[0]->message); } - public function testDecodeAllowsMultilineArrayInsideInlineTableInToml10Mode(): void - { - $input = <<<'TOML' -point = { values = [ - 1, - 2, -] } -TOML; - - $decoded = Toml::decode($input, TomlVersion::V10); - - $this->assertSame(['point' => ['values' => [1, 2]]], $decoded); - } - - public function testDecodeAllowsMultilineStringInsideInlineTableInToml10Mode(): void - { - $input = <<<'TOML' -point = { text = """ -hello -""" } -TOML; - - $decoded = Toml::decode($input, TomlVersion::V10); - - $this->assertSame(['point' => ['text' => "hello\n"]], $decoded); - } - public function testEncodeNormalizesLocalTimeForToml10Mode(): void { $encoded = Toml::encode([ From a8d880dfd1987227b54e0b2556506a7fd6f756ba Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sat, 30 May 2026 16:15:24 +0200 Subject: [PATCH 3/5] Support PHP 8.1 on v1 (#28) * Support PHP 8.1 on v1 * Keep PHPUnit config compatible with PHPUnit 10 --- .github/workflows/ci.yml | 6 +++--- README.md | 4 ++-- composer.json | 4 ++-- docs/guide/index.md | 2 +- docs/index.md | 4 ++-- phpunit.xml | 1 - src/Encoder/EncoderOptions.php | 24 ++++++++++++------------ src/Lexer/Span.php | 10 +++++----- src/Lexer/Token.php | 10 +++++----- src/Parser/ParseError.php | 8 ++++---- src/Parser/ParseResult.php | 8 ++++---- src/Value/LocalDate.php | 4 ++-- src/Value/LocalDateTime.php | 4 ++-- src/Value/LocalTime.php | 4 ++-- 14 files changed, 46 insertions(+), 47 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc0a715..2552c8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,16 +2,16 @@ name: CI on: push: - branches: [master] + branches: [master, v1] pull_request: - branches: [master] + branches: [master, v1] jobs: tests: runs-on: ubuntu-latest strategy: matrix: - php: ['8.2', '8.4'] + php: ['8.1', '8.2', '8.4'] name: PHP ${{ matrix.php }} diff --git a/README.md b/README.md index fbe647e..b7505c9 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Latest Stable Version](https://img.shields.io/packagist/v/php-collective/toml?style=flat-square)](https://packagist.org/packages/php-collective/toml) [![Total Downloads](https://img.shields.io/packagist/dt/php-collective/toml?style=flat-square)](https://packagist.org/packages/php-collective/toml) [![PHPStan](https://img.shields.io/badge/PHPStan-level%208-brightgreen.svg?style=flat-square)](https://phpstan.org/) -[![PHP Version](https://img.shields.io/badge/php-%3E%3D8.2-8892BF.svg?style=flat-square)](https://php.net) +[![PHP Version](https://img.shields.io/badge/php-%3E%3D8.1-8892BF.svg?style=flat-square)](https://php.net) [![Software License](https://img.shields.io/badge/license-MIT-green.svg?style=flat-square)](LICENSE) A [TOML](https://toml.io/) (v1.0 and v1.1) parser and encoder for PHP with AST access and collected parse errors. @@ -22,7 +22,7 @@ A [TOML](https://toml.io/) (v1.0 and v1.1) parser and encoder for PHP with AST a ## Requirements -- PHP 8.2 or higher +- PHP 8.1 or higher ## Installation diff --git a/composer.json b/composer.json index fc6adcd..63838b1 100644 --- a/composer.json +++ b/composer.json @@ -18,11 +18,11 @@ ], "homepage": "https://php-collective.github.io/toml/", "require": { - "php": ">=8.2", + "php": ">=8.1", "symfony/polyfill-mbstring": "^1.30" }, "require-dev": { - "phpunit/phpunit": "^11.5 || ^12.5 || ^13.0", + "phpunit/phpunit": "^10.5 || ^11.5 || ^12.5 || ^13.0", "phpstan/phpstan": "^2.0", "php-collective/code-sniffer": "dev-master" }, diff --git a/docs/guide/index.md b/docs/guide/index.md index 6938af9..e297134 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -10,7 +10,7 @@ Install via Composer: composer require php-collective/toml ``` -**Requirements:** PHP 8.2+ +**Requirements:** PHP 8.1+ ## Quick Start diff --git a/docs/index.md b/docs/index.md index 95a3f2e..ba5f218 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ layout: home hero: name: PHP Toml text: TOML 1.0/1.1 Parser - tagline: A modern PHP 8.2+ parser and encoder with AST access, trivia preservation, and error recovery + tagline: A modern PHP 8.1+ parser and encoder with AST access, trivia preservation, and error recovery image: src: /logo.svg alt: PHP Toml @@ -31,7 +31,7 @@ features: details: Full abstract syntax tree for analysis, transformation, or editor integrations - icon: ⚡ title: Zero Dependencies - details: No required extensions - pure PHP 8.2+ with optional php-ds for performance + details: No required extensions - pure PHP 8.1+ with optional php-ds for performance - icon: 🔄 title: Structured Re-Encode details: Parse TOML to an AST, modify nodes, and re-encode with partial trivia and layout preservation diff --git a/phpunit.xml b/phpunit.xml index b7abf16..c85a031 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -5,7 +5,6 @@ colors="true" cacheDirectory=".phpunit.cache" executionOrder="depends,defects" - shortenArraysForExportThreshold="10" displayDetailsOnTestsThatTriggerWarnings="true" displayDetailsOnTestsThatTriggerDeprecations="true" displayDetailsOnIncompleteTests="true" diff --git a/src/Encoder/EncoderOptions.php b/src/Encoder/EncoderOptions.php index 51e3b03..a308efc 100644 --- a/src/Encoder/EncoderOptions.php +++ b/src/Encoder/EncoderOptions.php @@ -6,20 +6,20 @@ use PhpCollective\Toml\TomlVersion; -final readonly class EncoderOptions +final class EncoderOptions { public function __construct( - public bool $sortKeys = false, - public string $newline = "\n", - public DocumentFormattingMode $documentFormatting = DocumentFormattingMode::Normalized, - public bool $skipNulls = false, - public TomlVersion $version = TomlVersion::V10, - public bool $integerGrouping = false, - public bool $trailingComma = false, - public bool $dottedKeys = false, - public ArrayStyle $arrayStyle = ArrayStyle::Inline, - public int $arrayAutoThreshold = 3, - public string $indent = ' ', + public readonly bool $sortKeys = false, + public readonly string $newline = "\n", + public readonly DocumentFormattingMode $documentFormatting = DocumentFormattingMode::Normalized, + public readonly bool $skipNulls = false, + public readonly TomlVersion $version = TomlVersion::V10, + public readonly bool $integerGrouping = false, + public readonly bool $trailingComma = false, + public readonly bool $dottedKeys = false, + public readonly ArrayStyle $arrayStyle = ArrayStyle::Inline, + public readonly int $arrayAutoThreshold = 3, + public readonly string $indent = ' ', ) { } diff --git a/src/Lexer/Span.php b/src/Lexer/Span.php index bc1df5a..de42f03 100644 --- a/src/Lexer/Span.php +++ b/src/Lexer/Span.php @@ -4,13 +4,13 @@ namespace PhpCollective\Toml\Lexer; -final readonly class Span +final class Span { public function __construct( - public int $start, - public int $end, - public int $line, - public int $column, + public readonly int $start, + public readonly int $end, + public readonly int $line, + public readonly int $column, ) { } diff --git a/src/Lexer/Token.php b/src/Lexer/Token.php index ae34a6a..0133285 100644 --- a/src/Lexer/Token.php +++ b/src/Lexer/Token.php @@ -4,13 +4,13 @@ namespace PhpCollective\Toml\Lexer; -final readonly class Token +final class Token { public function __construct( - public TokenType $type, - public string $value, - public mixed $parsed, - public Span $span, + public readonly TokenType $type, + public readonly string $value, + public readonly mixed $parsed, + public readonly Span $span, ) { } diff --git a/src/Parser/ParseError.php b/src/Parser/ParseError.php index e6632a5..3b1e1b8 100644 --- a/src/Parser/ParseError.php +++ b/src/Parser/ParseError.php @@ -6,12 +6,12 @@ use PhpCollective\Toml\Lexer\Span; -final readonly class ParseError +final class ParseError { public function __construct( - public string $message, - public Span $span, - public ?string $hint = null, + public readonly string $message, + public readonly Span $span, + public readonly ?string $hint = null, ) { } diff --git a/src/Parser/ParseResult.php b/src/Parser/ParseResult.php index 7348700..469247c 100644 --- a/src/Parser/ParseResult.php +++ b/src/Parser/ParseResult.php @@ -6,7 +6,7 @@ use PhpCollective\Toml\Ast\Document; -final readonly class ParseResult +final class ParseResult { /** * @param \PhpCollective\Toml\Ast\Document|null $document @@ -14,9 +14,9 @@ * @param array|null $value */ public function __construct( - private ?Document $document, - private array $errors, - private ?array $value = null, + private readonly ?Document $document, + private readonly array $errors, + private readonly ?array $value = null, ) { } diff --git a/src/Value/LocalDate.php b/src/Value/LocalDate.php index bf61351..ea3bcd8 100644 --- a/src/Value/LocalDate.php +++ b/src/Value/LocalDate.php @@ -7,9 +7,9 @@ use InvalidArgumentException; use PhpCollective\Toml\Support\TemporalValidator; -final readonly class LocalDate implements TomlValue +final class LocalDate implements TomlValue { - public function __construct(public string $value) + public function __construct(public readonly string $value) { if (!TemporalValidator::isValidLocalDate($value)) { throw new InvalidArgumentException("Invalid TOML local date: `{$value}`"); diff --git a/src/Value/LocalDateTime.php b/src/Value/LocalDateTime.php index 190107f..f3a5fb4 100644 --- a/src/Value/LocalDateTime.php +++ b/src/Value/LocalDateTime.php @@ -7,9 +7,9 @@ use InvalidArgumentException; use PhpCollective\Toml\Support\TemporalValidator; -final readonly class LocalDateTime implements TomlValue +final class LocalDateTime implements TomlValue { - public function __construct(public string $value) + public function __construct(public readonly string $value) { if (!TemporalValidator::isValidLocalDateTime($value)) { throw new InvalidArgumentException("Invalid TOML local datetime: `{$value}`"); diff --git a/src/Value/LocalTime.php b/src/Value/LocalTime.php index 79a9fd3..8cf2aad 100644 --- a/src/Value/LocalTime.php +++ b/src/Value/LocalTime.php @@ -7,9 +7,9 @@ use InvalidArgumentException; use PhpCollective\Toml\Support\TemporalValidator; -final readonly class LocalTime implements TomlValue +final class LocalTime implements TomlValue { - public function __construct(public string $value) + public function __construct(public readonly string $value) { if (!TemporalValidator::isValidLocalTime($value)) { throw new InvalidArgumentException("Invalid TOML local time: `{$value}`"); From 944200b351e38ca8b2907709bce51b480d7c1028 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sat, 30 May 2026 16:24:52 +0200 Subject: [PATCH 4/5] Add opt-in string encoding styles (#30) --- docs/guide/encoding.md | 18 +++++++++ docs/reference/api.md | 27 +++++++++++++- docs/reference/support-matrix.md | 2 +- src/Encoder/Encoder.php | 63 +++++++++++++++++++++++++++++--- src/Encoder/EncoderOptions.php | 1 + src/Encoder/StringStyle.php | 13 +++++++ tests/Encoder/EncoderTest.php | 58 +++++++++++++++++++++++++++++ 7 files changed, 174 insertions(+), 8 deletions(-) create mode 100644 src/Encoder/StringStyle.php diff --git a/docs/guide/encoding.md b/docs/guide/encoding.md index 373bfdf..ff08be2 100644 --- a/docs/guide/encoding.md +++ b/docs/guide/encoding.md @@ -347,6 +347,24 @@ message = "Hello\nWorld" path = "C:\\Users\\name" ``` +Literal or multiline string output can be enabled explicitly: + +```php +use PhpCollective\Toml\Encoder\StringStyle; + +$toml = Toml::encode([ + 'path' => 'C:\Users\name', +], new EncoderOptions(stringStyle: StringStyle::Literal)); +``` + +Output: + +```toml +path = 'C:\Users\name' +``` + +The default remains basic strings. Literal styles fall back to basic strings when a value cannot be represented safely in the requested style. + ## Re-encoding from AST ```php diff --git a/docs/reference/api.md b/docs/reference/api.md index b2fe5b3..3855163 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -223,6 +223,7 @@ public function __construct( ArrayStyle $arrayStyle = ArrayStyle::Inline, int $arrayAutoThreshold = 3, string $indent = ' ', + StringStyle $stringStyle = StringStyle::Basic, ) ``` @@ -248,13 +249,14 @@ $toml = Toml::encode($data, EncoderOptions::diffFriendly()); | `newline` | `string` | `"\n"` | Newline sequence to use (`"\n"` or `"\r\n"`) | | `documentFormatting` | `DocumentFormattingMode` | `Normalized` | `Normalized` or `SourceAware` for `encodeDocument()` | | `skipNulls` | `bool` | `false` | Omit `null` values instead of throwing `EncodeException` | -| `version` | `TomlVersion` | `V11` | TOML version for output rules | +| `version` | `TomlVersion` | `V10` | TOML version for output rules | | `integerGrouping` | `bool` | `false` | Add underscores to large integers (e.g., `1_000_000`) | | `trailingComma` | `bool` | `false` | Add trailing commas to inline arrays | | `dottedKeys` | `bool` | `false` | Use dotted keys instead of table sections | | `arrayStyle` | `ArrayStyle` | `Inline` | Array formatting style (see below) | | `arrayAutoThreshold` | `int` | `3` | Item count threshold for `ArrayStyle::Auto` | | `indent` | `string` | `' '` | Indentation string for multiline arrays | +| `stringStyle` | `StringStyle` | `Basic` | String formatting style for plain PHP strings | ### ArrayStyle @@ -334,6 +336,29 @@ $toml = Toml::encode( // database.port = 5432 ``` +### StringStyle + +Controls how plain PHP strings are formatted during `encode()`. + +```php +use PhpCollective\Toml\Encoder\StringStyle; +``` + +| Style | Description | +|-------|-------------| +| `StringStyle::Basic` | Basic double-quoted strings. This is the default and preserves current output behavior | +| `StringStyle::Literal` | Single-quoted literal strings when representable; falls back to basic strings when needed | +| `StringStyle::MultiLineBasic` | Multiline basic strings | +| `StringStyle::MultiLineLiteral` | Multiline literal strings when representable; falls back to multiline basic strings when needed | + +```php +$toml = Toml::encode( + ['path' => 'C:\Users\name'], + new EncoderOptions(stringStyle: StringStyle::Literal), +); +// path = 'C:\Users\name' +``` + ### TOML Version Modes Parsing and decoding default to TOML 1.1-compatible input. Encoding defaults to TOML 1.0-compatible output for interoperability and normalizes local times and local datetimes to include seconds where possible. diff --git a/docs/reference/support-matrix.md b/docs/reference/support-matrix.md index bd4bbe7..c719a61 100644 --- a/docs/reference/support-matrix.md +++ b/docs/reference/support-matrix.md @@ -68,7 +68,7 @@ The default parser/decoder behavior is TOML 1.1-compatible. The default encoder | Feature | Status | Notes | |---------|--------|-------| -| Strings | Supported | Encoded as basic strings | +| Strings | Supported | Encoded as basic strings by default; literal and multiline styles are opt-in via `EncoderOptions` | | Integers | Supported | | | Floats | Supported | | | Booleans | Supported | | diff --git a/src/Encoder/Encoder.php b/src/Encoder/Encoder.php index 09e9cca..8ce80a2 100644 --- a/src/Encoder/Encoder.php +++ b/src/Encoder/Encoder.php @@ -22,7 +22,7 @@ use PhpCollective\Toml\Ast\Value\LocalDateTime; use PhpCollective\Toml\Ast\Value\LocalTime; use PhpCollective\Toml\Ast\Value\OffsetDateTime; -use PhpCollective\Toml\Ast\Value\StringStyle; +use PhpCollective\Toml\Ast\Value\StringStyle as AstStringStyle; use PhpCollective\Toml\Ast\Value\StringValue; use PhpCollective\Toml\Ast\Value\Value; use PhpCollective\Toml\Exception\EncodeException; @@ -148,7 +148,7 @@ private function encodeValue(mixed $value): string } if (is_string($value)) { - return $this->encodeString($value); + return $this->encodeStringValue($value); } if ($value instanceof DateTimeInterface) { @@ -314,10 +314,10 @@ private function encodeAstStringValue(StringValue $value): string } return match ($value->style) { - StringStyle::Basic => $this->encodeString($value->value), - StringStyle::Literal => "'" . $value->value . "'", - StringStyle::MultiLineBasic => $this->encodeMultilineBasicString($value->value), - StringStyle::MultiLineLiteral => "'''\n" . $value->value . "'''", + AstStringStyle::Basic => $this->encodeString($value->value), + AstStringStyle::Literal => "'" . $value->value . "'", + AstStringStyle::MultiLineBasic => $this->encodeMultilineBasicString($value->value), + AstStringStyle::MultiLineLiteral => "'''\n" . $value->value . "'''", }; } @@ -1232,6 +1232,57 @@ private function encodeString(string $value): string return '"' . $escaped . '"'; } + private function encodeStringValue(string $value): string + { + return match ($this->options->stringStyle) { + StringStyle::Basic => $this->encodeString($value), + StringStyle::Literal => $this->encodeLiteralString($value), + StringStyle::MultiLineBasic => $this->encodeMultilineBasicString($value), + StringStyle::MultiLineLiteral => $this->encodeMultilineLiteralString($value), + }; + } + + private function encodeLiteralString(string $value): string + { + if (!$this->canUseSingleLineLiteralString($value)) { + return $this->encodeString($value); + } + + return "'" . $value . "'"; + } + + private function encodeMultilineLiteralString(string $value): string + { + if (!$this->canUseMultilineLiteralString($value)) { + return $this->encodeMultilineBasicString($value); + } + + return "'''\n" . $value . "'''"; + } + + private function canUseSingleLineLiteralString(string $value): bool + { + return !str_contains($value, "'") + && !str_contains($value, "\n") + && !str_contains($value, "\r") + && !$this->containsDisallowedControlCharacter($value); + } + + private function canUseMultilineLiteralString(string $value): bool + { + return !str_contains($value, "'''") + && !$this->containsDisallowedControlCharacter($value, allowNewline: true); + } + + private function containsDisallowedControlCharacter(string $value, bool $allowNewline = false): bool + { + if ($allowNewline) { + return preg_match('/[\x00-\x08\x0B-\x1F\x7F]/', $value) === 1; + } + + return preg_match('/[\x00-\x08\x0A-\x1F\x7F]/', $value) === 1; + } + /** * @param array $value */ diff --git a/src/Encoder/EncoderOptions.php b/src/Encoder/EncoderOptions.php index a308efc..0d6da85 100644 --- a/src/Encoder/EncoderOptions.php +++ b/src/Encoder/EncoderOptions.php @@ -20,6 +20,7 @@ public function __construct( public readonly ArrayStyle $arrayStyle = ArrayStyle::Inline, public readonly int $arrayAutoThreshold = 3, public readonly string $indent = ' ', + public readonly StringStyle $stringStyle = StringStyle::Basic, ) { } diff --git a/src/Encoder/StringStyle.php b/src/Encoder/StringStyle.php new file mode 100644 index 0000000..35c71ff --- /dev/null +++ b/src/Encoder/StringStyle.php @@ -0,0 +1,13 @@ +assertStringContainsString('quote = "say \\"hello\\""', $result); } + public function testEncodeStringStyleLiteral(): void + { + $encoder = new Encoder(new EncoderOptions(stringStyle: StringStyle::Literal)); + + $result = $encoder->encode([ + 'path' => 'C:\Users\name', + ]); + + $this->assertStringContainsString("path = 'C:\\Users\\name'", $result); + } + + public function testEncodeStringStyleLiteralFallsBackToBasicWhenNeeded(): void + { + $encoder = new Encoder(new EncoderOptions(stringStyle: StringStyle::Literal)); + + $result = $encoder->encode([ + 'quote' => "it's fine", + 'line' => "hello\nworld", + ]); + + $this->assertStringContainsString('quote = "it\'s fine"', $result); + $this->assertStringContainsString('line = "hello\\nworld"', $result); + } + + public function testEncodeStringStyleMultilineBasic(): void + { + $encoder = new Encoder(new EncoderOptions(stringStyle: StringStyle::MultiLineBasic)); + + $result = $encoder->encode([ + 'message' => "hello\nworld", + ]); + + $this->assertStringContainsString("message = \"\"\"\nhello\nworld\"\"\"", $result); + } + + public function testEncodeStringStyleMultilineLiteral(): void + { + $encoder = new Encoder(new EncoderOptions(stringStyle: StringStyle::MultiLineLiteral)); + + $result = $encoder->encode([ + 'message' => "hello\nworld", + ]); + + $this->assertStringContainsString("message = '''\nhello\nworld'''", $result); + } + + public function testEncodeStringStyleDoesNotChangeKeyStyle(): void + { + $encoder = new Encoder(new EncoderOptions(stringStyle: StringStyle::Literal)); + + $result = $encoder->encode([ + 'key with spaces' => 'value', + ]); + + $this->assertStringContainsString('"key with spaces" = \'value\'', $result); + } + public function testEncodeDateTime(): void { $encoder = new Encoder(new EncoderOptions()); From c04bed5b7b3316602325e743017af81c2af71e7d Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 31 May 2026 07:49:20 +0200 Subject: [PATCH 5/5] CI --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9ee80f..4a408ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,12 +11,12 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: ['8.1', '8.2', '8.4'] + php: ['8.1', '8.5'] name: PHP ${{ matrix.php }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -40,7 +40,7 @@ jobs: name: Coding Standards steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2