Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions json/escape_quickcheck_wbtest.mbt
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// Copyright 2026 International Digital Economy Academy
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Property-based tests for the `Json::stringify` escape fast path: the SIMD
// `need_escape` must agree with the scalar reference, and `escape` must agree
// with a straightforward per-code-unit model and with the real parser,
// including at SIMD block boundaries and on surrogate halves.

///|
/// Builds a string whose code units are biased toward everything the escaper
/// branches on: quotes, backslashes, slashes, control characters, both sides
/// of the 0x20 boundary, and surrogate halves (whose high bit set exercises
/// the unsigned SIMD comparisons). With `allow_surrogates=false` the
/// surrogate range is remapped so the string is well-formed UTF-16.
fn adversarial_string(seeds : Array[Int], allow_surrogates~ : Bool) -> String {
let buf = StringBuilder(size_hint=seeds.length())
for seed in seeds {
let code : UInt16 = match seed & 0xF {
0 => '"'
1 => '\\'
2 => '/'
3 => '\n'
4 => 0x1F
5 => ' '
6 => 0x0C
7 => 0xD800
8 => 0xDFFF
9 => 0xFFFF
10 => 'a'
_ => ((seed >> 4) & 0xFFFF).to_uint16()
}
let code = if !allow_surrogates && code is (0xD800..=0xDFFF) {
code ^ 0x2000
} else {
code
}
buf.write_char(code.unsafe_to_char())
}
buf.to_string()
}

///|
/// Per-code-unit model of `escape`, written directly from the JSON string
/// grammar with no fast path and no SIMD.
fn model_escape(str : String, escape_slash : Bool) -> String {
let buf = StringBuilder(size_hint=str.length())
for code in str.code_units() {
match code.to_int() {
0x22 => buf.write_string("\\\"")
0x5C => buf.write_string("\\\\")
0x2F => buf.write_string(if escape_slash { "\\/" } else { "/" })
0x08 => buf.write_string("\\b")
0x09 => buf.write_string("\\t")
0x0A => buf.write_string("\\n")
0x0C => buf.write_string("\\f")
0x0D => buf.write_string("\\r")
c =>
if c < 0x20 {
buf.write_string("\\u00")
buf.write_string(c.to_byte().to_hex())
} else {
buf.write_char(code.unsafe_to_char())
}
}
}
buf.to_string()
}

///|
test "quickcheck: need_escape agrees with the scalar reference" {
@quickcheck.check(count=300, (input : (Array[Int], Bool)) => {
let (seeds, escape_slash) = input
let str = adversarial_string(seeds, allow_surrogates=true)
need_escape(str, escape_slash) ==
need_escape_scalar(str, escape_slash, 0, str.length())
})
// Also over ordinary ASCII-biased Unicode strings, which cover other
// lengths and multi-code-unit characters.
@quickcheck.check(count=300, (input : (String, Bool)) => {
let (str, escape_slash) = input
need_escape(str, escape_slash) ==
need_escape_scalar(str, escape_slash, 0, str.length())
})
}

///|
test "quickcheck: escape matches the per-code-unit model" {
@quickcheck.check(count=300, (input : (Array[Int], Bool)) => {
let (seeds, escape_slash) = input
let str = adversarial_string(seeds, allow_surrogates=true)
guard escape(str, escape_slash~) == model_escape(str, escape_slash) else {
return false
}
// The no-copy fast path must fire exactly when nothing is escapable.
(escape(str, escape_slash~) == str) ==
!need_escape_scalar(str, escape_slash, 0, str.length())
})
@quickcheck.check(count=300, (input : (String, Bool)) => {
let (str, escape_slash) = input
escape(str, escape_slash~) == model_escape(str, escape_slash)
})
}

///|
/// Roundtrip through the real parser, whose string lexer is an independent
/// implementation of the same grammar. Surrogate halves are excluded because
/// well-formed MoonBit strings contain no unpaired surrogates.
test "quickcheck: stringify/parse roundtrip on adversarial strings" {
@quickcheck.check(count=300, (input : (Array[Int], Bool)) => {
let (seeds, escape_slash) = input
let json = Json::string(adversarial_string(seeds, allow_surrogates=false))
parse(json.stringify(escape_slash~)) == json
})
}

///|
/// Exhaustively places each escapable code unit at every position of an
/// otherwise clean string, for every length spanning several 8-unit SIMD
/// blocks, so block starts, block interiors, and the scalar tail are all
/// covered for both `escape_slash` values.
test "need_escape boundary sweep" {
let specials : Array[UInt16] = ['"', '\\', '\n', 0x00, 0x1F]
fn place(len : Int, pos : Int, special : UInt16) -> String {
let buf = StringBuilder(size_hint=len)
for i in 0..<len {
if i == pos {
buf.write_char(special.unsafe_to_char())
} else {
buf.write_char('a')
}
}
buf.to_string()
}

for len in 0..<=24 {
let clean = "a".repeat(len)
assert_false(need_escape(clean, false))
assert_false(need_escape(clean, true))
for pos in 0..<len {
for special in specials {
assert_true(need_escape(place(len, pos, special), false))
assert_true(need_escape(place(len, pos, special), true))
}
let with_slash = place(len, pos, '/')
assert_false(need_escape(with_slash, false))
assert_true(need_escape(with_slash, true))
}
}
}
33 changes: 33 additions & 0 deletions json/escape_simd_wbtest.mbt
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright 2026 International Digital Economy Academy
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

///|
test "need_escape SIMD blocks and scalar tail" {
let cases : Array[(String, Bool, Bool)] = [
("", false, false),
("0123456", false, false),
("01234567", false, false),
("012345678", false, false),
("0123456\"", false, true),
("01234567\\", false, true),
("012345678901234\n", false, true),
("01234567/89", false, false),
("01234567/89", true, true),
("0123456😀", false, false),
]
for case in cases {
let (str, escape_slash, expected) = case
@debug.assert_eq(need_escape(str, escape_slash), expected)
}
}
91 changes: 80 additions & 11 deletions json/json.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -382,34 +382,103 @@ pub fn Json::stringify(
buf.to_string()
}

///|
#inline
fn need_escape_scalar(
str : String,
escape_slash : Bool,
start : Int,
end : Int,
) -> Bool {
for i in start..<end {
let code = str.unsafe_get(i)
if code == '"' ||
code == '\\' ||
code < ' ' ||
(escape_slash && code == '/') {
return true
}
}
false
}

///|
#cfg(not(any(target="native", target="wasm")))
#warnings("-unused_value")
fn suppress_unused_v128_import_on_scalar_targets() -> Unit {
ignore(@v128.i16x8_splat(0))
}

///|
#cfg(not(any(target="native", target="wasm")))
fn need_escape(str : String, escape_slash : Bool) -> Bool {
need_escape_scalar(str, escape_slash, 0, str.length())
}

///|
// Scan eight UTF-16 code units at a time on linear-memory backends, then scan
// the remaining tail one code unit at a time.
#cfg(any(target="native", target="wasm"))
fn need_escape(str : String, escape_slash : Bool) -> Bool {
let len = str.length()
guard len >= 8 else { return need_escape_scalar(str, escape_slash, 0, len) }
let control_limit = @v128.i16x8_splat(' ')
let quote = @v128.i16x8_splat('"')
let backslash = @v128.i16x8_splat('\\')
let slash = @v128.i16x8_splat('/')
let tail_start = for pos = 0; pos + 8 <= len; {
let block = @v128.v128_load_i16x8(str, pos)
let escaped = @v128.v128_or_(
@v128.i16x8_lt_u(block, control_limit),
@v128.v128_or_(
@v128.i16x8_eq(block, quote),
@v128.i16x8_eq(block, backslash),
),
)
let escaped = if escape_slash {
@v128.v128_or_(escaped, @v128.i16x8_eq(block, slash))
} else {
escaped
}
if @v128.v128_any_true(escaped) {
return true
}
continue pos + 8
} nobreak {
pos
}
need_escape_scalar(str, escape_slash, tail_start, len)
}

///|
fn escape(str : String, escape_slash~ : Bool) -> String {
let buf = StringBuilder(size_hint=str.length())
for c in str {
match c {
let len = str.length()
if !need_escape(str, escape_slash) {
return str
}
let buf = StringBuilder(size_hint=len)
for code in str.code_units() {
match code {
'"' => buf.write_string("\\\"")
'\\' => buf.write_string("\\\\")
'/' =>
if escape_slash {
buf.write_string("\\/")
} else {
buf.write_char(c)
buf.write_char('/')
}
'\n' => buf.write_string("\\n")
'\r' => buf.write_string("\\r")
'\b' => buf.write_string("\\b")
'\t' => buf.write_string("\\t")
_ => {
let code = c.to_int()
if code == 0x0C {
buf.write_string("\\f")
} else if code < ' ' {
0x0C => buf.write_string("\\f")
_ =>
if code < ' ' {
buf.write_string("\\u00")
buf.write_string(code.to_byte().to_hex())
} else {
buf.write_char(c)
buf.write_char(code.unsafe_to_char())
}
}
}
}
buf.to_string()
Expand Down
5 changes: 5 additions & 0 deletions json/moon.pkg
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
"moonbitlang/core/internal/strconv" @internal/strconv,
"moonbitlang/core/option",
"moonbitlang/core/buffer",
"moonbitlang/core/v128", // Used only by the native and wasm SIMD implementation.
}

import {
Expand All @@ -21,3 +22,7 @@ import {
"moonbitlang/core/quickcheck/shrink",
"moonbitlang/core/quickcheck/splitmix",
} for "test"

import {
"moonbitlang/core/quickcheck",
} for "wbtest"
45 changes: 45 additions & 0 deletions json/stringify_escape_bench_test.mbt
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright 2026 International Digital Economy Academy
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

///|
let stringify_escape_bench_count = 2048

///|
fn make_stringify_escape_bench_array(value : String) -> Json {
Array::makei(stringify_escape_bench_count, i => value + i.to_string()).to_json()
}

///|
test "bench Json::stringify strings no escape n=2048" (it : @bench.T) {
let json = make_stringify_escape_bench_array("moonbit-core-json-value-")
it.bench(fn() { it.keep(json.stringify().length()) })
}

///|
test "bench Json::stringify strings slash no escape n=2048" (it : @bench.T) {
let json = make_stringify_escape_bench_array("moonbit/core/json/value/")
it.bench(fn() { it.keep(json.stringify().length()) })
}

///|
test "bench Json::stringify strings slash escaped n=2048" (it : @bench.T) {
let json = make_stringify_escape_bench_array("moonbit/core/json/value/")
it.bench(fn() { it.keep(json.stringify(escape_slash=true).length()) })
}

///|
test "bench Json::stringify strings quotes controls n=2048" (it : @bench.T) {
let json = make_stringify_escape_bench_array("moonbit\"core\\json\nvalue")
it.bench(fn() { it.keep(json.stringify().length()) })
}
Loading