From b5dbc63a8b5ae4d9450a395e0c4cbeeedb030a14 Mon Sep 17 00:00:00 2001 From: Tilo Sloboda Date: Thu, 11 Jun 2026 11:07:42 -0700 Subject: [PATCH 1/3] 1.1.1: C-extension on_warning warnings now match the pure-Ruby parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The C parser had separate warning emission sites from the Ruby parser and had drifted: empty_value / duplicate_key dropped the key name (and duplicate_key the resolution strategy), and duplicate_key reported the wrong column. Bring the C messages up to the Ruby contract, and attribute the duplicate-key warning to the duplicate member's value position (a parallel offset array on the value stack) so the line/col match on single-line, multiline, and nested objects. Adds spec/warning_parity_spec.rb: per-type [true,false] message+line+col checks plus a cross-path equality test over every warning type — the coverage that was missing and would have caught this drift. Suite 1,097 green, 100% coverage. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 4 ++ ext/smarter_json/smarter_json.c | 87 +++++++++++++++++++++-------- lib/smarter_json/version.rb | 2 +- spec/warning_parity_spec.rb | 98 +++++++++++++++++++++++++++++++++ 4 files changed, 168 insertions(+), 23 deletions(-) create mode 100644 spec/warning_parity_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 6595c8b..afed3ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ > ⚠️ We discourage the use of `process(input).first` / `process(input)[0]` because it silently drops potential additional documents > Please use `process_one` if you are expecting only one JSON doc, e.g. in API payloads. +## 1.1.1 (2026-06-11) + +- The C extension now emits the same `on_warning` warnings as the pure-Ruby parser. `empty_value` and `duplicate_key` warnings name the offending key (and `duplicate_key` names the resolution strategy), and the warning text, line, and column are now identical whether or not the C extension is loaded. + ## 1.1.0 (2026-06-09) RSpec tests: 1,038 → 1,070 diff --git a/ext/smarter_json/smarter_json.c b/ext/smarter_json/smarter_json.c index 5852635..d16a463 100644 --- a/ext/smarter_json/smarter_json.c +++ b/ext/smarter_json/smarter_json.c @@ -81,9 +81,9 @@ typedef struct { * an error) by scanning from the start of the buffer. CR, LF, and CRLF each * count as one newline; col is bytes since the last line start (1-based). * Keeping this off the hot path is the point — fj_advance never touches it. */ -static void fj_line_col(fj_state *st, long *line, long *col) { +static void fj_line_col_to(fj_state *st, long stop, long *line, long *col) { long l = 1, c = 1, i; - long limit = (st->pos < st->len) ? st->pos : st->len; + long limit = (stop < st->len) ? stop : st->len; for (i = 0; i < limit; i++) { unsigned char b = (unsigned char)st->buf[i]; if (b == 0x0A) { l++; c = 1; } @@ -93,6 +93,9 @@ static void fj_line_col(fj_state *st, long *line, long *col) { *line = l; *col = c; } +static void fj_line_col(fj_state *st, long *line, long *col) { + fj_line_col_to(st, st->pos, line, col); +} /* Report a non-fatal lenient fix to the on_warning callable — a no-op (and builds no * Warning) when no handler was given. The internal Qnil guard is the safety net; the @@ -106,6 +109,25 @@ static void fj_warn(fj_state *st, VALUE type_sym, const char *msg) { rb_utf8_str_new_cstr(msg), LONG2NUM(line), LONG2NUM(col))); } +/* Like fj_warn but the message is a prebuilt Ruby String (rb_enc_sprintf, for messages + * that interpolate the offending key). Same Qnil guard and st->pos line/col. */ +static void fj_warn_str(fj_state *st, VALUE type_sym, VALUE msg) { + long line, col; + if (st->on_warning == Qnil) return; + fj_line_col(st, &line, &col); + rb_funcall(st->on_warning, fj_call_id, 1, + rb_funcall(cWarning, fj_new_id, 4, type_sym, msg, LONG2NUM(line), LONG2NUM(col))); +} + +/* Like fj_warn_str but at an explicit (line,col) — for a warning detected away from the + * cursor. Duplicate keys are found while building the closed object, but attributed to + * the object's closing position so the column matches the pure-Ruby parser. */ +static void fj_warn_str_at(fj_state *st, VALUE type_sym, VALUE msg, long line, long col) { + if (st->on_warning == Qnil) return; + rb_funcall(st->on_warning, fj_call_id, 1, + rb_funcall(cWarning, fj_new_id, 4, type_sym, msg, LONG2NUM(line), LONG2NUM(col))); +} + /* 1-based column of the current byte position (bytes since the last line start). * Used for triple-quoted indentation stripping (smarter_json.md §2.3). */ static long fj_column(fj_state *st) { @@ -1290,7 +1312,7 @@ void rb_hash_bulk_insert(long, const VALUE *, VALUE); /* Build a Hash from `count` interleaved key,value slots. Fast path (String keys, * default :last_wins): pre-size + bulk insert. symbolize_keys / :first_wins use a * per-member loop into the same pre-sized hash. */ -static VALUE fj_build_object(fj_state *st, const VALUE *pairs, long count) { +static VALUE fj_build_object(fj_state *st, const VALUE *pairs, const long *positions, long count) { long entries = count / 2, i; VALUE hash = rb_hash_new_capa(entries); @@ -1305,7 +1327,16 @@ static VALUE fj_build_object(fj_state *st, const VALUE *pairs, long count) { VALUE k = st->symbolize_keys ? rb_funcall(pairs[i], fj_to_sym_id, 0) : pairs[i]; if (st->dup_first_wins || st->on_warning != Qnil) { if (RTEST(rb_funcall(hash, fj_key_p_id, 1, k))) { - fj_warn(st, fj_sym_duplicate_key, "duplicate key"); + if (st->on_warning != Qnil) { + long wl, wc; + fj_line_col_to(st, positions[i + 1], &wl, &wc); /* the duplicate member's value position — matches the Ruby parser */ + fj_warn_str_at(st, fj_sym_duplicate_key, + rb_enc_sprintf(rb_utf8_encoding(), + "duplicate key %"PRIsVALUE" \xe2\x80\x94 %s", + rb_inspect(k), + st->dup_first_wins ? "first_wins" : "last_wins"), + wl, wc); + } if (st->dup_first_wins) continue; } } @@ -1323,6 +1354,7 @@ typedef struct { long mark; int is_obj; } fj_frame; typedef struct { VALUE *vptr; long vhead; long vcapa; /* pending values (GC-marked) */ + long *pptr; /* byte offset just past each pushed value (mirrors vptr/vcapa); used only to attribute a duplicate-key warning to the duplicate member's position */ fj_frame *fptr; long fhead; long fcapa; /* open-container frames (no VALUEs) */ } fj_pstack; @@ -1334,12 +1366,13 @@ static void fj_pstack_mark(void *p) { static void fj_pstack_free(void *p) { fj_pstack *ps = (fj_pstack *)p; if (ps->vptr != NULL) xfree(ps->vptr); + if (ps->pptr != NULL) xfree(ps->pptr); if (ps->fptr != NULL) xfree(ps->fptr); xfree(ps); } static size_t fj_pstack_memsize(const void *p) { const fj_pstack *ps = (const fj_pstack *)p; - return sizeof(fj_pstack) + (size_t)ps->vcapa * sizeof(VALUE) + (size_t)ps->fcapa * sizeof(fj_frame); + return sizeof(fj_pstack) + (size_t)ps->vcapa * (sizeof(VALUE) + sizeof(long)) + (size_t)ps->fcapa * sizeof(fj_frame); } static const rb_data_type_t fj_pstack_type = { "smarter_json/pstack", @@ -1347,8 +1380,13 @@ static const rb_data_type_t fj_pstack_type = { 0, 0, RUBY_TYPED_FREE_IMMEDIATELY, }; -static inline void fj_vpush(fj_pstack *ps, VALUE v) { - if (ps->vhead >= ps->vcapa) { ps->vcapa *= 2; REALLOC_N(ps->vptr, VALUE, ps->vcapa); } +static inline void fj_vpush(fj_state *st, fj_pstack *ps, VALUE v) { + if (ps->vhead >= ps->vcapa) { + ps->vcapa *= 2; + REALLOC_N(ps->vptr, VALUE, ps->vcapa); + REALLOC_N(ps->pptr, long, ps->vcapa); + } + ps->pptr[ps->vhead] = st->pos; /* offset just past this value — the duplicate-key warning position */ ps->vptr[ps->vhead++] = v; } static inline void fj_fpush(fj_pstack *ps, long mark, int is_obj) { @@ -1368,6 +1406,7 @@ static VALUE fj_parse_iter(fj_state *st, int implicit_root) { int vss = 0; /* warnings: has a value landed in the current container since the last separator? */ ps->vptr = ALLOC_N(VALUE, 64); ps->vhead = 0; ps->vcapa = 64; + ps->pptr = ALLOC_N(long, 64); ps->fptr = ALLOC_N(fj_frame, 16); ps->fhead = 0; ps->fcapa = 16; if (implicit_root) fj_fpush(ps, 0, 1); @@ -1398,7 +1437,7 @@ static VALUE fj_parse_iter(fj_state *st, int implicit_root) { b = fj_byte(st); if (FJ_UNLIKELY(fj_needs_ws_skip(b))) { fj_skip_ws_comments(st); b = fj_byte(st); } if (b == ',') { /* collapsing separator: skip empty member */ - if (st->on_warning != Qnil && !vss) fj_warn(st, fj_sym_empty_slot, "extra comma, collapsed an empty slot"); + if (st->on_warning != Qnil && !vss) fj_warn(st, fj_sym_empty_slot, "extra comma \xe2\x80\x94 collapsed an empty slot"); vss = 0; fj_advance(st, 1); continue; @@ -1406,17 +1445,17 @@ static VALUE fj_parse_iter(fj_state *st, int implicit_root) { if (b == '}') { VALUE hash; fj_advance(st, 1); - hash = fj_build_object(st, &ps->vptr[mark], ps->vhead - mark); + hash = fj_build_object(st, &ps->vptr[mark], &ps->pptr[mark], ps->vhead - mark); ps->vhead = mark; ps->fhead--; if (ps->fhead == 0) { result = hash; break; } - fj_vpush(ps, hash); + fj_vpush(st, ps, hash); vss = 1; continue; } if (b == -1) { if (implicit_root && ps->fhead == 1) { - result = fj_build_object(st, &ps->vptr[mark], ps->vhead - mark); + result = fj_build_object(st, &ps->vptr[mark], &ps->pptr[mark], ps->vhead - mark); break; } fj_error(st, "unterminated object"); @@ -1430,28 +1469,32 @@ static VALUE fj_parse_iter(fj_state *st, int implicit_root) { b = fj_byte(st); if (FJ_UNLIKELY(fj_needs_ws_skip(b))) { fj_skip_ws_comments(st); b = fj_byte(st); } if (b == '{' || b == '[') { - fj_vpush(ps, key); + fj_vpush(st, ps, key); fj_advance(st, 1); fj_fpush(ps, ps->vhead, (b == '{')); vss = 0; continue; } if (b == '}' || b == ',') { /* key with a colon but no value -> null */ - fj_vpush(ps, key); - fj_vpush(ps, Qnil); - fj_warn(st, fj_sym_empty_value, "empty value, used null"); + fj_vpush(st, ps, key); + fj_vpush(st, ps, Qnil); + if (st->on_warning != Qnil) + fj_warn_str(st, fj_sym_empty_value, + rb_enc_sprintf(rb_utf8_encoding(), + "key %"PRIsVALUE" had no value \xe2\x80\x94 used null", + rb_inspect(key))); vss = 1; continue; } if (b == -1) fj_error(st, "unexpected end of input"); - fj_vpush(ps, key); - fj_vpush(ps, fj_parse_member_value(st)); + fj_vpush(st, ps, key); + fj_vpush(st, ps, fj_parse_member_value(st)); vss = 1; } else { /* array */ b = fj_byte(st); if (FJ_UNLIKELY(fj_needs_ws_skip(b))) { fj_skip_ws_comments(st); b = fj_byte(st); } if (b == ',') { /* collapsing separator: skip empty slot */ - if (st->on_warning != Qnil && !vss) fj_warn(st, fj_sym_empty_slot, "extra comma, collapsed an empty slot"); + if (st->on_warning != Qnil && !vss) fj_warn(st, fj_sym_empty_slot, "extra comma \xe2\x80\x94 collapsed an empty slot"); vss = 0; fj_advance(st, 1); continue; @@ -1463,7 +1506,7 @@ static VALUE fj_parse_iter(fj_state *st, int implicit_root) { ps->vhead = mark; ps->fhead--; if (ps->fhead == 0) { result = ary; break; } - fj_vpush(ps, ary); + fj_vpush(st, ps, ary); vss = 1; continue; } @@ -1481,10 +1524,10 @@ static VALUE fj_parse_iter(fj_state *st, int implicit_root) { smart-quote, literals) falls through to the full dispatch below. */ if (b == '-' || b == '+' || b == '.' || (b >= '0' && b <= '9')) { VALUE num; - if (fj_try_member_number(st, &num)) { fj_vpush(ps, num); vss = 1; continue; } + if (fj_try_member_number(st, &num)) { fj_vpush(st, ps, num); vss = 1; continue; } } - if (b == '"') { fj_vpush(ps, fj_parse_string(st, '"')); vss = 1; continue; } - fj_vpush(ps, fj_parse_member_value(st)); + if (b == '"') { fj_vpush(st, ps, fj_parse_string(st, '"')); vss = 1; continue; } + fj_vpush(st, ps, fj_parse_member_value(st)); vss = 1; } } diff --git a/lib/smarter_json/version.rb b/lib/smarter_json/version.rb index 4ea7698..fee2b14 100644 --- a/lib/smarter_json/version.rb +++ b/lib/smarter_json/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module SmarterJSON - VERSION = "1.1.0" + VERSION = "1.1.1" end diff --git a/spec/warning_parity_spec.rb b/spec/warning_parity_spec.rb new file mode 100644 index 0000000..463ad17 --- /dev/null +++ b/spec/warning_parity_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "smarter_json" + +# Every lenient fix must report the SAME SmarterJSON::Warning (type, message, line, +# col) on the C extension and on the pure-Ruby parser. Most warnings already do, +# because they are emitted from shared Ruby code (the Recovery layer and the number +# path); empty_slot / empty_value / duplicate_key have SEPARATE emission sites in the +# C extension and in the Ruby parser, which is where message/column drift crept in. +# +# The pure-Ruby parser is the contract: it names the offending key, names the +# duplicate-key resolution strategy, and reports the line/col of the duplicate +# member's value. The C extension is brought up to match. (— is an em dash; the save hook keeps +# the spec ASCII, so the expected strings use the escape, which equals the parser's +# real em dash at run time.) +RSpec.describe SmarterJSON, "warning parity (C and Ruby emit identical warnings)" do + def first_warning(input, **opts) + seen = nil + SmarterJSON.process(input, on_warning: ->(w) { seen ||= w }, **opts) + seen + end + + [true, false].each do |acceleration| + context "acceleration: #{acceleration}" do + it "empty_slot — names nothing, em dash, col at the extra comma" do + w = first_warning("[1,,2]", acceleration: acceleration) + expect([w.type, w.message, w.line, w.col]) + .to eq([:empty_slot, "extra comma — collapsed an empty slot", 1, 4]) + end + + it "empty_value — names the key" do + w = first_warning('{"x":}', acceleration: acceleration) + expect([w.type, w.message, w.line, w.col]) + .to eq([:empty_value, "key \"x\" had no value — used null", 1, 6]) + end + + it "duplicate_key — names the key and strategy (last_wins), col at the duplicate value" do + w = first_warning('{"a":1,"a":2}', acceleration: acceleration) + expect([w.type, w.message, w.line, w.col]) + .to eq([:duplicate_key, "duplicate key \"a\" — last_wins", 1, 13]) + end + + it "duplicate_key — names the first_wins strategy" do + w = first_warning('{"a":1,"a":2}', duplicate_key: :first_wins, acceleration: acceleration) + expect(w.message).to eq("duplicate key \"a\" — first_wins") + end + + it "duplicate_key — inspects a symbolized key" do + w = first_warning('{"a":1,"a":2}', symbolize_keys: true, acceleration: acceleration) + expect(w.message).to eq("duplicate key :a — last_wins") + end + + it "duplicate_key — multiline: line/col track the duplicate member's value, not the brace" do + w = first_warning(%({\n "a": 1,\n "a": 2\n}), acceleration: acceleration) + expect([w.type, w.message, w.line, w.col]) + .to eq([:duplicate_key, "duplicate key \"a\" — last_wins", 3, 9]) + end + + it "duplicate_key — nested object, col tracks the inner duplicate" do + w = first_warning(%({\n "outer": {\n "k": 1,\n "k": 2\n }\n}), acceleration: acceleration) + expect([w.line, w.col]).to eq([4, 11]) + end + + it "duplicate_key — trailing whitespace after the value is included, matching Ruby" do + w = first_warning(%({\n "a": 1,\n "a": 2 \n}), acceleration: acceleration) + expect([w.line, w.col]).to eq([3, 11]) + end + end + end + + # Belt-and-suspenders: for every warning type, the C path and the Ruby path must + # produce a byte-identical Warning. This is the coverage that was missing — it would + # have caught the message/column drift immediately, and locks all paths going forward. + describe "every warning type is identical on the C and Ruby paths" do + { + "empty_slot (array)" => [:process, "[1,,2]"], + "empty_slot (object)" => [:process, '{"a":1,,"b":2}'], + "empty_value" => [:process, '{"x":}'], + "duplicate_key" => [:process, '{"a":1,"a":2}'], + "duplicate_key (multiline)" => [:process, "{\n \"a\": 1,\n \"a\": 2\n}"], + "number_overflow" => [:process, "1e400"], + "code_fence_stripped" => [:process, "```json\n{\"a\":1}\n```"], + "prefix_text_ignored" => [:process, 'Here is the json: {"a":1}'], + "suffix_text_ignored" => [:process, '{"a":1} thanks!'], + "wrapper_tag_stripped" => [:process, "{\"a\":1}"], + "extra_documents" => [:process_one, "{\"a\":1}\n{\"b\":2}"], + }.each do |label, (meth, input)| + it "#{label}: same (type, message, line, col) with and without the C extension" do + tuple = lambda do |accel| + w = nil + SmarterJSON.public_send(meth, input, acceleration: accel, on_warning: ->(x) { w ||= x }) + [w.type, w.message, w.line, w.col] + end + expect(tuple.call(true)).to eq(tuple.call(false)) + end + end + end +end From 451aeadebdf933b238b68b81ce3e9f7733a4e1f7 Mon Sep 17 00:00:00 2001 From: Tilo Sloboda Date: Thu, 11 Jun 2026 11:14:23 -0700 Subject: [PATCH 2/3] update --- CHANGELOG.md | 6 ++++-- README.md | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afed3ff..1a6a31c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,8 @@ > ⚠️ SmarterJSON **always returns an `Array`** of documents. > -> `SmarterJSON.process` / `SmarterJSON.process_file` return: -> +> `SmarterJSON.process` / `SmarterJSON.process_file` +> both return: > — `[]` for no doc > - `[doc]` for one doc > - `[d1, d2, …]` for several docs (NDJSON / JSONL / concatenated docs) @@ -14,6 +14,8 @@ ## 1.1.1 (2026-06-11) +RSpec tests: 1,070 → 1,097 + - The C extension now emits the same `on_warning` warnings as the pure-Ruby parser. `empty_value` and `duplicate_key` warnings name the offending key (and `duplicate_key` names the resolution strategy), and the warning text, line, and column are now identical whether or not the C extension is loaded. ## 1.1.0 (2026-06-09) diff --git a/README.md b/README.md index bfa3ce5..020991c 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ A lenient, fast JSON processor for Ruby. It extracts strict JSON, NDJSON, JSONL, ## Why SmarterJSON? +> 📖 **The thinking behind it:** [*Strict by Accident: Your JSON parser isn't broken, it's answering the wrong question*](https://dev.to/tilo_sloboda/strict-by-accident-your-json-parser-isnt-broken-its-answering-the-wrong-question-54f0) — why a data pipeline wants a lenient, recovery-first parser rather than a spec-policing one. + **Are you tired of seeing errors like these?** ``` From 6b93a1b96d338df785814a8b4f0162e881f915a7 Mon Sep 17 00:00:00 2001 From: Tilo Sloboda Date: Thu, 11 Jun 2026 11:20:06 -0700 Subject: [PATCH 3/3] Guard per-value position tracking behind on_warning (zero fast-path cost) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The duplicate-key warning position array (pptr) is only read when an on_warning handler is present, so allocate and write it only then. Parses without a handler — the common case — now do no extra allocation or per-push store; pptr stays NULL. Co-Authored-By: Claude Opus 4.8 --- ext/smarter_json/smarter_json.c | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/ext/smarter_json/smarter_json.c b/ext/smarter_json/smarter_json.c index d16a463..501921b 100644 --- a/ext/smarter_json/smarter_json.c +++ b/ext/smarter_json/smarter_json.c @@ -1372,7 +1372,9 @@ static void fj_pstack_free(void *p) { } static size_t fj_pstack_memsize(const void *p) { const fj_pstack *ps = (const fj_pstack *)p; - return sizeof(fj_pstack) + (size_t)ps->vcapa * (sizeof(VALUE) + sizeof(long)) + (size_t)ps->fcapa * sizeof(fj_frame); + return sizeof(fj_pstack) + (size_t)ps->vcapa * sizeof(VALUE) + + (ps->pptr ? (size_t)ps->vcapa * sizeof(long) : 0) + + (size_t)ps->fcapa * sizeof(fj_frame); } static const rb_data_type_t fj_pstack_type = { "smarter_json/pstack", @@ -1384,9 +1386,11 @@ static inline void fj_vpush(fj_state *st, fj_pstack *ps, VALUE v) { if (ps->vhead >= ps->vcapa) { ps->vcapa *= 2; REALLOC_N(ps->vptr, VALUE, ps->vcapa); - REALLOC_N(ps->pptr, long, ps->vcapa); + if (ps->pptr) REALLOC_N(ps->pptr, long, ps->vcapa); } - ps->pptr[ps->vhead] = st->pos; /* offset just past this value — the duplicate-key warning position */ + /* pptr is allocated only when on_warning is set; the fast path (no handler) does no + * extra store. The offset is just past this value — the duplicate-key warning position. */ + if (ps->pptr) ps->pptr[ps->vhead] = st->pos; ps->vptr[ps->vhead++] = v; } static inline void fj_fpush(fj_pstack *ps, long mark, int is_obj) { @@ -1406,7 +1410,7 @@ static VALUE fj_parse_iter(fj_state *st, int implicit_root) { int vss = 0; /* warnings: has a value landed in the current container since the last separator? */ ps->vptr = ALLOC_N(VALUE, 64); ps->vhead = 0; ps->vcapa = 64; - ps->pptr = ALLOC_N(long, 64); + ps->pptr = (st->on_warning != Qnil) ? ALLOC_N(long, 64) : NULL; /* only the warning path needs per-value positions */ ps->fptr = ALLOC_N(fj_frame, 16); ps->fhead = 0; ps->fcapa = 16; if (implicit_root) fj_fpush(ps, 0, 1); @@ -1445,7 +1449,7 @@ static VALUE fj_parse_iter(fj_state *st, int implicit_root) { if (b == '}') { VALUE hash; fj_advance(st, 1); - hash = fj_build_object(st, &ps->vptr[mark], &ps->pptr[mark], ps->vhead - mark); + hash = fj_build_object(st, &ps->vptr[mark], ps->pptr ? &ps->pptr[mark] : NULL, ps->vhead - mark); ps->vhead = mark; ps->fhead--; if (ps->fhead == 0) { result = hash; break; } @@ -1455,7 +1459,7 @@ static VALUE fj_parse_iter(fj_state *st, int implicit_root) { } if (b == -1) { if (implicit_root && ps->fhead == 1) { - result = fj_build_object(st, &ps->vptr[mark], &ps->pptr[mark], ps->vhead - mark); + result = fj_build_object(st, &ps->vptr[mark], ps->pptr ? &ps->pptr[mark] : NULL, ps->vhead - mark); break; } fj_error(st, "unterminated object");