From a7eda41d15c0cfc3a5e5525af66465a784586f40 Mon Sep 17 00:00:00 2001 From: Zsolt Parragi Date: Wed, 8 Jul 2026 22:00:14 +0000 Subject: [PATCH 2/8] Add JSON5 trailing comma support to the JSON parser When the json5 flag is set, allow one trailing comma before a closing ] or }, in both the recursive-descent and incremental parsers. --- src/common/jsonapi.c | 50 ++++++++++++++++++- .../json5_trailing_commas.json5 | 7 +++ .../json5_trailing_commas.out | 32 ++++++++++++ .../test_json_parser/t/005_test_json5.pl | 30 ++++++++++- 4 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 src/test/modules/test_json_parser/json5_trailing_commas.json5 create mode 100644 src/test/modules/test_json_parser/json5_trailing_commas.out diff --git a/src/common/jsonapi.c b/src/common/jsonapi.c index 4ca70c5fea9..7e94b4c4200 100644 --- a/src/common/jsonapi.c +++ b/src/common/jsonapi.c @@ -151,6 +151,8 @@ struct JsonParserStack bool *fnull; JsonTokenType scalar_tok; char *scalar_val; + /* json5: last matched terminal was a comma */ + bool json5_after_comma; }; /* json5 comment lexing state saved across incremental chunks */ @@ -659,6 +661,27 @@ have_prediction(JsonParserStack *pstack) return pstack->pred_index > 0; } +/* + * json5 trailing comma support: discard predictions for an element that + * will never arrive, stopping at the enclosing close bracket/brace or the + * more-elements production that leads to it. + */ +static void +json5_pop_to_closing(JsonParserStack *pstack) +{ + while (have_prediction(pstack)) + { + char next = next_prediction(pstack); + + if (next == JSON_TOKEN_ARRAY_END || + next == JSON_TOKEN_OBJECT_END || + next == JSON_NT_MORE_ARRAY_ELEMENTS || + next == JSON_NT_MORE_KEY_PAIRS) + break; + pop_prediction(pstack); + } +} + static inline void set_fname(JsonLexContext *lex, char *fname) { @@ -920,9 +943,28 @@ pg_parse_json_incremental(JsonLexContext *lex, while (have_prediction(pstack)) { - char top = pop_prediction(pstack); + char top; td_entry entry; + /* + * json5 trailing comma: the last matched terminal was a comma and + * the next token closes the array/object, so the predictions for the + * expected next element are phantom. Discard them down to the + * enclosing MORE_* production, which accepts the close bracket. + * Nothing else may unwind this way: a comma flanked by anything else + * must fail like it does in strict mode. + */ + if (lex->json5 && pstack->json5_after_comma && + (tok == JSON_TOKEN_ARRAY_END || tok == JSON_TOKEN_OBJECT_END)) + { + json5_pop_to_closing(pstack); + pstack->json5_after_comma = false; + if (!have_prediction(pstack)) + break; + } + + top = pop_prediction(pstack); + /* * these first two branches are the guts of the Table Driven method */ @@ -932,6 +974,8 @@ pg_parse_json_incremental(JsonLexContext *lex, * tok can only be a terminal symbol, so top must be too. the * token matches the top of the stack, so get the next token. */ + if (lex->json5) + pstack->json5_after_comma = (top == JSON_TOKEN_COMMA); if (tok < JSON_TOKEN_END) { result = json_lex(lex); @@ -1451,6 +1495,8 @@ parse_object(JsonLexContext *lex, const JsonSemAction *sem) result = json_lex(lex); if (result != JSON_SUCCESS) break; + if (lex->json5 && lex_peek(lex) == JSON_TOKEN_OBJECT_END) + break; result = parse_object_field(lex, sem); } break; @@ -1563,6 +1609,8 @@ parse_array(JsonLexContext *lex, const JsonSemAction *sem) result = json_lex(lex); if (result != JSON_SUCCESS) break; + if (lex->json5 && lex_peek(lex) == JSON_TOKEN_ARRAY_END) + break; result = parse_array_element(lex, sem); } } diff --git a/src/test/modules/test_json_parser/json5_trailing_commas.json5 b/src/test/modules/test_json_parser/json5_trailing_commas.json5 new file mode 100644 index 00000000000..b41af4393ee --- /dev/null +++ b/src/test/modules/test_json_parser/json5_trailing_commas.json5 @@ -0,0 +1,7 @@ +{ + "arr": [1, 2, 3,], + "nested_arr": [[1,], [2,],], + "obj": {"a": 1, "b": 2,}, + "nested_obj": {"x": {"y": 1,},}, + "last": true, +} diff --git a/src/test/modules/test_json_parser/json5_trailing_commas.out b/src/test/modules/test_json_parser/json5_trailing_commas.out new file mode 100644 index 00000000000..86f88bda47f --- /dev/null +++ b/src/test/modules/test_json_parser/json5_trailing_commas.out @@ -0,0 +1,32 @@ +{ +"arr": [ +1, +2, +3 +] +, +"nested_arr": [ +[ +1 +] +, +[ +2 +] + +] +, +"obj": { +"a": 1, +"b": 2 +} +, +"nested_obj": { +"x": { +"y": 1 +} + +} +, +"last": true +} diff --git a/src/test/modules/test_json_parser/t/005_test_json5.pl b/src/test/modules/test_json_parser/t/005_test_json5.pl index 7aac5005ab6..7e2519cd427 100644 --- a/src/test/modules/test_json_parser/t/005_test_json5.pl +++ b/src/test/modules/test_json_parser/t/005_test_json5.pl @@ -30,7 +30,22 @@ my @features = ( name => 'comments', file => 'json5_comments', error => qr/Token "\/" is invalid/, - },); + }, + { name => 'trailing commas', file => 'json5_trailing_commas' },); + +# Inputs that stay invalid even in json5 mode. +my @json5_invalid = ( + [ 'doubled trailing comma', '[1,,]' ],); + +# Write $content to a temp file and return the file name. +sub inline_file +{ + my ($content) = @_; + my ($fh, $fname) = tempfile(DIR => $dir); + print $fh $content; + close($fh); + return $fname; +} # Parse $file with --json5 and compare the semantic output against # $expected, whole-input and one byte at a time. @@ -94,6 +109,19 @@ foreach my $exe (@exes) $f->{error}); } + foreach my $inv (@json5_invalid) + { + my ($label, $content) = @$inv; + my $fname = inline_file($content); + + for my $size (64, 1) + { + check_rejected($exe, $fname, + "json5 mode, chunk size $size: $label", + undef, "--json5", "-c", $size); + } + } + } done_testing(); -- 2.43.0