From d4560252aeaff1cc942720ab8cc4aec6b7f268c7 Mon Sep 17 00:00:00 2001
From: Zsolt Parragi <zsolt.parragi@percona.com>
Date: Wed, 8 Jul 2026 22:26:32 +0000
Subject: [PATCH 4/8] Add JSON5 single-quoted string support to the JSON lexer

When json5 is set, accept '...'-delimited strings with the same escape
handling as double-quoted strings, in normal and incremental modes.
---
 src/common/jsonapi.c                          | 38 +++++++++++++++----
 .../test_json_parser/json5_strings.json5      |  8 ++++
 .../test_json_parser/json5_strings.out        | 13 +++++++
 .../test_json_parser/t/005_test_json5.pl      |  3 +-
 4 files changed, 54 insertions(+), 8 deletions(-)
 create mode 100644 src/test/modules/test_json_parser/json5_strings.json5
 create mode 100644 src/test/modules/test_json_parser/json5_strings.out

diff --git a/src/common/jsonapi.c b/src/common/jsonapi.c
index e8634a749ea..f7728ffcf1f 100644
--- a/src/common/jsonapi.c
+++ b/src/common/jsonapi.c
@@ -1774,12 +1774,14 @@ json_lex(JsonLexContext *lex)
 		JsonLexContext dummy_lex = {0};
 		JsonParseErrorType partial_result;
 
-		if (ptok->data[0] == '"')
+		if (ptok->data[0] == '"' ||
+			(lex->json5 && ptok->data[0] == '\''))
 		{
 			/*
 			 * It's a string. Accumulate characters until we reach an
-			 * unescaped '"'.
+			 * unescaped closing quote.
 			 */
+			char		pquote = ptok->data[0];
 			int			escapes = 0;
 
 			for (int i = ptok->len - 1; i > 0; i--)
@@ -1797,7 +1799,7 @@ json_lex(JsonLexContext *lex)
 
 				jsonapi_appendStringInfoCharMacro(ptok, c);
 				added++;
-				if (c == '"' && escapes % 2 == 0)
+				if (c == pquote && escapes % 2 == 0)
 				{
 					tok_done = true;
 					break;
@@ -2182,6 +2184,18 @@ json_lex(JsonLexContext *lex)
 				lex->token_terminator = s + 1;
 				lex->token_type = JSON_TOKEN_COLON;
 				break;
+			case '\'':
+				if (lex->json5)
+				{
+					result = json_lex_string(lex);
+					if (result != JSON_SUCCESS)
+						return result;
+					lex->token_type = JSON_TOKEN_STRING;
+					break;
+				}
+				lex->prev_token_terminator = lex->token_terminator;
+				lex->token_terminator = s + 1;
+				return JSON_INVALID_TOKEN;
 			case '"':
 				/* string */
 				result = json_lex_string(lex);
@@ -2317,6 +2331,7 @@ json_lex_string(JsonLexContext *lex)
 	const char *s;
 	const char *const end = lex->input + lex->input_length;
 	int			hi_surrogate = -1;
+	char		quote_char = *lex->token_start;
 
 	/* Convenience macros for error exits */
 #define FAIL_OR_INCOMPLETE_AT_CHAR_START(code) \
@@ -2359,7 +2374,7 @@ json_lex_string(JsonLexContext *lex)
 		/* Premature end of the string. */
 		if (s >= end)
 			FAIL_OR_INCOMPLETE_AT_CHAR_START(JSON_INVALID_TOKEN);
-		else if (*s == '"')
+		else if (*s == quote_char)
 			break;
 		else if (*s == '\\')
 		{
@@ -2467,6 +2482,14 @@ json_lex_string(JsonLexContext *lex)
 					case '/':
 						jsonapi_appendStringInfoChar(lex->strval, *s);
 						break;
+					case '\'':
+						if (!lex->json5)
+						{
+							lex->token_start = s;
+							FAIL_AT_CHAR_END(JSON_ESCAPING_INVALID);
+						}
+						jsonapi_appendStringInfoChar(lex->strval, *s);
+						break;
 					case 'b':
 						jsonapi_appendStringInfoChar(lex->strval, '\b');
 						break;
@@ -2493,7 +2516,8 @@ json_lex_string(JsonLexContext *lex)
 						FAIL_AT_CHAR_END(JSON_ESCAPING_INVALID);
 				}
 			}
-			else if (strchr("\"\\/bfnrt", *s) == NULL)
+			else if (strchr("\"\\/bfnrt", *s) == NULL &&
+					 !(lex->json5 && *s == '\''))
 			{
 				/*
 				 * Simpler processing if we're not bothered about de-escaping
@@ -2519,13 +2543,13 @@ json_lex_string(JsonLexContext *lex)
 			 */
 			while (p < end - sizeof(Vector8) &&
 				   !pg_lfind8('\\', (const uint8 *) p, sizeof(Vector8)) &&
-				   !pg_lfind8('"', (const uint8 *) p, sizeof(Vector8)) &&
+				   !pg_lfind8(quote_char, (const uint8 *) p, sizeof(Vector8)) &&
 				   !pg_lfind8_le(31, (const uint8 *) p, sizeof(Vector8)))
 				p += sizeof(Vector8);
 
 			for (; p < end; p++)
 			{
-				if (*p == '\\' || *p == '"')
+				if (*p == '\\' || *p == quote_char)
 					break;
 				else if ((unsigned char) *p <= 31)
 				{
diff --git a/src/test/modules/test_json_parser/json5_strings.json5 b/src/test/modules/test_json_parser/json5_strings.json5
new file mode 100644
index 00000000000..7345d8a452f
--- /dev/null
+++ b/src/test/modules/test_json_parser/json5_strings.json5
@@ -0,0 +1,8 @@
+{
+  a: 'single quoted',
+  b: 'embedded "double" quotes',
+  c: 'escaped \' apostrophe',
+  d: "double with 'single' inside",
+  e: 'line1\nline2',
+  arr: [ 'x', "y", 'z' ]
+}
diff --git a/src/test/modules/test_json_parser/json5_strings.out b/src/test/modules/test_json_parser/json5_strings.out
new file mode 100644
index 00000000000..3efe7a744db
--- /dev/null
+++ b/src/test/modules/test_json_parser/json5_strings.out
@@ -0,0 +1,13 @@
+{
+"a": "single quoted",
+"b": "embedded \"double\" quotes",
+"c": "escaped ' apostrophe",
+"d": "double with 'single' inside",
+"e": "line1\nline2",
+"arr": [
+"x",
+"y",
+"z"
+]
+
+}
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 75d2e7b2ae8..3302c756b50 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
@@ -32,7 +32,8 @@ my @features = (
 		error => qr/Token "\/" is invalid/,
 	},
 	{ name => 'trailing commas', file => 'json5_trailing_commas' },
-	{ name => 'unquoted keys', file => 'json5_keys' },);
+	{ name => 'unquoted keys', file => 'json5_keys' },
+	{ name => 'single-quoted strings', file => 'json5_strings' },);
 
 # Inputs that stay invalid even in json5 mode.
 my @json5_invalid = (
-- 
2.43.0

