diff --git a/.gitattributes b/.gitattributes index 4e57adf..3dd7607 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,5 +2,6 @@ .github export-ignore .gitignore export-ignore phpstan.neon.dist export-ignore +phpstan-baseline.neon export-ignore phpunit.xml.dist export-ignore /tests export-ignore diff --git a/README.md b/README.md index ab4346d..e658f72 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ You can also pass additional flags to `JsonParser::lint/parse` that tweak the fu - `JsonParser::ALLOW_DUPLICATE_KEYS` collects duplicate keys. e.g. if you have two `foo` keys they will end up as `foo` and `foo.2`. - `JsonParser::PARSE_TO_ASSOC` parses to associative arrays instead of stdClass objects. - `JsonParser::ALLOW_COMMENTS` parses while allowing (and ignoring) inline `//` and multiline `/* */` comments in the JSON document. +- `JsonParser::ALLOW_DUPLICATE_KEYS_TO_ARRAY` collects duplicate keys. e.g. if you have two `foo` keys the `foo` key will become an object (or array in assoc mode) with all `foo` values accessible as an array in `$result->foo->__duplicates__` (or `$result['foo']['__duplicates__']` in assoc mode). Example: diff --git a/composer.json b/composer.json index 3f8afc5..dbc48e3 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,7 @@ }, "require-dev": { "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0 || ^8.5.13", - "phpstan/phpstan": "^1.5" + "phpstan/phpstan": "^1.11" }, "autoload": { "psr-4": { "Seld\\JsonLint\\": "src/Seld/JsonLint/" } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..bce1ff3 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,16 @@ +parameters: + ignoreErrors: + - + message: "#^Cannot access offset 1 on array\\|bool\\|float\\|int\\|stdClass\\|string\\|null\\.$#" + count: 1 + path: src/Seld/JsonLint/JsonParser.php + + - + message: "#^Property Seld\\\\JsonLint\\\\JsonParser\\:\\:\\$vstack \\(list\\\\) does not accept non\\-empty\\-array\\, array\\|bool\\|float\\|int\\|stdClass\\|string\\|null\\>\\.$#" + count: 4 + path: src/Seld/JsonLint/JsonParser.php + + - + message: "#^Property Seld\\\\JsonLint\\\\JsonParser\\:\\:\\$vstack \\(list\\\\) does not accept non\\-empty\\-array\\, mixed\\>\\.$#" + count: 1 + path: src/Seld/JsonLint/JsonParser.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index aa3323d..bd3a0ec 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,6 +1,12 @@ +includes: + - phpstan-baseline.neon + - vendor/phpstan/phpstan/conf/bleedingEdge.neon + parameters: level: 8 + treatPhpDocTypesAsCertain: false + paths: - src/ - tests/ diff --git a/src/Seld/JsonLint/JsonParser.php b/src/Seld/JsonLint/JsonParser.php index 420c0fd..cec4a87 100644 --- a/src/Seld/JsonLint/JsonParser.php +++ b/src/Seld/JsonLint/JsonParser.php @@ -31,6 +31,7 @@ class JsonParser const ALLOW_DUPLICATE_KEYS = 2; const PARSE_TO_ASSOC = 4; const ALLOW_COMMENTS = 8; + const ALLOW_DUPLICATE_KEYS_TO_ARRAY = 16; /** @var Lexer */ private $lexer; @@ -201,6 +202,10 @@ public function lint($input, $flags = 0) */ public function parse($input, $flags = 0) { + if (($flags & self::ALLOW_DUPLICATE_KEYS_TO_ARRAY) && ($flags & self::ALLOW_DUPLICATE_KEYS)) { + throw new \InvalidArgumentException('Only one of ALLOW_DUPLICATE_KEYS and ALLOW_DUPLICATE_KEYS_TO_ARRAY can be used, you passed in both.'); + } + $this->failOnBOM($input); $this->flags = $flags; @@ -334,7 +339,7 @@ public function parse($input, $flags = 0) } // this shouldn't happen, unless resolve defaults are off - if (\is_array($action[0]) && \count($action) > 1) { // @phpstan-ignore-line + if (\is_array($action[0]) && \count($action) > 1) { throw new ParsingException('Parse Error: multiple actions possible at state: ' . $state . ', token: ' . $symbol); } @@ -484,14 +489,21 @@ private function performAction($currentToken, $yytext, $yyleng, $yylineno, $yyst $errStr .= $this->lexer->showPosition() . "\n"; $errStr .= "Duplicate key: ".$this->vstack[$len][0]; throw new DuplicateKeyException($errStr, $this->vstack[$len][0], array('line' => $yylineno+1)); - } elseif (($this->flags & self::ALLOW_DUPLICATE_KEYS) && isset($this->vstack[$len-2][$key])) { + } + if (($this->flags & self::ALLOW_DUPLICATE_KEYS) && isset($this->vstack[$len-2][$key])) { $duplicateCount = 1; do { $duplicateKey = $key . '.' . $duplicateCount++; } while (isset($this->vstack[$len-2][$duplicateKey])); - $key = $duplicateKey; + $this->vstack[$len-2][$duplicateKey] = $this->vstack[$len][1]; + } elseif (($this->flags & self::ALLOW_DUPLICATE_KEYS_TO_ARRAY) && isset($this->vstack[$len-2][$key])) { + if (!isset($this->vstack[$len-2][$key]['__duplicates__']) || !is_array($this->vstack[$len-2][$key]['__duplicates__'])) { + $this->vstack[$len-2][$key] = array('__duplicates__' => array($this->vstack[$len-2][$key])); + } + $this->vstack[$len-2][$key]['__duplicates__'][] = $this->vstack[$len][1]; + } else { + $this->vstack[$len-2][$key] = $this->vstack[$len][1]; } - $this->vstack[$len-2][$key] = $this->vstack[$len][1]; } else { assert($this->vstack[$len-2] instanceof stdClass); $token = $this->vstack[$len-2]; @@ -500,19 +512,26 @@ private function performAction($currentToken, $yytext, $yyleng, $yylineno, $yyst } else { $key = $this->vstack[$len][0]; } - if (($this->flags & self::DETECT_KEY_CONFLICTS) && isset($this->vstack[$len-2]->{$key})) { + if (($this->flags & self::DETECT_KEY_CONFLICTS) && isset($this->vstack[$len-2]->$key)) { $errStr = 'Parse error on line ' . ($yylineno+1) . ":\n"; $errStr .= $this->lexer->showPosition() . "\n"; $errStr .= "Duplicate key: ".$this->vstack[$len][0]; throw new DuplicateKeyException($errStr, $this->vstack[$len][0], array('line' => $yylineno+1)); - } elseif (($this->flags & self::ALLOW_DUPLICATE_KEYS) && isset($this->vstack[$len-2]->{$key})) { + } + if (($this->flags & self::ALLOW_DUPLICATE_KEYS) && isset($this->vstack[$len-2]->$key)) { $duplicateCount = 1; do { $duplicateKey = $key . '.' . $duplicateCount++; } while (isset($this->vstack[$len-2]->$duplicateKey)); - $key = $duplicateKey; + $this->vstack[$len-2]->$duplicateKey = $this->vstack[$len][1]; + } elseif (($this->flags & self::ALLOW_DUPLICATE_KEYS_TO_ARRAY) && isset($this->vstack[$len-2]->$key)) { + if (!isset($this->vstack[$len-2]->$key->__duplicates__)) { + $this->vstack[$len-2]->$key = (object) array('__duplicates__' => array($this->vstack[$len-2]->$key)); + } + $this->vstack[$len-2]->$key->__duplicates__[] = $this->vstack[$len][1]; + } else { + $this->vstack[$len-2]->$key = $this->vstack[$len][1]; } - $this->vstack[$len-2]->$key = $this->vstack[$len][1]; } break; case 18: diff --git a/tests/JsonParserTest.php b/tests/JsonParserTest.php index 3eb73c2..9b903bf 100644 --- a/tests/JsonParserTest.php +++ b/tests/JsonParserTest.php @@ -231,7 +231,9 @@ public function testDuplicateKeys() { $parser = new JsonParser(); - $result = $parser->parse('{"a":"b", "a":"c", "a":"d"}', JsonParser::ALLOW_DUPLICATE_KEYS); + $str = '{"a":"b", "a":"c", "a":"d"}'; + + $result = $parser->parse($str, JsonParser::ALLOW_DUPLICATE_KEYS); $this->assertThat($result, $this->logicalAnd( $this->objectHasAttribute('a'), @@ -239,6 +241,24 @@ public function testDuplicateKeys() $this->objectHasAttribute('a.2') ) ); + + $result = $parser->parse($str, JsonParser::ALLOW_DUPLICATE_KEYS | JsonParser::PARSE_TO_ASSOC); + self::assertSame(array('a' => 'b', 'a.1' => 'c', 'a.2' => 'd'), $result); + } + + public function testDuplicateKeysToArray() + { + $parser = new JsonParser(); + + $str = '{"a":"b", "a":"c", "a":"d"}'; + + $result = $parser->parse($str, JsonParser::ALLOW_DUPLICATE_KEYS_TO_ARRAY); + $this->assertThat($result, $this->objectHasAttribute('a')); + $this->assertThat($result->a, $this->objectHasAttribute('__duplicates__')); + self::assertSame(array('b', 'c', 'd'), $result->a->__duplicates__); + + $result = $parser->parse($str, JsonParser::ALLOW_DUPLICATE_KEYS_TO_ARRAY | JsonParser::PARSE_TO_ASSOC); + self::assertSame(array('a' => array('__duplicates__' => array('b', 'c', 'd'))), $result); } public function testDuplicateKeysWithEmpty()