Skip to content

Commit 23cb5ee

Browse files
bobzhangclaude
andcommitted
fix(json): tighten the surrogate docs and error coverage
Follow-up nits from review. Two doc claims were imprecise. "Each escape must denote a scalar value" is not true of either half of an accepted pair, so it now says an escape must denote a scalar on its own *or* be one half of a correctly ordered pair. And the promise that a failed pairing always raises `InvalidChar` at the backslash overstated it: running out of input still raises `InvalidEof`, and a malformed second escape is reported as the hex-digit error it is. Both are qualified now. The comparison to other parsers is narrowed too — serde_json rejects when parsing into `String`/`Value` while its byte mode admits WTF-8, and it is Go's `encoding/json` that substitutes U+FFFD, its v2 parser being stricter. The test preamble claimed the old parser accepted every rejection below it, which was false: the ones that also run out of input or misspell the second escape were already errors. Corrected, and the escaped-leading + raw-trailing case moved last, since that is the one that aborted the process outright before this change and would otherwise mask the blocks after it. Added the exact assertions that were missing: the second mismatch arm, a second escape that is well formed but is not a trailing surrogate, and a trailing backslash at EOF — all pinning the one documented position. Also covers the two arms whose `shift` was corrected, where an unknown escape naming a non-BMP character and a non-BMP character among hex digits are now reported whole rather than as a broken half one column further on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent df7811f commit 23cb5ee

3 files changed

Lines changed: 75 additions & 20 deletions

File tree

‎json/README.mbt.md‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,11 @@ test "parse and validate jsons" {
5454
#### What may appear inside a string
5555

5656
Every string in a parsed document is well-formed Unicode, so each `\uXXXX`
57-
escape has to denote a Unicode scalar value. An escaped leading surrogate
58-
must be followed immediately by an escaped trailing surrogate, and the pair
59-
decodes to the single character it stands for; an escape that cannot pair up
60-
is a parse error.
57+
escape must denote a Unicode scalar value on its own or be one half of a
58+
correctly ordered surrogate pair. An escaped leading surrogate must be
59+
followed immediately by an escaped trailing surrogate, and the pair decodes
60+
to the single character it stands for; an escape that cannot pair up is a
61+
parse error, reported at the backslash that opens it.
6162

6263
```mbt check
6364
///|
@@ -76,7 +77,8 @@ so the alternatives would be to hand one back that is not, or to substitute
7677
U+FFFD and lose the difference between two distinct keys. Note that
7778
`JSON.stringify` in JavaScript does emit lone surrogates this way, so a
7879
document JavaScript and Python accept can be rejected here — as it is by
79-
Rust's serde_json; Go substitutes U+FFFD instead.
80+
Rust's serde_json when parsing into `String` or `Value`; Go's
81+
`encoding/json` substitutes U+FFFD instead.
8082

8183
### Object Navigation
8284

‎json/lex_string_test.mbt‎

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -99,14 +99,17 @@ test "lex_hex_digits accepts all hex digit ranges" {
9999
// `ParseError`.
100100
//
101101
// The cases are split by shape so that one failing assertion cannot mask the
102-
// rest; the old parser accepted all of the rejections below, and aborted
103-
// outright on the escaped-leading + raw-trailing one.
102+
// rest. The old parser accepted the *unpaired-surrogate* spellings below —
103+
// it already rejected the ones that also run out of input or misspell the
104+
// second escape — and aborted the process outright on the escaped-leading +
105+
// raw-trailing one, which is why that case is kept last.
104106

105107
///|
106108
test "unpaired leading-surrogate escape is rejected" {
107-
// Nothing after it at all, and a closing quote after it.
108-
assert_false(@json.valid("\"\\uD800"))
109+
// A closing quote after it, and nothing after it at all. (The second was
110+
// already an EOF error before the surrogate rule.)
109111
assert_false(@json.valid("\"\\uD800\""))
112+
assert_false(@json.valid("\"\\uD800"))
110113
// A character that is not the start of an escape.
111114
assert_false(@json.valid("\"\\uD800x\""))
112115
// An escape that is not `\u`.
@@ -128,12 +131,6 @@ test "bare trailing-surrogate escape is rejected" {
128131
assert_false(@json.valid("\"\\uDE00\\uD83D\""))
129132
}
130133

131-
///|
132-
test "escaped and raw surrogate halves do not pair up" {
133-
let lone_low = String::from_array([(0xDC00).unsafe_to_char()])
134-
assert_false(@json.valid("\"\\uD800" + lone_low + "\""))
135-
}
136-
137134
///|
138135
test "unpaired surrogate escapes are rejected in object keys too" {
139136
assert_false(@json.valid("{\"\\uD800\": 1}"))
@@ -168,11 +165,50 @@ test "the surrogate error names the escape that could not pair up" {
168165
#|InvalidChar({ line: 1, column: 1 }, '\\')
169166
),
170167
)
171-
// Running out of input is still an EOF error rather than a character one.
168+
// The same position whichever way the pairing fails: a follower that is
169+
// not a backslash, a backslash not followed by `u`, and a second escape
170+
// that is well formed but is not a trailing surrogate.
171+
debug_inspect(
172+
expect_parse_error("\"\\uD800\\n\"", "expected InvalidChar"),
173+
content=(
174+
#|InvalidChar({ line: 1, column: 1 }, '\\')
175+
),
176+
)
177+
debug_inspect(
178+
expect_parse_error("\"\\uD800\\u0041\"", "expected InvalidChar"),
179+
content=(
180+
#|InvalidChar({ line: 1, column: 1 }, '\\')
181+
),
182+
)
183+
// Running out of input is still an EOF error rather than a character one,
184+
// including on a trailing backslash where the second escape should start.
172185
debug_inspect(
173186
expect_parse_error("\"\\uD800", "expected InvalidEof"),
174187
content="InvalidEof",
175188
)
189+
debug_inspect(
190+
expect_parse_error("\"\\uD800\\", "expected InvalidEof"),
191+
content="InvalidEof",
192+
)
193+
}
194+
195+
///|
196+
test "an escape naming a non-BMP character reports it whole" {
197+
// These two arms read a character and then step back by its width. Using
198+
// a fixed step of one code unit landed inside a non-BMP character and
199+
// reported a broken half one column further on.
200+
debug_inspect(
201+
expect_parse_error("\"\\\u{1F600}\"", "expected InvalidChar"),
202+
content=(
203+
#|InvalidChar({ line: 1, column: 2 }, '😀')
204+
),
205+
)
206+
debug_inspect(
207+
expect_parse_error("\"\\u1\u{1F600}23\"", "expected InvalidChar"),
208+
content=(
209+
#|InvalidChar({ line: 1, column: 4 }, '😀')
210+
),
211+
)
176212
}
177213

178214
///|
@@ -194,3 +230,12 @@ test "valid surrogate pairs decode to one scalar value" {
194230
// character, which is a scalar value like any other.
195231
assert_true(@json.parse("\"\\u0041\\uFFFD\"") == Json::string("A\u{FFFD}"))
196232
}
233+
234+
///|
235+
test "escaped and raw surrogate halves do not pair up" {
236+
// Kept last: before the surrogate rule this input aborted the process
237+
// rather than raising, so a regression here would take the whole test
238+
// binary with it.
239+
let lone_low = String::from_array([(0xDC00).unsafe_to_char()])
240+
assert_false(@json.valid("\"\\uD800" + lone_low + "\""))
241+
}

‎json/parse.mbt‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,17 @@ pub fn valid(input : StringView) -> Bool {
3232
/// ## What strings may contain
3333
///
3434
/// Every string in the result is well-formed Unicode: each `\uXXXX` escape
35-
/// has to denote a Unicode scalar value. An escaped leading surrogate
35+
/// must denote a Unicode scalar value on its own, or be one half of a
36+
/// correctly ordered surrogate pair. An escaped leading surrogate
3637
/// (`\uD800`–`\uDBFF`) must therefore be followed immediately by an escaped
3738
/// trailing surrogate (`\uDC00`–`\uDFFF`), and the pair is decoded as the
38-
/// one character it stands for; an escape that cannot pair up raises
39-
/// `InvalidChar`, positioned at the backslash that opens it.
39+
/// one character it stands for.
40+
///
41+
/// An escape that cannot pair up raises `InvalidChar` positioned at the
42+
/// backslash that opens it — that one position, whatever the scan actually
43+
/// stopped on. Input that simply runs out still raises `InvalidEof`, and a
44+
/// second escape that is itself malformed is reported as the hex-digit
45+
/// error it is, at the offending digit.
4046
///
4147
/// This is a limit on what a string may *contain*, which RFC 8259 §9 leaves
4248
/// to the implementation — not a claim about which documents are
@@ -50,7 +56,9 @@ pub fn valid(input : StringView) -> Bool {
5056
///
5157
/// The cost is real: `JSON.stringify` in JavaScript emits lone surrogates as
5258
/// `\uXXXX`, so some JSON that JavaScript and Python accept is rejected
53-
/// here. Rust's serde_json rejects it too; Go substitutes U+FFFD.
59+
/// here. Rust's serde_json rejects it too when parsing into `String` or
60+
/// `Value`, though its byte-oriented mode admits WTF-8; Go's `encoding/json`
61+
/// substitutes U+FFFD, while its experimental v2 parser is stricter.
5462
#label_migration(max_nesting_depth, fill=false)
5563
pub fn parse(
5664
input : StringView,

0 commit comments

Comments
 (0)