fluffos/testsuite/std/json.lpc
Yucong Sun bc20bfe3ae
json: buffer-based single-pass parser/encoder and comprehensive tests (#1163)
Rework of the mudlib JSON library using the driver's buffer features:

- json_decode() scans UTF-8 bytes with a single-pass, position-based
  parser: strings without escapes and numbers decode via one buffer
  range slice instead of byte-by-byte copies (LPC string indexing is
  codepoint-based and O(i) per access, which made the old parser O(n^2)
  on long inputs). Escaped strings copy plain runs in slices and route
  all appends through one json_append(buffer ref, int ref, mixed)
  helper built on to_buffer() promotion and range assignment.
- json_decode() also accepts a buffer directly, skipping the string
  conversion round-trip.
- \uXXXX escapes decode via sprintf("%c") -> raw UTF-8 bytes, including
  UTF-16 surrogate pairs (with validation of lone/misordered
  surrogates); astral-plane characters encode as \uXXXX\uXXXX pairs
  (they used to produce a corrupt 5-digit escape).
- json_encode_string() emits escape sequences as string literals
  through the same append helper instead of hand-poked hex bytes.

The test suite grows from 44 to 599 lines / 178 checks: numbers,
strings, escapes, Unicode/surrogates, booleans/null, arrays, objects,
nesting, encoding for every type, non-string keys, circular reference
detection, round-trips, real-world JSON files, the buffer-input path,
buffer-growth regressions, and print-only performance benchmarks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 02:02:49 -04:00

676 lines
21 KiB
Text

/**
* json.lpc
*
* LPC support functions for JSON serialization and deserialization.
* Attempts to be compatible with reasonably current FluffOS and LDMud
* drivers, with at least a gesture or two toward compatibility with
* older drivers.
*
*
* mixed json_decode(string | buffer text)
* Deserializes JSON into an LPC value. A buffer argument is parsed
* directly as UTF-8 bytes, skipping the string conversion.
*
* string json_encode(mixed value)
* Serializes an LPC value into JSON text.
*
* v1.0: initial release
* v1.0.1: fix for handling of \uXXXX on FLUFFOS
* v1.0.2: define array keyword for LDMud & use it consistently
* v1.0.3: fix for empty data structures
* v1.0.4: Removed array keyword. (Yucong Sun)
* v1.0.5: Fix decoding number 0.
* v1.1: Buffer-based single-pass parser and encoder: strings and numbers
* are scanned as UTF-8 bytes (LPC string indexing is codepoint-based
* and O(i) per access, which made the old parser O(n^2) on long
* inputs), astral-plane characters encode as \uXXXX\uXXXX surrogate
* pairs, and json_decode() also accepts a buffer.
*
* LICENSE
*
* The MIT License (MIT)
*
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
#ifndef __STD_JSON_H
#define __STD_JSON_H
#define to_string(x) ("" + (x))
#define JSON_DECODE_PARSE_TEXT 0
#define JSON_DECODE_PARSE_POS 1
#define JSON_DECODE_PARSE_FIELDS 2
private mixed json_decode_parse_value(mixed* parse);
private varargs mixed json_decode_parse_string(mixed* parse, int initiator_checked);
/*
* Append bytes to a growing buffer. <bytes> may be a buffer, a string
* (appended as its raw UTF-8 bytes) or an array of ints 0..255 -- all
* three convert through to_buffer(). Grows <dst> geometrically, so
* repeated appends stay O(n) overall.
*/
private void json_append(buffer ref dst, int ref len, mixed bytes) {
buffer add = to_buffer(bytes);
int n = sizeof(add);
while(len + n > sizeof(dst))
dst += allocate_buffer(sizeof(dst) || 16);
if(n)
dst[len..len + n - 1] = add;
len += n;
}
private int json_decode_hexdigit(int ch) {
if(ch >= '0' && ch <= '9')
return ch - '0';
if(ch >= 'a' && ch <= 'f')
return ch - 'a' + 10;
if(ch >= 'A' && ch <= 'F')
return ch - 'A' + 10;
return -1;
}
private varargs void json_decode_parse_error(mixed* parse, string msg, int ch) {
if(ch)
msg = sprintf("%s, '%c'", msg, ch);
msg = sprintf("%s @ position %d\n", msg, parse[JSON_DECODE_PARSE_POS]);
error(msg);
}
private void json_decode_skip_ws(mixed* parse) {
buffer text = parse[JSON_DECODE_PARSE_TEXT];
int pos = parse[JSON_DECODE_PARSE_POS];
int ch;
while(1) {
ch = text[pos];
if(ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n' || ch == 0x0c) {
pos++;
} else {
parse[JSON_DECODE_PARSE_POS] = pos;
return;
}
}
}
private mixed json_decode_parse_object(mixed* parse) {
buffer text = parse[JSON_DECODE_PARSE_TEXT];
mapping out = ([]);
mixed key, value;
int ch;
// Skip opening brace
parse[JSON_DECODE_PARSE_POS]++;
// Check for empty object
json_decode_skip_ws(parse);
if(text[parse[JSON_DECODE_PARSE_POS]] == '}') {
parse[JSON_DECODE_PARSE_POS]++;
return out;
}
while(1) {
// Skip whitespace before key
json_decode_skip_ws(parse);
// Parse key
key = json_decode_parse_string(parse);
// Skip whitespace and find colon
json_decode_skip_ws(parse);
ch = text[parse[JSON_DECODE_PARSE_POS]];
if(ch != ':') {
if(ch == 0)
json_decode_parse_error(parse, "Unexpected end of data");
json_decode_parse_error(parse, "Expected ':' after object key", ch);
}
parse[JSON_DECODE_PARSE_POS]++;
// Parse value
value = json_decode_parse_value(parse);
out[key] = value;
// Skip whitespace and check for comma or closing brace
json_decode_skip_ws(parse);
ch = text[parse[JSON_DECODE_PARSE_POS]];
if(ch == '}') {
parse[JSON_DECODE_PARSE_POS]++;
return out;
}
if(ch == ',') {
parse[JSON_DECODE_PARSE_POS]++;
continue;
}
if(ch == 0)
json_decode_parse_error(parse, "Unexpected end of data");
json_decode_parse_error(parse, "Expected ',' or '}' in object", ch);
}
}
private mixed json_decode_parse_array(mixed* parse) {
buffer text = parse[JSON_DECODE_PARSE_TEXT];
mixed* values;
mixed value;
int ch;
int count = 0;
// Skip opening bracket
parse[JSON_DECODE_PARSE_POS]++;
// Check for empty array
json_decode_skip_ws(parse);
if(text[parse[JSON_DECODE_PARSE_POS]] == ']') {
parse[JSON_DECODE_PARSE_POS]++;
return ({});
}
// Pre-allocate and grow geometrically; trimmed to size on return
values = allocate(16);
while(1) {
// Parse value
value = json_decode_parse_value(parse);
if(count >= sizeof(values))
values += allocate(sizeof(values));
values[count++] = value;
// Skip whitespace and check for comma or closing bracket
json_decode_skip_ws(parse);
ch = text[parse[JSON_DECODE_PARSE_POS]];
if(ch == ']') {
parse[JSON_DECODE_PARSE_POS]++;
if(count < sizeof(values))
return values[0..count-1];
return values;
}
if(ch == ',') {
parse[JSON_DECODE_PARSE_POS]++;
continue;
}
if(ch == 0)
json_decode_parse_error(parse, "Unexpected end of data");
json_decode_parse_error(parse, "Expected ',' or ']' in array", ch);
}
}
/*
* Decode the four hex digits of a \uXXXX escape at text[pos].
* Returns the code unit; advances the caller's position by reference.
*/
private int json_decode_parse_hex4(mixed* parse, buffer text, int ref pos) {
int code = 0;
int ch, digit;
for(int i = 0; i < 4; i++) {
ch = text[pos++];
digit = json_decode_hexdigit(ch);
if(digit == -1)
json_decode_parse_error(parse, "Invalid hex digit", ch);
code = (code << 4) | digit;
}
return code;
}
private varargs mixed json_decode_parse_string(mixed* parse, int initiator_checked) {
buffer text = parse[JSON_DECODE_PARSE_TEXT];
buffer result;
int result_len;
int pos, start, ch, esc;
if(!initiator_checked) {
ch = text[parse[JSON_DECODE_PARSE_POS]];
if(!ch)
json_decode_parse_error(parse, "Unexpected end of data");
if(ch != '"')
json_decode_parse_error(parse, "Unexpected character", ch);
}
parse[JSON_DECODE_PARSE_POS]++;
// Fast path: scan for the closing quote; a string without escapes is
// decoded straight out of the input with a single range slice.
pos = start = parse[JSON_DECODE_PARSE_POS];
while((ch = text[pos]) != '"' && ch != '\\' && ch != 0)
pos++;
if(ch == 0)
json_decode_parse_error(parse, "Unexpected end of data");
if(ch == '"') {
parse[JSON_DECODE_PARSE_POS] = pos + 1;
if(pos == start)
return "";
return string_decode(text[start..pos-1], "utf-8");
}
// Escapes present: build the result, seeded with the clean prefix
result = text[start..pos-1] + allocate_buffer(64);
result_len = pos - start;
while(1) {
// Copy a run of plain characters in one slice
start = pos;
while((ch = text[pos]) != '"' && ch != '\\' && ch != 0)
pos++;
if(pos > start)
json_append(ref result, ref result_len, text[start..pos-1]);
if(ch == 0)
json_decode_parse_error(parse, "Unexpected end of data");
if(ch == '"') {
parse[JSON_DECODE_PARSE_POS] = pos + 1;
if(result_len < sizeof(result))
result = result[0..result_len-1];
return string_decode(result, "utf-8");
}
// Escape sequence
pos++;
ch = text[pos];
switch(ch) {
case 0:
json_decode_parse_error(parse, "Unexpected end of data");
case '"': esc = '"'; break;
case '\\': esc = '\\'; break;
case '/': esc = '/'; break;
case 'b': esc = '\b'; break;
case 'f': esc = 0x0c; break;
case 'n': esc = '\n'; break;
case 'r': esc = '\r'; break;
case 't': esc = '\t'; break;
case 'u': {
// Unicode escape \uXXXX (or a \uXXXX\uXXXX surrogate pair)
int code;
pos++;
code = json_decode_parse_hex4(parse, text, ref pos);
if((code & 0xfffff800) == 0xd800) {
int high = code, low;
// A low surrogate (0xDC00-0xDFFF) may not appear first
if(code >= 0xdc00)
json_decode_parse_error(parse, "Invalid string, unexpected low surrogate");
if(text[pos] != '\\' || text[pos+1] != 'u')
json_decode_parse_error(parse, "Invalid string, missing surrogate pair");
pos += 2; // Skip \u
low = json_decode_parse_hex4(parse, text, ref pos);
// The second escape must be a low surrogate
if((low & 0xfffffc00) != 0xdc00)
json_decode_parse_error(parse, "Invalid string, invalid low surrogate");
code = 0x10000 + (high - 0xd800) * 0x400 + (low - 0xdc00);
}
// sprintf("%c") yields the codepoint as a UTF-8 string, whose
// raw bytes append directly to the result buffer
json_append(ref result, ref result_len, sprintf("%c", code));
continue;
}
default:
json_decode_parse_error(parse, "Invalid escape sequence", ch);
}
json_append(ref result, ref result_len, ({ esc }));
pos++;
}
}
private mixed json_decode_parse_number(mixed* parse) {
buffer text = parse[JSON_DECODE_PARSE_TEXT];
int pos = parse[JSON_DECODE_PARSE_POS];
int from = pos;
int has_dot = 0;
int has_exp = 0;
int ch, next_ch;
string num;
ch = text[pos];
// Handle negative sign
if(ch == '-') {
pos++;
next_ch = text[pos];
if(!next_ch)
json_decode_parse_error(parse, "Unexpected end of data");
if(next_ch < '0' || next_ch > '9')
json_decode_parse_error(parse, "Unexpected character", next_ch);
ch = next_ch;
}
// A leading zero may only be followed by '.', 'e'/'E', or a separator
if(ch == '0') {
pos++;
next_ch = text[pos];
if(next_ch != '.' && next_ch != 'e' && next_ch != 'E') {
if((next_ch >= '0' && next_ch <= '9') || next_ch == '-')
json_decode_parse_error(parse, "Unexpected character", next_ch);
parse[JSON_DECODE_PARSE_POS] = pos;
return 0;
}
}
// Scan digits, decimal point, and exponent; the text is sliced once
// at the end instead of being copied byte by byte.
while(1) {
ch = text[pos];
switch(ch) {
case '.':
if(has_dot || has_exp)
json_decode_parse_error(parse, "Unexpected character", ch);
has_dot = 1;
pos++;
break;
case '0': case '1': case '2': case '3': case '4':
case '5': case '6': case '7': case '8': case '9':
pos++;
break;
case 'e':
case 'E':
if(has_exp)
json_decode_parse_error(parse, "Unexpected character", ch);
has_exp = 1;
pos++;
// Optional +/- after the exponent
ch = text[pos];
if(ch == '+' || ch == '-')
pos++;
break;
case '-':
case '+':
json_decode_parse_error(parse, "Unexpected character", ch);
default:
// End of number
if(pos == from || (pos == from + 1 && (text[from] == '-' || text[from] == '.')))
json_decode_parse_error(parse, "Invalid number");
parse[JSON_DECODE_PARSE_POS] = pos;
num = string_decode(text[from..pos-1], "utf-8");
if(has_dot || has_exp)
return to_float(num);
return to_int(num);
}
}
}
private mixed json_decode_parse_value(mixed* parse) {
buffer text = parse[JSON_DECODE_PARSE_TEXT];
int ch;
int pos;
// Skip leading whitespace
json_decode_skip_ws(parse);
ch = text[parse[JSON_DECODE_PARSE_POS]];
switch(ch) {
case 0:
json_decode_parse_error(parse, "Unexpected end of data");
case '{':
return json_decode_parse_object(parse);
case '[':
return json_decode_parse_array(parse);
case '"':
return json_decode_parse_string(parse, 1);
case '-':
case '0':
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9':
return json_decode_parse_number(parse);
case 't':
// Parse "true" - first char already matched by switch
pos = parse[JSON_DECODE_PARSE_POS];
if(text[pos+1] == 'r' && text[pos+2] == 'u' && text[pos+3] == 'e') {
parse[JSON_DECODE_PARSE_POS] = pos + 4;
return 1;
}
json_decode_parse_error(parse, "Invalid token starting with 't'", ch);
case 'f':
// Parse "false" - first char already matched by switch
pos = parse[JSON_DECODE_PARSE_POS];
if(text[pos+1] == 'a' && text[pos+2] == 'l' && text[pos+3] == 's' &&
text[pos+4] == 'e') {
parse[JSON_DECODE_PARSE_POS] = pos + 5;
return 0;
}
json_decode_parse_error(parse, "Invalid token starting with 'f'", ch);
case 'n':
// Parse "null" - first char already matched by switch
pos = parse[JSON_DECODE_PARSE_POS];
if(text[pos+1] == 'u' && text[pos+2] == 'l' && text[pos+3] == 'l') {
parse[JSON_DECODE_PARSE_POS] = pos + 4;
return 0;
}
json_decode_parse_error(parse, "Invalid token starting with 'n'", ch);
default:
json_decode_parse_error(parse, "Unexpected character", ch);
}
}
private mixed json_decode_parse(mixed* parse) {
buffer text = parse[JSON_DECODE_PARSE_TEXT];
mixed out = json_decode_parse_value(parse);
// Skip trailing whitespace
json_decode_skip_ws(parse);
// Should be at end of input
if(text[parse[JSON_DECODE_PARSE_POS]] != 0) {
json_decode_parse_error(parse, "Unexpected character after value",
text[parse[JSON_DECODE_PARSE_POS]]);
}
return out;
}
// Accept string or buffer - a buffer is parsed directly as UTF-8 bytes
mixed json_decode(mixed text) {
mixed* parse;
buffer buf;
if(!text) {
return 0;
}
// The parser relies on a 0 sentinel byte terminating the input
if(bufferp(text)) {
buf = text;
if(sizeof(buf) == 0 || buf[<1] != 0)
buf += ({ 0 });
} else {
buf = to_buffer(text) + ({ 0 });
}
parse = allocate(JSON_DECODE_PARSE_FIELDS);
parse[JSON_DECODE_PARSE_TEXT] = buf;
parse[JSON_DECODE_PARSE_POS] = 0;
return json_decode_parse(parse);
}
private string json_encode_string(string value) {
buffer result, src;
int result_len = 0;
int len;
int i, start, ch;
// Convert to UTF-8 bytes once and scan the buffer: indexing an LPC
// string is codepoint-based (ICU) and O(i) per access, which made this
// loop O(n^2) for long strings.
src = to_buffer(value);
len = sizeof(src);
// Headroom for the quotes plus a few escapes
result = allocate_buffer(len + 8);
json_append(ref result, ref result_len, "\"");
for(i = 0; i < len; ) {
// Copy a run of plain ASCII in one slice
start = i;
while(i < len && (ch = src[i]) >= 0x20 && ch < 0x7f &&
ch != '"' && ch != '\\' && ch != '/')
i++;
if(i > start)
json_append(ref result, ref result_len, src[start..i-1]);
if(i >= len)
break;
switch(ch) {
case '"': json_append(ref result, ref result_len, "\\\""); break;
case '\\': json_append(ref result, ref result_len, "\\\\"); break;
case '/': json_append(ref result, ref result_len, "\\/"); break;
case '\b': json_append(ref result, ref result_len, "\\b"); break;
case 0x0c: json_append(ref result, ref result_len, "\\f"); break;
case '\n': json_append(ref result, ref result_len, "\\n"); break;
case '\r': json_append(ref result, ref result_len, "\\r"); break;
case '\t': json_append(ref result, ref result_len, "\\t"); break;
default:
if(ch > 0x7f) {
// Lead byte of a multi-byte UTF-8 sequence: decode the
// codepoint, then escape it as \uXXXX (or a UTF-16
// surrogate pair for codepoints above the BMP).
int code, extra, k;
if((ch & 0xe0) == 0xc0) { code = ch & 0x1f; extra = 1; }
else if((ch & 0xf0) == 0xe0) { code = ch & 0x0f; extra = 2; }
else { code = ch & 0x07; extra = 3; }
for(k = 0; k < extra; k++) {
i++;
code = (code << 6) | (src[i] & 0x3f);
}
if(code > 0xffff) {
// JSON requires astral-plane codepoints to be written
// as a \uXXXX\uXXXX UTF-16 surrogate pair
int v = code - 0x10000;
json_append(ref result, ref result_len,
sprintf("\\u%04x\\u%04x",
0xd800 + (v >> 10), 0xdc00 + (v & 0x3ff)));
} else {
json_append(ref result, ref result_len, sprintf("\\u%04x", code));
}
} else {
// Control character (including \x1b): encode as \uXXXX
json_append(ref result, ref result_len, sprintf("\\u%04x", ch));
}
break;
}
i++;
}
json_append(ref result, ref result_len, "\"");
// Trim to actual size
if(result_len < sizeof(result))
result = result[0..result_len-1];
return string_decode(result, "utf-8");
}
varargs string json_encode(mixed value, mixed* pointers) {
if(undefinedp(value))
return "null";
if(intp(value) || floatp(value))
return to_string(value);
if(stringp(value)) {
return json_encode_string(value);
}
if(mapp(value)) {
string* parts = ({});
if(pointers) {
// Don't recurse into circular data structures, output null for
// their interior reference
if(member_array(value, pointers) != -1)
return "null";
pointers += ({ value });
} else {
pointers = ({ value });
}
foreach(mixed k, mixed v in value) {
// Non-string keys are skipped because the JSON spec requires that
// object field names be strings.
if(!stringp(k))
continue;
parts += ({ sprintf("%s:%s", json_encode_string(k), json_encode(v, pointers)) });
}
if(!sizeof(parts))
return "{}";
return sprintf("{%s}", implode(parts, ","));
}
if(arrayp(value))
{
if(sizeof(value)) {
string* parts = ({});
if(pointers) {
// Don't recurse into circular data structures, output null for
// their interior reference
if(member_array(value, pointers) != -1)
return "null";
pointers += ({ value });
} else {
pointers = ({ value });
}
foreach(mixed v in value) {
parts += ({ json_encode(v, pointers) });
}
return sprintf("[%s]", implode(parts, ","));
} else {
return "[]";
}
}
// Values that cannot be represented in JSON are replaced by nulls.
return "null";
}
#endif /* __STD_JSON_H */