From a7eda41d15c0cfc3a5e5525af66465a784586f40 Mon Sep 17 00:00:00 2001
From: Zsolt Parragi <zsolt.parragi@percona.com>
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

