diff --git a/.github/actions/setup-php-composer/action.yml b/.github/actions/setup-php-composer/action.yml index 39cf403..e91d9b1 100644 --- a/.github/actions/setup-php-composer/action.yml +++ b/.github/actions/setup-php-composer/action.yml @@ -5,7 +5,7 @@ inputs: php-version: description: 'PHP version to use' required: false - default: '8.3' + default: '8.4' coverage: description: 'Coverage driver to use (none, xdebug, pcov)' required: false diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index dcc9cc3..15d3a9f 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -25,7 +25,7 @@ jobs: - name: Setup PHP and Composer uses: ./.github/actions/setup-php-composer with: - php-version: 8.3 + php-version: 8.4 # Store a baseline from the PR's base branch on this same runner, so the # comparison is hardware-consistent (baselines are never committed). diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 46873ef..fb741f9 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -14,10 +14,10 @@ jobs: test: runs-on: ${{ matrix.os }} strategy: - fail-fast: true + fail-fast: false matrix: os: [ubuntu-latest, windows-latest] - php: [8.3, 8.4, 8.5] + php: [8.4, 8.5] stability: [prefer-lowest, prefer-stable] name: PHP${{ matrix.php }} - ${{ matrix.stability }} - ${{ matrix.os }} diff --git a/README.md b/README.md index 9065a35..200335d 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Repair invalid JSON strings by automatically fixing common syntax errors like si ## Requirements -- PHP 8.3+ +- PHP 8.4+ - JSON PHP extension ## Installation @@ -28,14 +28,14 @@ use function Cortex\JsonRepair\json_repair_decode; // Broken JSON (single quotes, unquoted keys, trailing comma) $broken = "{'name': 'John', age: 30, active: true,}"; -$repaired = (new JsonRepairer($broken))->repair(); +$repaired = new JsonRepairer($broken)->repair(); // {"name": "John", "age": 30, "active": true} // Or use the helper function $repaired = json_repair($broken); // Repair and decode in one step -$data = (new JsonRepairer($broken))->decode(); +$data = new JsonRepairer($broken)->decode(); // ['name' => 'John', 'age' => 30, 'active' => true] // Or use the helper function diff --git a/composer.json b/composer.json index 7147aef..624c262 100644 --- a/composer.json +++ b/composer.json @@ -17,19 +17,21 @@ } ], "require": { - "php": "^8.3", + "php": "^8.4", "ext-json": "*", "psr/log": "^3.0" }, "require-dev": { "colinodell/psr-testlogger": "^1.3", - "pestphp/pest": "^4.1.4", - "pestphp/pest-plugin-type-coverage": "^4.0.3", + "pestphp/pest": "^5.0", + "pestphp/pest-plugin-phpstan": "^5.0", + "pestphp/pest-plugin-rector": "^5.0", + "pestphp/pest-plugin-type-coverage": "^5.0", "phpbench/phpbench": "^1.4", "phpstan/phpstan": "^2.1.32", "phpstan/phpstan-strict-rules": "^2.0", "rector/rector": "^2.2", - "symplify/easy-coding-standard": "^13.0" + "symplify/easy-coding-standard": "~13.1.0" }, "autoload": { "psr-4": { @@ -45,7 +47,7 @@ } }, "scripts": { - "test": "pest", + "test": "pest --parallel", "benchmark": "phpbench run", "benchmark:base": "phpbench run --tag=base --progress=none", "benchmark:ci": "phpbench run --ref=base --report=aggregate --progress=none --assert=\"mode(variant.time.avg) <= mode(baseline.time.avg) +/- 15%\"", diff --git a/docs/json-repair/configuration.mdx b/docs/json-repair/configuration.mdx index 7b4b6f1..e28df9c 100644 --- a/docs/json-repair/configuration.mdx +++ b/docs/json-repair/configuration.mdx @@ -117,7 +117,7 @@ $repaired = $repairer->repair(); ```php use Cortex\JsonRepair\JsonRepairer; -$result = (new JsonRepairer("{'key': 'value'}"))->repairWithDetails(); +$result = new JsonRepairer("{'key': 'value'}")->repairWithDetails(); // $result->json, $result->wasAlreadyValid, $result->fixes ``` @@ -126,7 +126,7 @@ $result = (new JsonRepairer("{'key': 'value'}"))->repairWithDetails(); `repairAll()` repairs each top-level JSON value separately: ```php -$results = (new JsonRepairer("{'a':1}\n{'b':2}"))->repairAll(); +$results = new JsonRepairer("{'a':1}\n{'b':2}")->repairAll(); // ['{"a": 1}', '{"b": 2}'] ``` @@ -147,7 +147,7 @@ $partial = $stream->current(); // closes incomplete string/braces `decode()` returns `mixed` — top-level scalars (strings, numbers, booleans, null) decode directly without being cast to arrays: ```php -(new JsonRepairer('true'))->decode(); // true +new JsonRepairer('true')->decode(); // true json_repair_decode('null'); // null ``` diff --git a/docs/json-repair/installation.mdx b/docs/json-repair/installation.mdx index 0155794..0f7db0c 100644 --- a/docs/json-repair/installation.mdx +++ b/docs/json-repair/installation.mdx @@ -6,7 +6,7 @@ icon: 'terminal' ## Requirements -- PHP 8.3+ +- PHP 8.4+ - JSON PHP extension - Composer for dependency management diff --git a/docs/json-repair/quickstart.mdx b/docs/json-repair/quickstart.mdx index d762137..2d1bdc8 100644 --- a/docs/json-repair/quickstart.mdx +++ b/docs/json-repair/quickstart.mdx @@ -49,7 +49,7 @@ use Cortex\JsonRepair\JsonRepairer; use Cortex\JsonRepair\Exceptions\JsonRepairException; try { - $repaired = (new JsonRepairer($input))->repair(); + $repaired = new JsonRepairer($input)->repair(); } catch (JsonRepairException $e) { // input could not be repaired into valid JSON } diff --git a/ecs.php b/ecs.php index 49dd243..f3e2bd1 100644 --- a/ecs.php +++ b/ecs.php @@ -33,7 +33,7 @@ cleanCode: true, ) ->withPhpCsFixerSets( - php83Migration: true, + php84Migration: true, ) ->withRules([ NotOperatorWithSuccessorSpaceFixer::class, diff --git a/phpstan.dist.neon b/phpstan.dist.neon index e4fcd01..c6ff3f2 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -1,9 +1,11 @@ includes: - vendor/phpstan/phpstan-strict-rules/rules.neon + - vendor/pestphp/pest-plugin-phpstan/extension.neon parameters: level: 10 paths: - src + - tests tmpDir: .phpstan-cache checkBenevolentUnionTypes: true diff --git a/rector.php b/rector.php index efd5116..8f33a8e 100644 --- a/rector.php +++ b/rector.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Rector\Config\RectorConfig; +use Pest\Rector\Set\PestSetList; return RectorConfig::configure() ->withPaths([ @@ -14,7 +15,12 @@ importDocBlockNames: false, removeUnusedImports: true, ) - ->withPhpSets() + ->withPhpSets( + php84: true, + ) + ->withSets([ + PestSetList::CODING_STYLE, + ]) ->withPreparedSets( deadCode: true, codeQuality: true, diff --git a/src/Concerns/InputSanitization.php b/src/Concerns/InputSanitization.php index 154410a..20b759e 100644 --- a/src/Concerns/InputSanitization.php +++ b/src/Concerns/InputSanitization.php @@ -166,7 +166,7 @@ private function removeComments(string $input): string $this->log('Removing multi-line comment'); $i += 2; - while ($i + 1 < $length && ! ($input[$i] === '*' && $input[$i + 1] === '/')) { + while ($i + 1 < $length && ($input[$i] !== '*' || $input[$i + 1] !== '/')) { $i++; } diff --git a/tests/Pest.php b/tests/Pest.php index e36a9ee..9e2f664 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -4,4 +4,6 @@ namespace Cortex\JsonRepair\Tests; -uses(TestCase::class)->in('Unit'); +pest() + ->extend(TestCase::class) + ->in('Unit'); diff --git a/tests/Unit/ApiUsageTest.php b/tests/Unit/ApiUsageTest.php index fb93f83..c0c67de 100644 --- a/tests/Unit/ApiUsageTest.php +++ b/tests/Unit/ApiUsageTest.php @@ -15,25 +15,32 @@ it('works with JsonRepairer class directly', function (): void { $repairer = new JsonRepairer("{'key': 'value'}"); $result = $repairer->repair(); - expect(json_validate($result))->toBeTrue(); - expect(json_decode($result, true)['key'])->toBe('value'); + expect(json_validate($result))->toBeTrue() + ->and(json_decode($result, true)) + ->toBe([ + 'key' => 'value', + ]); }); it('can decode repaired JSON', function (): void { $repairer = new JsonRepairer("{'key': 'value', 'number': 123}"); $decoded = $repairer->decode(); - expect($decoded)->toBeArray(); - expect($decoded['key'])->toBe('value'); - expect($decoded['number'])->toBe(123); + expect($decoded)->toBeArray() + ->toMatchArray([ + 'key' => 'value', + 'number' => 123, + ]); }); it('can use json_repair_decode helper function', function (): void { $decoded = json_repair_decode("{'key': 'value', 'number': 123}"); - expect($decoded)->toBeArray(); - expect($decoded['key'])->toBe('value'); - expect($decoded['number'])->toBe(123); + expect($decoded)->toBeArray() + ->toMatchArray([ + 'key' => 'value', + 'number' => 123, + ]); }); it('works with JsonRepairer class directly with omitEmptyValues', function (): void { @@ -54,8 +61,8 @@ it('works with json_repair_decode with new options', function (): void { $decoded = json_repair_decode('{"key": }', omitEmptyValues: true); - expect($decoded)->toBeArray(); - expect($decoded)->toBe([]); + expect($decoded)->toBeArray() + ->toBe([]); }); }); @@ -64,17 +71,21 @@ $repairer = new JsonRepairer("{'key': 'value'}"); $repairResult = $repairer->repairWithDetails(); - expect($repairResult->wasAlreadyValid)->toBeFalse(); - expect($repairResult->json)->toBe('{"key": "value"}'); - expect($repairResult->fixes)->toContain('Starting JSON repair'); + expect($repairResult->wasAlreadyValid)->toBeFalse() + ->and($repairResult->json) + ->toBe('{"key": "value"}') + ->and($repairResult->fixes) + ->toContain('Starting JSON repair'); }); it('marks valid JSON as already valid', function (): void { - $repairResult = (new JsonRepairer('{"key": "value"}'))->repairWithDetails(); + $repairResult = new JsonRepairer('{"key": "value"}')->repairWithDetails(); - expect($repairResult->wasAlreadyValid)->toBeTrue(); - expect($repairResult->json)->toBe('{"key": "value"}'); - expect($repairResult->fixes)->toContain('JSON is already valid, returning as-is'); + expect($repairResult->wasAlreadyValid)->toBeTrue() + ->and($repairResult->json) + ->toBe('{"key": "value"}') + ->and($repairResult->fixes) + ->toContain('JSON is already valid, returning as-is'); }); }); @@ -83,39 +94,45 @@ $repairer = new JsonRepairer('{"a":1}{"b":2}'); $results = $repairer->repairAll(); - expect($results)->toHaveCount(2); - expect(json_decode($results[0], true))->toBe([ - 'a' => 1, - ]); - expect(json_decode($results[1], true))->toBe([ - 'b' => 2, - ]); + expect($results)->toHaveCount(2) + ->and(json_decode($results[0], true)) + ->toBe([ + 'a' => 1, + ]) + ->and(json_decode($results[1], true)) + ->toBe([ + 'b' => 2, + ]); }); it('repairs NDJSON lines', function (): void { $repairer = new JsonRepairer("{'a':1}\n{'b':2}"); $results = $repairer->repairAll(); - expect($results)->toHaveCount(2); - expect(json_decode($results[0], true))->toBe([ - 'a' => 1, - ]); - expect(json_decode($results[1], true))->toBe([ - 'b' => 2, - ]); + expect($results)->toHaveCount(2) + ->and(json_decode($results[0], true)) + ->toBe([ + 'a' => 1, + ]) + ->and(json_decode($results[1], true)) + ->toBe([ + 'b' => 2, + ]); }); it('repairs top-level values despite leading stray closing brackets', function (): void { $repairer = new JsonRepairer('}{"a":1}{"b":2}'); $results = $repairer->repairAll(); - expect($results)->toHaveCount(2); - expect(json_decode($results[0], true))->toBe([ - 'a' => 1, - ]); - expect(json_decode($results[1], true))->toBe([ - 'b' => 2, - ]); + expect($results)->toHaveCount(2) + ->and(json_decode($results[0], true)) + ->toBe([ + 'a' => 1, + ]) + ->and(json_decode($results[1], true)) + ->toBe([ + 'b' => 2, + ]); }); }); @@ -142,33 +159,36 @@ $repairer = new JsonRepairer('{a: 1, b: 2, a: 3}', duplicateKeyPolicy: DuplicateKeyPolicy::KeepFirst); $result = $repairer->repair(); - expect(json_validate($result))->toBeTrue(); - expect(json_decode($result, true))->toBe([ - 'a' => 1, - 'b' => 2, - ]); + expect(json_validate($result))->toBeTrue() + ->and(json_decode($result, true)) + ->toBe([ + 'a' => 1, + 'b' => 2, + ]); }); it('keeps last non-adjacent duplicate key without dropping intervening keys', function (): void { $repairer = new JsonRepairer('{a: 1, b: 2, a: 3}', duplicateKeyPolicy: DuplicateKeyPolicy::KeepLast); $result = $repairer->repair(); - expect(json_validate($result))->toBeTrue(); - expect(json_decode($result, true))->toBe([ - 'b' => 2, - 'a' => 3, - ]); + expect(json_validate($result))->toBeTrue() + ->and(json_decode($result, true)) + ->toBe([ + 'b' => 2, + 'a' => 3, + ]); }); it('keeps last across multiple non-adjacent duplicates', function (): void { $repairer = new JsonRepairer('{a: 1, b: 2, a: 3, b: 4}', duplicateKeyPolicy: DuplicateKeyPolicy::KeepLast); $result = $repairer->repair(); - expect(json_validate($result))->toBeTrue(); - expect(json_decode($result, true))->toBe([ - 'a' => 3, - 'b' => 4, - ]); + expect(json_validate($result))->toBeTrue() + ->and(json_decode($result, true)) + ->toBe([ + 'a' => 3, + 'b' => 4, + ]); }); it('does not corrupt large integers when keep-last is enabled', function (): void { @@ -178,9 +198,9 @@ ); $result = $repairer->repair(); - expect(json_validate($result))->toBeTrue(); - expect($result)->toContain('12345678901234567890'); - expect($result)->not->toContain('e+'); + expect(json_validate($result))->toBeTrue() + ->and($result) + ->toContain('12345678901234567890')->not->toContain('e+'); }); it('preserves large integers while still deduplicating with keep-last', function (): void { @@ -190,12 +210,13 @@ ); $result = $repairer->repair(); - expect(json_validate($result))->toBeTrue(); - expect($result)->toContain('12345678901234567890'); - expect($result)->not->toContain('e+'); - - $decoded = json_decode($result, true); - expect($decoded['a'])->toBe(2); + expect(json_validate($result))->toBeTrue() + ->and($result) + ->toContain('12345678901234567890')->not->toContain('e+') + ->and(json_decode($result, true)) + ->toMatchArray([ + 'a' => 2, + ]); }); it('keeps first when skipped duplicate value is a structure containing brackets in strings', function (): void { @@ -205,11 +226,12 @@ ); $result = $repairer->repair(); - expect(json_validate($result))->toBeTrue(); - expect(json_decode($result, true))->toBe([ - 'a' => 1, - 'z' => 9, - ]); + expect(json_validate($result))->toBeTrue() + ->and(json_decode($result, true)) + ->toBe([ + 'a' => 1, + 'z' => 9, + ]); }); it( @@ -221,11 +243,12 @@ function (): void { ); $result = $repairer->repair(); - expect(json_validate($result))->toBeTrue(); - expect(json_decode($result, true))->toBe([ - 'a' => 1, - 'z' => 9, - ]); + expect(json_validate($result))->toBeTrue() + ->and(json_decode($result, true)) + ->toBe([ + 'a' => 1, + 'z' => 9, + ]); }, ); }); @@ -241,6 +264,6 @@ function (): void { }); it('decodes top-level string scalars', function (): void { - expect((new JsonRepairer('"hello"'))->decode())->toBe('hello'); + expect(new JsonRepairer('"hello"')->decode())->toBe('hello'); }); }); diff --git a/tests/Unit/EdgeCasesTest.php b/tests/Unit/EdgeCasesTest.php index 3960a08..0e3dab3 100644 --- a/tests/Unit/EdgeCasesTest.php +++ b/tests/Unit/EdgeCasesTest.php @@ -13,8 +13,9 @@ describe('Edge cases and special features', function (): void { it('handles incomplete JSON at end of string', function (string $input, string $expected): void { $result = json_repair($input); - expect(json_validate($result))->toBeTrue(); - expect($result)->toBe($expected); + expect(json_validate($result))->toBeTrue() + ->and($result) + ->toBe($expected); $decoded = json_decode($result, true); expect($decoded)->toBe(json_decode($expected, true)); @@ -22,8 +23,9 @@ it('repairs incomplete JSON from streaming LLM responses', function (string $input, string $expected): void { $result = json_repair($input); - expect(json_validate($result))->toBeTrue(); - expect($result)->toBe($expected); + expect(json_validate($result))->toBeTrue() + ->and($result) + ->toBe($expected); $decoded = json_decode($result, true); expect($decoded)->toBe(json_decode($expected, true)); @@ -32,24 +34,37 @@ it('handles complex nested structures', function (): void { $input = '{"resourceType": "Bundle", "id": "1", "type": "collection", "entry": [{"resource": {"resourceType": "Patient", "id": "1", "name": [{"use": "official", "family": "Corwin", "given": ["Keisha", "Sunny"], "prefix": ["Mrs."}, {"use": "maiden", "family": "Goodwin", "given": ["Keisha", "Sunny"], "prefix": ["Mrs."]}]}}]}'; $result = json_repair($input); - expect(json_validate($result))->toBeTrue(); - - $decoded = json_decode($result, true); - expect($decoded)->toBeArray(); - expect($decoded['resourceType'])->toBe('Bundle'); - expect($decoded['id'])->toBe('1'); - expect($decoded['type'])->toBe('collection'); - expect($decoded['entry'])->toBeArray(); - expect($decoded['entry'][0]['resource']['resourceType'])->toBe('Patient'); - expect($decoded['entry'][0]['resource']['name'])->toBeArray(); - expect($decoded['entry'][0]['resource']['name'])->toHaveCount(1); - expect($decoded['entry'][0]['resource']['name'][0]['use'])->toBe('official'); - expect($decoded['entry'][0]['resource']['name'][0]['family'])->toBe('Corwin'); - expect($decoded['entry'][0]['resource']['name'][0]['given'])->toBe(['Keisha', 'Sunny']); - expect($decoded['entry'][0]['resource']['name'][0]['prefix'][0])->toBe('Mrs.'); - expect($decoded['entry'][0]['resource']['name'][0]['prefix'][1])->toBeArray(); - expect($decoded['entry'][0]['resource']['name'][0]['prefix'][1]['use'])->toBe('maiden'); - expect($decoded['entry'][0]['resource']['name'][0]['prefix'][1]['family'])->toBe('Goodwin'); + expect(json_validate($result))->toBeTrue() + ->and(json_decode($result, true)) + ->toBe([ + 'resourceType' => 'Bundle', + 'id' => '1', + 'type' => 'collection', + 'entry' => [ + [ + 'resource' => [ + 'resourceType' => 'Patient', + 'id' => '1', + 'name' => [ + [ + 'use' => 'official', + 'family' => 'Corwin', + 'given' => ['Keisha', 'Sunny'], + 'prefix' => [ + 'Mrs.', + [ + 'use' => 'maiden', + 'family' => 'Goodwin', + 'given' => ['Keisha', 'Sunny'], + 'prefix' => ['Mrs.'], + ], + ], + ], + ], + ], + ], + ], + ]); }); it('handles multiple JSON objects', function (string $input, ?string $expectedKey, ?string $expectedValue): void { @@ -58,11 +73,14 @@ if ($expectedKey !== null) { $decoded = json_decode($result, true); + expect($decoded)->toBeArray(); + + if (! is_array($decoded)) { + return; + } if ($expectedValue !== null) { expect($decoded[$expectedKey])->toBe($expectedValue); - } else { - expect($decoded)->toBeArray(); } } })->with('multiple_json_objects'); @@ -74,15 +92,23 @@ function (string $input, ?string $expectedKey, ?string $expectedValue): void { expect(json_validate($result))->toBeTrue(); if ($expectedKey !== null) { - expect(json_decode($result, true)[$expectedKey])->toBe($expectedValue); + $decoded = json_decode($result, true); + expect($decoded)->toBeArray(); + + if (! is_array($decoded)) { + return; + } + + expect($decoded[$expectedKey])->toBe($expectedValue); } }, )->with('markdown_code_blocks'); it('handles markdown links in strings', function (string $input, string $expected): void { $result = json_repair($input); - expect(json_validate($result))->toBeTrue(); - expect($result)->toBe($expected); + expect(json_validate($result))->toBeTrue() + ->and($result) + ->toBe($expected); $decoded = json_decode($result, true); expect($decoded)->toBe(json_decode($expected, true)); @@ -90,8 +116,9 @@ function (string $input, ?string $expectedKey, ?string $expectedValue): void { it('handles leading and trailing characters', function (string $input, string $expected): void { $result = json_repair($input); - expect(json_validate($result))->toBeTrue(); - expect($result)->toBe($expected); + expect(json_validate($result))->toBeTrue() + ->and($result) + ->toBe($expected); $decoded = json_decode($result, true); expect($decoded)->toBe(json_decode($expected, true)); @@ -99,8 +126,9 @@ function (string $input, ?string $expectedKey, ?string $expectedValue): void { it('handles JSON code blocks inside string values', function (string $input, string $expected): void { $result = json_repair($input); - expect(json_validate($result))->toBeTrue(); - expect($result)->toBe($expected); + expect(json_validate($result))->toBeTrue() + ->and($result) + ->toBe($expected); $decoded = json_decode($result, true); expect($decoded)->toBe(json_decode($expected, true)); @@ -119,7 +147,8 @@ function (string $input, ?string $expectedKey, ?string $expectedValue): void { it('removes comments', function (string $input, string $expected): void { $result = json_repair($input); - expect(json_validate($result))->toBeTrue(); - expect($result)->toBe($expected); + expect(json_validate($result))->toBeTrue() + ->and($result) + ->toBe($expected); })->with('comments'); }); diff --git a/tests/Unit/JsonRepairsTest.php b/tests/Unit/JsonRepairsTest.php index 6d3fd07..cc5ee57 100644 --- a/tests/Unit/JsonRepairsTest.php +++ b/tests/Unit/JsonRepairsTest.php @@ -13,8 +13,9 @@ describe('JSON repairs', function (): void { it('passes through valid JSON unchanged', function (string $json): void { $result = json_repair($json); - expect(json_validate($result))->toBeTrue(); - expect(json_decode($result, true))->toBe(json_decode($json, true)); + expect(json_validate($result))->toBeTrue() + ->and(json_decode($result, true)) + ->toBe(json_decode($json, true)); })->with('valid_json'); it('handles non-JSON strings', function (string $input, string $expected): void { @@ -22,15 +23,17 @@ expect($result)->toBe($expected); if ($result !== '') { - expect(json_validate($result))->toBeTrue(); - expect(json_decode($result, true))->toBe([]); + expect(json_validate($result))->toBeTrue() + ->and(json_decode($result, true)) + ->toBe([]); } })->with('parse_string'); it('repairs single quotes to double quotes', function (string $input, string $expected): void { $result = json_repair($input); - expect(json_validate($result))->toBeTrue(); - expect($result)->toBe($expected); + expect(json_validate($result))->toBeTrue() + ->and($result) + ->toBe($expected); $decoded = json_decode($result, true); expect($decoded)->toBe(json_decode($expected, true)); @@ -38,8 +41,9 @@ it('repairs unquoted keys', function (string $input, string $expected): void { $result = json_repair($input); - expect(json_validate($result))->toBeTrue(); - expect($result)->toBe($expected); + expect(json_validate($result))->toBeTrue() + ->and($result) + ->toBe($expected); $decoded = json_decode($result, true); expect($decoded)->toBe(json_decode($expected, true)); @@ -47,14 +51,18 @@ it('repairs missing quotes around keys', function (): void { $result = json_repair('{key: "value"}'); - expect(json_validate($result))->toBeTrue(); - expect(json_decode($result, true)['key'])->toBe('value'); + expect(json_validate($result))->toBeTrue() + ->and(json_decode($result, true)) + ->toBe([ + 'key' => 'value', + ]); }); it('handles mixed single and double quotes', function (string $input, string $expected): void { $result = json_repair($input); - expect(json_validate($result))->toBeTrue(); - expect($result)->toBe($expected); + expect(json_validate($result))->toBeTrue() + ->and($result) + ->toBe($expected); $decoded = json_decode($result, true); expect($decoded)->toBe(json_decode($expected, true)); @@ -62,14 +70,16 @@ it('handles quotes inside string values', function (string $input, string $expected): void { $result = json_repair($input); - expect(json_validate($result))->toBeTrue(); - expect($result)->toBe($expected); + expect(json_validate($result))->toBeTrue() + ->and($result) + ->toBe($expected); })->with('quotes_inside_strings'); it('repairs trailing commas', function (string $input, string $expected): void { $result = json_repair($input); - expect(json_validate($result))->toBeTrue(); - expect($result)->toBe($expected); + expect(json_validate($result))->toBeTrue() + ->and($result) + ->toBe($expected); $decoded = json_decode($result, true); expect($decoded)->toBe(json_decode($expected, true)); @@ -77,8 +87,9 @@ it('repairs missing commas', function (string $input, string $expected): void { $result = json_repair($input); - expect(json_validate($result))->toBeTrue(); - expect($result)->toBe($expected); + expect(json_validate($result))->toBeTrue() + ->and($result) + ->toBe($expected); $decoded = json_decode($result, true); expect($decoded)->toBe(json_decode($expected, true)); @@ -86,14 +97,18 @@ it('repairs missing colons', function (): void { $result = json_repair('{"key" "value"}'); - expect(json_validate($result))->toBeTrue(); - expect(json_decode($result, true)['key'])->toBe('value'); + expect(json_validate($result))->toBeTrue() + ->and(json_decode($result, true)) + ->toBe([ + 'key' => 'value', + ]); }); it('repairs missing closing brackets', function (string $input, string $expected): void { $result = json_repair($input); - expect(json_validate($result))->toBeTrue(); - expect($result)->toBe($expected); + expect(json_validate($result))->toBeTrue() + ->and($result) + ->toBe($expected); $decoded = json_decode($result, true); expect($decoded)->toBe(json_decode($expected, true)); @@ -103,9 +118,20 @@ $result = json_repair($input); expect(json_validate($result))->toBeTrue(); $decoded = json_decode($result, true); + expect($decoded)->toBeArray(); + + if (! is_array($decoded)) { + return; + } $value = $decoded; foreach (explode('.', $expectedPath) as $key) { + expect($value)->toBeArray()->toHaveKey($key); + + if (! is_array($value)) { + return; + } + $value = $value[$key]; } @@ -114,8 +140,9 @@ it('repairs missing values', function (string $input, string $expected): void { $result = json_repair($input); - expect(json_validate($result))->toBeTrue(); - expect($result)->toBe($expected); + expect(json_validate($result))->toBeTrue() + ->and($result) + ->toBe($expected); $decoded = json_decode($result, true); expect($decoded)->toBe(json_decode($expected, true)); @@ -124,10 +151,10 @@ it('handles missing keys in objects', function (): void { $input = '{: "value"}'; $result = json_repair($input); - expect(json_validate($result))->toBeTrue(); - $decoded = json_decode($result, true); - expect($decoded)->toBeArray(); - expect($decoded)->toHaveKey('value'); - expect($decoded['value'])->toBe(''); + expect(json_validate($result))->toBeTrue() + ->and(json_decode($result, true)) + ->toBe([ + 'value' => '', + ]); }); }); diff --git a/tests/Unit/LoggingTest.php b/tests/Unit/LoggingTest.php index 6cdefd4..b8d161b 100644 --- a/tests/Unit/LoggingTest.php +++ b/tests/Unit/LoggingTest.php @@ -17,9 +17,11 @@ $result = json_repair('{"key": "value"}', logger: $logger); - expect($logger->hasDebug('JSON is already valid, returning as-is'))->toBeTrue(); - expect($logger->records)->toHaveCount(1); - expect($result)->toBe('{"key": "value"}'); + expect($logger->hasDebug('JSON is already valid, returning as-is'))->toBeTrue() + ->and($logger->records) + ->toHaveCount(1) + ->and($result) + ->toBe('{"key": "value"}'); }); it('logs repair actions for unclosed strings and brackets', function (): void { @@ -27,12 +29,15 @@ $result = json_repair('{"key": "value', logger: $logger); - expect($logger->hasDebug('Starting JSON repair'))->toBeTrue(); - expect($logger->hasDebug('Adding missing closing quote for unclosed string'))->toBeTrue(); - expect($logger->hasDebug('Adding missing closing bracket/brace'))->toBeTrue(); - - expect(json_validate($result))->toBeTrue(); - expect($result)->toBe('{"key": "value"}'); + expect($logger->hasDebug('Starting JSON repair'))->toBeTrue() + ->and($logger->hasDebug('Adding missing closing quote for unclosed string')) + ->toBeTrue() + ->and($logger->hasDebug('Adding missing closing bracket/brace')) + ->toBeTrue() + ->and(json_validate($result)) + ->toBeTrue() + ->and($result) + ->toBe('{"key": "value"}'); }); it('logs quote conversions and boolean normalization', function (): void { @@ -40,14 +45,25 @@ $result = json_repair("{'active': True}", logger: $logger); - expect($logger->hasDebug('Converting single-quoted key to double quotes'))->toBeTrue(); - expect($logger->hasDebugThatPasses( - fn(array $record): bool => $record['message'] === 'Normalizing boolean/null value' - && $record['context']['from'] === 'True' - && $record['context']['to'] === 'true', - ))->toBeTrue(); - - expect($result)->toBe('{"active": true}'); + expect($logger->hasDebug('Converting single-quoted key to double quotes'))->toBeTrue() + ->and( + $logger->hasDebugThatPasses( + function (array $record): bool { + if ($record['message'] !== 'Normalizing boolean/null value') { + return false; + } + + $context = $record['context'] ?? null; + + return is_array($context) + && ($context['from'] ?? null) === 'True' + && ($context['to'] ?? null) === 'true'; + }, + ), + ) + ->toBeTrue() + ->and($result) + ->toBe('{"active": true}'); }); it('logs unquoted key and value repairs', function (): void { @@ -55,10 +71,11 @@ $result = json_repair('{name: John}', logger: $logger); - expect($logger->hasDebug('Adding quotes around unquoted key'))->toBeTrue(); - expect($logger->hasDebug('Found unquoted string value, adding quotes'))->toBeTrue(); - - expect($result)->toBe('{"name": "John"}'); + expect($logger->hasDebug('Adding quotes around unquoted key'))->toBeTrue() + ->and($logger->hasDebug('Found unquoted string value, adding quotes')) + ->toBeTrue() + ->and($result) + ->toBe('{"name": "John"}'); }); it('logs missing comma and colon insertions', function (): void { @@ -66,10 +83,11 @@ $result = json_repair('{"a": 1 "b" 2}', logger: $logger); - expect($logger->hasDebug('Inserting missing comma'))->toBeTrue(); - expect($logger->hasDebug('Inserting missing colon after key'))->toBeTrue(); - - expect(json_validate($result))->toBeTrue(); + expect($logger->hasDebug('Inserting missing comma'))->toBeTrue() + ->and($logger->hasDebug('Inserting missing colon after key')) + ->toBeTrue() + ->and(json_validate($result)) + ->toBeTrue(); }); it('logs context with position information', function (): void { @@ -79,9 +97,14 @@ // Verify that log entries include position and context expect($logger->hasDebugThatPasses( - fn(array $record): bool => isset($record['context']['position']) - && isset($record['context']['context']) - && str_contains((string) $record['context']['context'], '>>>'), + function (array $record): bool { + $context = $record['context'] ?? null; + + return is_array($context) + && isset($context['position']) + && is_string($context['context'] ?? null) + && str_contains($context['context'], '>>>'); + }, ))->toBeTrue(); }); @@ -90,8 +113,9 @@ $result = json_repair('```json {"key": "value"} ```', logger: $logger); - expect($logger->hasDebug('Extracted JSON from markdown code block'))->toBeTrue(); - expect($result)->toBe('{"key": "value"}'); + expect($logger->hasDebug('Extracted JSON from markdown code block'))->toBeTrue() + ->and($result) + ->toBe('{"key": "value"}'); }); it('logs omitEmptyValues actions', function (): void { @@ -99,8 +123,9 @@ $result = json_repair('{"a": 1, "b": }', omitEmptyValues: true, logger: $logger); - expect($logger->hasDebug('Removing key with missing value (omitEmptyValues enabled)'))->toBeTrue(); - expect($result)->toBe('{"a": 1}'); + expect($logger->hasDebug('Removing key with missing value (omitEmptyValues enabled)'))->toBeTrue() + ->and($result) + ->toBe('{"a": 1}'); }); it('works with JsonRepairer class and setLogger', function (): void { @@ -111,7 +136,8 @@ $result = $repairer->repair(); - expect($logger->hasDebugRecords())->toBeTrue(); - expect($result)->toBe('{"key": "value"}'); + expect($logger->hasDebugRecords())->toBeTrue() + ->and($result) + ->toBe('{"key": "value"}'); }); }); diff --git a/tests/Unit/OptionsTest.php b/tests/Unit/OptionsTest.php index 8400802..8467787 100644 --- a/tests/Unit/OptionsTest.php +++ b/tests/Unit/OptionsTest.php @@ -14,8 +14,9 @@ describe('omitEmptyValues', function (): void { it('omits empty values when omitEmptyValues is true', function (string $input, string $expected): void { $result = json_repair($input, omitEmptyValues: true); - expect(json_validate($result))->toBeTrue(); - expect($result)->toBe($expected); + expect(json_validate($result))->toBeTrue() + ->and($result) + ->toBe($expected); $decoded = json_decode($result, true); expect($decoded)->toBe(json_decode($expected, true)); @@ -23,8 +24,9 @@ it('keeps empty values when omitEmptyValues is false', function (string $input, string $expected): void { $result = json_repair($input, omitEmptyValues: false); - expect(json_validate($result))->toBeTrue(); - expect($result)->toBe($expected); + expect(json_validate($result))->toBeTrue() + ->and($result) + ->toBe($expected); $decoded = json_decode($result, true); expect($decoded)->toBe(json_decode($expected, true)); @@ -34,8 +36,9 @@ $input = '{"user": {"name": "John", "age": }, "meta": {"count": }}'; $expected = '{"user": {"name": "John"}, "meta": {}}'; $result = json_repair($input, omitEmptyValues: true); - expect(json_validate($result))->toBeTrue(); - expect($result)->toBe($expected); + expect(json_validate($result))->toBeTrue() + ->and($result) + ->toBe($expected); $decoded = json_decode($result, true); expect($decoded)->toBe([ @@ -50,8 +53,9 @@ $input = '{"key": }'; $expected = '{}'; $result = json_repair($input, omitEmptyValues: true); - expect(json_validate($result))->toBeTrue(); - expect($result)->toBe($expected); + expect(json_validate($result))->toBeTrue() + ->and($result) + ->toBe($expected); $decoded = json_decode($result, true); expect($decoded)->toBe([]); @@ -63,8 +67,9 @@ 'omits incomplete strings when omitIncompleteStrings is true', function (string $input, string $expected): void { $result = json_repair($input, omitIncompleteStrings: true); - expect(json_validate($result))->toBeTrue(); - expect($result)->toBe($expected); + expect(json_validate($result))->toBeTrue() + ->and($result) + ->toBe($expected); $decoded = json_decode($result, true); expect($decoded)->toBe(json_decode($expected, true)); @@ -75,8 +80,9 @@ function (string $input, string $expected): void { 'keeps incomplete strings when omitIncompleteStrings is false', function (string $input, string $expected): void { $result = json_repair($input, omitIncompleteStrings: false); - expect(json_validate($result))->toBeTrue(); - expect($result)->toBe($expected); + expect(json_validate($result))->toBeTrue() + ->and($result) + ->toBe($expected); $decoded = json_decode($result, true); expect($decoded)->toBe(json_decode($expected, true)); @@ -87,8 +93,9 @@ function (string $input, string $expected): void { $input = '{"key": "val'; $expected = '{}'; $result = json_repair($input, omitIncompleteStrings: true); - expect(json_validate($result))->toBeTrue(); - expect($result)->toBe($expected); + expect(json_validate($result))->toBeTrue() + ->and($result) + ->toBe($expected); $decoded = json_decode($result, true); expect($decoded)->toBe([]); @@ -100,8 +107,9 @@ function (string $input, string $expected): void { 'handles both omitEmptyValues and omitIncompleteStrings together', function (string $input, string $expected): void { $result = json_repair($input, omitEmptyValues: true, omitIncompleteStrings: true); - expect(json_validate($result))->toBeTrue(); - expect($result)->toBe($expected); + expect(json_validate($result))->toBeTrue() + ->and($result) + ->toBe($expected); $decoded = json_decode($result, true); expect($decoded)->toBe(json_decode($expected, true)); diff --git a/tests/Unit/StreamingJsonRepairerTest.php b/tests/Unit/StreamingJsonRepairerTest.php index 7f83675..e679660 100644 --- a/tests/Unit/StreamingJsonRepairerTest.php +++ b/tests/Unit/StreamingJsonRepairerTest.php @@ -16,9 +16,10 @@ $result = $stream->current(); - expect(json_validate($result))->toBeTrue(); - expect(json_decode($result, true))->toBe([ - 'key' => 'val', - ]); + expect(json_validate($result))->toBeTrue() + ->and(json_decode($result, true)) + ->toBe([ + 'key' => 'val', + ]); }); }); diff --git a/tests/Unit/ValuesAndStructuresTest.php b/tests/Unit/ValuesAndStructuresTest.php index 687cdeb..1973b35 100644 --- a/tests/Unit/ValuesAndStructuresTest.php +++ b/tests/Unit/ValuesAndStructuresTest.php @@ -13,8 +13,9 @@ describe('Values and structures', function (): void { it('repairs non-standard booleans and null', function (string $input, string $expected): void { $result = json_repair($input); - expect(json_validate($result))->toBeTrue(); - expect($result)->toBe($expected); + expect(json_validate($result))->toBeTrue() + ->and($result) + ->toBe($expected); $decoded = json_decode($result, true); expect($decoded)->toBe(json_decode($expected, true)); @@ -37,14 +38,20 @@ return; } - expect(json_decode($result, true)['key'])->toBe($expected); + expect(json_decode($result, true))->toMatchArray([ + 'key' => $expected, + ]); })->with('numbers'); it('repairs invalid numbers', function (string $input, string $expectedJson, int|float $expected): void { $result = json_repair($input); - expect(json_validate($result))->toBeTrue(); - expect($result)->toBe($expectedJson); - expect(json_decode($result, true)['key'])->toBe($expected); + expect(json_validate($result))->toBeTrue() + ->and($result) + ->toBe($expectedJson) + ->and(json_decode($result, true)) + ->toMatchArray([ + 'key' => $expected, + ]); })->with('invalid_numbers'); it('does not treat a lone sign or dot as a number', function (string $input): void { @@ -62,18 +69,30 @@ ]); it('still parses valid leading-dot and signed numbers', function (): void { - expect(json_decode(json_repair('{"key": .5}'), true)['key'])->toBe(0.5); - expect(json_decode(json_repair('{"key": -.5}'), true)['key'])->toBe(-0.5); - expect(json_decode(json_repair('{"key": +.5}'), true)['key'])->toBe(0.5); - expect(json_decode(json_repair('[1, 2.5, 3]'), true))->toBe([1, 2.5, 3]); + expect(json_decode(json_repair('{"key": .5}'), true))->toBe([ + 'key' => 0.5, + ]) + ->and(json_decode(json_repair('{"key": -.5}'), true)) + ->toBe([ + 'key' => -0.5, + ]) + ->and(json_decode(json_repair('{"key": +.5}'), true)) + ->toBe([ + 'key' => 0.5, + ]) + ->and(json_decode(json_repair('[1, 2.5, 3]'), true)) + ->toBe([1, 2.5, 3]); }); it( 'drops an incomplete exponent without removing the whole number', function (string $input, int|float $expected): void { $result = json_repair($input); - expect(json_validate($result))->toBeTrue(); - expect(json_decode($result, true)['key'])->toBe($expected); + expect(json_validate($result))->toBeTrue() + ->and(json_decode($result, true)) + ->toMatchArray([ + 'key' => $expected, + ]); }, )->with([ 'plus exponent no digits' => ['{"key": 1e+}', 1], @@ -84,38 +103,47 @@ function (string $input, int|float $expected): void { it('drops an incomplete exponent inside an array', function (): void { $result = json_repair('[1e+]'); - expect(json_validate($result))->toBeTrue(); - expect(json_decode($result, true))->toBe([1]); + expect(json_validate($result))->toBeTrue() + ->and(json_decode($result, true)) + ->toBe([1]); }); it('preserves complete signed exponents', function (): void { - expect(json_decode(json_repair('{"key": 1e+5}'), true)['key'])->toBe(100000.0); - expect(json_decode(json_repair('{"key": 1.5e-3}'), true)['key'])->toBe(0.0015); + expect(json_decode(json_repair('{"key": 1e+5}'), true))->toBe([ + 'key' => 100000.0, + ]) + ->and(json_decode(json_repair('{"key": 1.5e-3}'), true)) + ->toBe([ + 'key' => 0.0015, + ]); }); it( 'handles strings with special characters', function (string $input, string $expectedKey, string $expectedValue): void { $result = json_repair($input); - expect(json_validate($result))->toBeTrue(); - $decoded = json_decode($result, true); - expect($decoded[$expectedKey])->toBe($expectedValue); + expect(json_validate($result))->toBeTrue() + ->and(json_decode($result, true)) + ->toMatchArray([ + $expectedKey => $expectedValue, + ]); }, )->with('special_characters'); it('handles escape sequences', function (string $input, string $expectedValue): void { $result = json_repair($input); - expect(json_validate($result))->toBeTrue(); - $decoded = json_decode($result, true); - expect($decoded)->toBeArray(); - expect($decoded)->toHaveKey('key'); - expect($decoded['key'])->toBe($expectedValue); + expect(json_validate($result))->toBeTrue() + ->and(json_decode($result, true)) + ->toBe([ + 'key' => $expectedValue, + ]); })->with('escape_sequences'); it('handles advanced escaping cases', function (string $input, string $expected): void { $result = json_repair($input); - expect(json_validate($result))->toBeTrue(); - expect($result)->toBe($expected); + expect(json_validate($result))->toBeTrue() + ->and($result) + ->toBe($expected); $decoded = json_decode($result, true); expect($decoded)->toBe(json_decode($expected, true)); @@ -123,29 +151,33 @@ function (string $input, string $expectedKey, string $expectedValue): void { it('escapes unicode characters when ensureAscii is true', function (): void { $result = json_repair("{'city':'上海'}"); - expect(json_validate($result))->toBeTrue(); - expect($result)->toBe('{"city":"\\u4e0a\\u6d77"}'); - - $decoded = json_decode($result, true); - expect($decoded['city'])->toBe('上海'); + expect(json_validate($result))->toBeTrue() + ->and($result) + ->toBe('{"city":"\u4e0a\u6d77"}') + ->and(json_decode($result, true)) + ->toBe([ + 'city' => '上海', + ]); }); it('handles unicode characters when ensureAscii is false', function (): void { $input = "{'test_中国人_ascii':'统一码'}"; $result = json_repair($input, ensureAscii: false); - expect(json_validate($result))->toBeTrue(); - expect($result)->toContain('统一码'); - expect($result)->toContain('test_中国人_ascii'); - - $decoded = json_decode($result, true); - expect($decoded)->toHaveKey('test_中国人_ascii'); - expect($decoded['test_中国人_ascii'])->toBe('统一码'); + expect(json_validate($result))->toBeTrue() + ->and($result) + ->toContain('统一码') + ->toContain('test_中国人_ascii') + ->and(json_decode($result, true)) + ->toBe([ + 'test_中国人_ascii' => '统一码', + ]); }); it('handles empty strings as values', function (string $input, string $expected): void { $result = json_repair($input); - expect(json_validate($result))->toBeTrue(); - expect($result)->toBe($expected); + expect(json_validate($result))->toBeTrue() + ->and($result) + ->toBe($expected); $decoded = json_decode($result, true); expect($decoded)->toBe(json_decode($expected, true)); @@ -153,14 +185,16 @@ function (string $input, string $expectedKey, string $expectedValue): void { it('handles nested structures', function (string $input): void { $result = json_repair($input); - expect(json_validate($result))->toBeTrue(); - expect(json_decode($result, true))->toBe(json_decode($input, true)); + expect(json_validate($result))->toBeTrue() + ->and(json_decode($result, true)) + ->toBe(json_decode($input, true)); })->with('nested_structures'); it('handles empty structures', function (string $input, string $expected): void { $result = json_repair($input); - expect(json_validate($result))->toBeTrue(); - expect($result)->toBe($expected); + expect(json_validate($result))->toBeTrue() + ->and($result) + ->toBe($expected); $decoded = json_decode($result, true); expect($decoded)->toBe(json_decode($expected, true)); @@ -168,8 +202,9 @@ function (string $input, string $expectedKey, string $expectedValue): void { it('handles arrays with mixed types', function (string $input, string $expected): void { $result = json_repair($input); - expect(json_validate($result))->toBeTrue(); - expect($result)->toBe($expected); + expect(json_validate($result))->toBeTrue() + ->and($result) + ->toBe($expected); $decoded = json_decode($result, true); expect($decoded)->toBe(json_decode($expected, true));