use crate::error::{Error, Result, SyntaxErrorKind};
use crate::metal::{Dispatch, GpuBuffer, MetalContext, MjParams, THREADGROUP_SIZE};
use crate::stage::{Stage, Stage1Buffers};
use crate::tape::{STRING_RECORD_HEADER_BYTES, make_string};
use super::stage1::Stage1Output;
use super::stage2::{Stage2, Stage2Accepted, Stage2Output, Stage2Run};
pub const ERR_STRING_ESCAPE: u32 = 23;
pub const ERR_STRING_CONTROL: u32 = 24;
pub const LONG_STRING_THRESHOLD: u32 = 16384;
const STRINGBUF_POISON: u8 = 0xA5;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StringsOutput {
pub stage2: Stage2Output,
pub record_offsets: Vec<u64>,
pub stringbuf: Vec<u8>,
pub tape: Vec<u64>,
pub long_string_fixups: Vec<u32>,
pub error: Option<u64>,
}
impl StringsOutput {
#[must_use]
pub fn error_offset_code(&self) -> Option<(u64, u32)> {
self.error.map(|e| (e >> 32, e as u32))
}
#[must_use]
pub fn record_content(&self, s: usize) -> &[u8] {
let offset = usize::try_from(self.record_offsets[s]).expect("offset fits usize");
let header: [u8; STRING_RECORD_HEADER_BYTES] = self.stringbuf
[offset..offset + STRING_RECORD_HEADER_BYTES]
.try_into()
.expect("4 header bytes");
let len = u32::from_le_bytes(header) as usize;
let content_start = offset + STRING_RECORD_HEADER_BYTES;
let content = &self.stringbuf[content_start..content_start + len];
assert_eq!(
self.stringbuf[content_start + len],
0,
"record {s} must be NUL-terminated"
);
content
}
fn rejected(stage2: Stage2Output, packed_error: u64) -> Self {
Self {
stage2,
error: Some(packed_error),
..Self::default()
}
}
}
#[derive(Debug)]
pub struct StringsStage {
stage2: Stage2,
offsets: Stage,
unescape: Stage,
finalize: Stage,
}
impl StringsStage {
#[must_use]
pub const fn new() -> Self {
Self {
stage2: Stage2::new(),
offsets: Stage::new("string_record_offsets"),
unescape: Stage::new("strings_unescape"),
finalize: Stage::new("structure_finalize"),
}
}
pub fn run(&self, ctx: &MetalContext, input: &[u8]) -> Result<StringsOutput> {
let mut bufs1 = Stage1Buffers::new(ctx, input)?;
self.run_with_buffers(ctx, &mut bufs1)
}
pub fn run_with_buffers(
&self,
ctx: &MetalContext,
bufs1: &mut Stage1Buffers,
) -> Result<StringsOutput> {
let Stage2Accepted {
bufs2,
header,
gpu_seconds: _,
} = match self.stage2.run_to_lists(ctx, crate::pool::Alloc::Direct, bufs1)? {
Stage2Run::Rejected(rejection) => {
let out = Stage2Output::from_rejection(bufs1, &rejection);
return Ok(StringsOutput::rejected(out, rejection.packed));
}
Stage2Run::Accepted(run) => *run,
};
let stage1 = Stage1Output::snapshot(bufs1, &header, None);
let stage2_out = Stage2::collect_outputs(stage1, &bufs2, &header);
let token_total = bufs2.token_total();
let string_total =
usize::try_from(header.string_total).expect("string_total fits usize");
let stringbuf_total =
usize::try_from(header.stringbuf_total).expect("stringbuf_total fits usize");
let tape_words =
usize::try_from(header.tape_word_total).expect("tape_word_total fits usize") + 2;
let mut tape_buf = GpuBuffer::alloc(ctx, tape_words * size_of::<u64>())?;
tape_buf.contents_mut().fill(0);
if string_total == 0 {
return Ok(StringsOutput {
stage2: stage2_out,
tape: tape_buf.as_slice::<u64>().to_vec(),
..StringsOutput::default()
});
}
for stage in [&self.offsets, &self.unescape, &self.finalize] {
let max = stage.pipeline(ctx)?.max_total_threads_per_threadgroup();
assert!(
max >= THREADGROUP_SIZE,
"kernel `{}` supports only {max} threads/threadgroup (< {THREADGROUP_SIZE})",
stage.name()
);
}
let mut record_offsets = GpuBuffer::alloc(ctx, string_total * size_of::<u64>())?;
let mut stringbuf = GpuBuffer::alloc(ctx, stringbuf_total)?;
stringbuf.contents_mut().fill(STRINGBUF_POISON); let str_chunks = string_total.div_ceil(THREADGROUP_SIZE);
let mut chunk_error = GpuBuffer::alloc(ctx, str_chunks * size_of::<u64>())?;
let mut long_count = GpuBuffer::alloc(ctx, size_of::<u32>())?;
long_count.as_mut_slice::<u32>()[0] = 0;
let mut long_list = GpuBuffer::alloc(ctx, string_total * size_of::<u32>())?;
let input_len = bufs1.input_len() as u64;
let tok_chunks = bufs2.chunks();
let token_params = MjParams {
input_len,
element_count: token_total as u64,
..Default::default()
};
let string_params = MjParams {
input_len,
element_count: string_total as u64,
reserved0: tape_words as u64, reserved1: token_total as u64, };
let fold_params = MjParams {
input_len,
element_count: str_chunks as u64,
..Default::default()
};
{
let mut batch = ctx.batch()?;
let h_input = batch.bind_read(&bufs1.input);
let h_pos = batch.bind_read(bufs1.tok_pos.as_ref().expect("stage 2 allocated tokens"));
let h_kind =
batch.bind_read(bufs1.tok_kind.as_ref().expect("stage 2 allocated tokens"));
let h_counts = batch.bind_read(&bufs2.chunk_counts);
let h_sbytes = batch.bind_read(&bufs2.chunk_string_bytes);
let h_strings =
batch.bind_read(bufs2.string_tokens.as_ref().expect("lists allocated"));
let h_tape_ofs = batch.bind_read(&bufs2.tape_ofs);
let h_offsets = batch.bind_write(&mut record_offsets);
let h_sb = batch.bind_write(&mut stringbuf);
let h_tape = batch.bind_write(&mut tape_buf);
let h_err = batch.bind_write(&mut chunk_error);
let h_lcount = batch.bind_write(&mut long_count);
let h_llist = batch.bind_write(&mut long_list);
let h_header = batch.bind_write(&mut bufs1.header);
self.offsets.encode(
&mut batch,
&[h_pos, h_kind, h_counts, h_sbytes, h_offsets],
Some(&token_params),
Dispatch::Threadgroups(tok_chunks),
)?;
self.unescape.encode(
&mut batch,
&[
h_input, h_pos, h_strings, h_offsets, h_tape_ofs, h_sb, h_tape, h_err,
h_lcount, h_llist,
],
Some(&string_params),
Dispatch::Threadgroups(str_chunks),
)?;
self.finalize.encode(
&mut batch,
&[h_err, h_header],
Some(&fold_params),
Dispatch::Threadgroups(1),
)?;
batch.commit_and_wait()?;
}
let long_total = (long_count.as_slice::<u32>()[0] as usize).min(string_total);
let mut long_string_fixups = long_list.as_slice::<u32>()[..long_total].to_vec();
long_string_fixups.sort_unstable();
let raw_input_len = bufs1.input_len();
let patch_error = patch_long_strings(
&bufs1.input.contents()[..raw_input_len],
bufs1
.tok_pos
.as_ref()
.expect("stage 2 allocated tokens")
.as_slice::<u32>(),
bufs2
.string_tokens
.as_ref()
.expect("lists allocated")
.as_slice::<u32>(),
record_offsets.as_slice::<u64>(),
bufs2.tape_ofs.as_slice::<u32>(),
&long_string_fixups,
stringbuf.contents_mut(),
tape_buf.as_mut_slice::<u64>(),
);
let header = bufs1.read_header();
let header_error = header.first_error().map(|(o, c)| (o << 32) | u64::from(c));
let error = match (header_error, patch_error) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
};
if let Some(packed) = error {
let mut out = StringsOutput::rejected(stage2_out, packed);
out.long_string_fixups = long_string_fixups;
return Ok(out);
}
Ok(StringsOutput {
stage2: stage2_out,
record_offsets: record_offsets.as_slice::<u64>().to_vec(),
stringbuf: stringbuf.contents().to_vec(),
tape: tape_buf.as_slice::<u64>().to_vec(),
long_string_fixups,
error: None,
})
}
}
impl Default for StringsStage {
fn default() -> Self {
Self::new()
}
}
pub fn run_strings(ctx: &MetalContext, input: &[u8]) -> Result<StringsOutput> {
StringsStage::new().run(ctx, input)
}
#[allow(clippy::too_many_arguments)] pub fn patch_long_strings(
input: &[u8],
tok_pos: &[u32],
string_tokens: &[u32],
record_offsets: &[u64],
tape_ofs: &[u32],
long_fixups: &[u32],
stringbuf: &mut [u8],
tape: &mut [u64],
) -> Option<u64> {
let mut first_error: Option<u64> = None;
for &s in long_fixups {
let t = string_tokens[s as usize] as usize;
let open_pos = tok_pos[t] as usize;
let close_pos = tok_pos[t + 1] as usize;
let raw = &input[open_pos + 1..close_pos];
let base = u32::try_from(open_pos + 1).expect("input is capped below u32::MAX");
match crate::unescape::unescape(raw, base) {
Ok(bytes) => {
let rec = usize::try_from(record_offsets[s as usize]).expect("offset fits usize");
let len = u32::try_from(bytes.len()).expect("string longer than u32::MAX bytes");
stringbuf[rec..rec + STRING_RECORD_HEADER_BYTES]
.copy_from_slice(&len.to_le_bytes());
let content = rec + STRING_RECORD_HEADER_BYTES;
stringbuf[content..content + bytes.len()].copy_from_slice(&bytes);
stringbuf[content + bytes.len()] = 0;
stringbuf[content + bytes.len() + 1..content + raw.len() + 1].fill(0);
tape[tape_ofs[t] as usize] = make_string(record_offsets[s as usize]);
}
Err(Error::Syntax { offset, kind }) => {
let code = match kind {
SyntaxErrorKind::InvalidStringEscape => ERR_STRING_ESCAPE,
SyntaxErrorKind::ControlCharacterInString => ERR_STRING_CONTROL,
other => panic!("the unescaper cannot produce {other:?}"),
};
let packed = (offset << 32) | u64::from(code);
first_error = Some(first_error.map_or(packed, |e| e.min(packed)));
}
Err(other) => panic!("the unescaper cannot produce {other:?}"),
}
}
first_error
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tape::make_string;
fn ctx_or_skip(test: &str) -> Option<MetalContext> {
match MetalContext::new() {
Ok(ctx) => Some(ctx),
Err(err) => {
if std::env::var_os("METAL_JSON_REQUIRE_GPU").is_some_and(|v| v == "1") {
panic!("METAL_JSON_REQUIRE_GPU=1 but no usable Metal device: {err}");
}
eprintln!("SKIP {test}: no usable Metal device here ({err})");
None
}
}
}
fn u_esc(hex: &str) -> String {
format!("{}u{hex}", '\\')
}
fn quoted(parts: &[&str]) -> Vec<u8> {
let mut s = String::from("\"");
for p in parts {
s.push_str(p);
}
s.push('"');
s.into_bytes()
}
fn assert_strings_empty(out: &StringsOutput, label: &str) {
assert!(out.record_offsets.is_empty(), "{label}: no record offsets");
assert!(out.stringbuf.is_empty(), "{label}: no string buffer");
assert!(out.tape.is_empty(), "{label}: no tape");
}
fn contents(out: &StringsOutput) -> Vec<Vec<u8>> {
(0..out.record_offsets.len())
.map(|s| out.record_content(s).to_vec())
.collect()
}
fn root_content(stage: &StringsStage, ctx: &MetalContext, input: &[u8]) -> Vec<u8> {
let out = stage.run(ctx, input).unwrap();
assert_eq!(
out.error,
None,
"{:?} must unescape cleanly",
String::from_utf8_lossy(input)
);
assert_eq!(out.record_offsets, vec![0], "one root string record");
assert_eq!(out.tape.len(), 3);
assert_eq!(out.tape[1], make_string(0));
out.record_content(0).to_vec()
}
fn expect_err(stage: &StringsStage, ctx: &MetalContext, input: &[u8], code: u32) -> u64 {
let out = stage.run(ctx, input).unwrap();
let (offset, got_code) = out
.error_offset_code()
.unwrap_or_else(|| panic!("expected an error for {:?}", String::from_utf8_lossy(input)));
assert_eq!(
got_code,
code,
"code for {:?}",
String::from_utf8_lossy(input)
);
assert_strings_empty(&out, "rejected");
assert!(
!out.stage2.string_tokens.is_empty(),
"{:?}: stage-2 outputs kept on a K11 rejection",
String::from_utf8_lossy(input)
);
offset
}
#[test]
fn msl_error_codes_match_the_rust_constants() {
let src = include_str!("../../shaders/13_strings.metal");
for (name, value) in [
("MJ_ERR_STRING_ESCAPE", ERR_STRING_ESCAPE),
("MJ_ERR_STRING_CONTROL", ERR_STRING_CONTROL),
("MJ_LONG_STRING_THRESHOLD", LONG_STRING_THRESHOLD),
] {
let needle = format!("constant constexpr uint {name} = {value};");
assert!(
src.contains(&needle),
"shaders/13_strings.metal must define `{needle}`"
);
}
const {
assert!(ERR_STRING_ESCAPE > super::super::ERR_EMPTY_INPUT);
assert!(ERR_STRING_CONTROL > super::super::ERR_EMPTY_INPUT);
assert!(ERR_STRING_ESCAPE != ERR_STRING_CONTROL);
}
}
#[test]
fn worked_example_strings_fill_their_tape_holes() {
let Some(ctx) = ctx_or_skip("worked_example_strings_fill_their_tape_holes") else {
return;
};
let out = StringsStage::new()
.run(&ctx, br#"{"a":[1,2.5],"b":"x\n"}"#)
.unwrap();
assert_eq!(out.error, None);
assert_eq!(out.record_offsets, vec![0, 6, 12]);
assert_eq!(out.stringbuf.len(), 20);
assert_eq!(contents(&out), vec![b"a".to_vec(), b"b".to_vec(), b"x\n".to_vec()]);
assert_eq!(&out.stringbuf[0..6], &[1, 0, 0, 0, b'a', 0]);
assert_eq!(&out.stringbuf[6..12], &[1, 0, 0, 0, b'b', 0]);
assert_eq!(&out.stringbuf[12..19], &[2, 0, 0, 0, b'x', 0x0A, 0]);
assert_eq!(out.stringbuf[19], 0, "slot gap byte is zero-filled");
assert_eq!(out.tape.len(), 13);
let mut want = vec![0u64; 13];
want[2] = make_string(0);
want[9] = make_string(6);
want[10] = make_string(12);
assert_eq!(out.tape, want);
assert_eq!(out.stage2.string_tokens, vec![1, 10, 13]);
}
#[test]
fn every_single_escape_and_unicode_escape_unescapes_exactly() {
let Some(ctx) = ctx_or_skip("every_single_escape_and_unicode_escape_unescapes_exactly")
else {
return;
};
let stage = StringsStage::new();
assert_eq!(
root_content(&stage, &ctx, br#""\" \\ \/ \b \f \n \r \t""#),
b"\" \\ / \x08 \x0C \n \r \t"
);
assert_eq!(root_content(&stage, &ctx, br#""hello""#), b"hello");
assert_eq!(root_content(&stage, &ctx, b"\"a\x7Fb\""), b"a\x7Fb");
assert_eq!(
root_content(&stage, &ctx, "\"héllo 😀\"".as_bytes()),
"héllo 😀".as_bytes()
);
let cases: &[(&str, &[u8])] = &[
("0041", b"A"),
("00e9", "é".as_bytes()),
("00E9", "é".as_bytes()),
("2603", "\u{2603}".as_bytes()),
("FFFF", "\u{FFFF}".as_bytes()),
];
for &(hex, want) in cases {
assert_eq!(
root_content(&stage, &ctx, "ed(&[&u_esc(hex)])),
want,
"{hex}"
);
}
assert_eq!(
root_content(&stage, &ctx, "ed(&["a", &u_esc("0000"), "b"])),
b"a\0b"
);
}
#[test]
fn surrogate_pairs_combine_up_to_u10ffff() {
let Some(ctx) = ctx_or_skip("surrogate_pairs_combine_up_to_u10ffff") else {
return;
};
let stage = StringsStage::new();
let cases: &[(&str, &str, &str)] = &[
("D83D", "DE00", "\u{1F600}"), ("d83d", "de00", "\u{1F600}"), ("d834", "dd1e", "\u{1D11E}"), ("D800", "DC00", "\u{10000}"), ("DBFF", "DFFF", "\u{10FFFF}"), ];
for &(hi, lo, want) in cases {
assert_eq!(
root_content(&stage, &ctx, "ed(&[&u_esc(hi), &u_esc(lo)])),
want.as_bytes(),
"{hi}/{lo}"
);
}
}
#[test]
fn rejections_report_reference_offsets_and_codes() {
let Some(ctx) = ctx_or_skip("rejections_report_reference_offsets_and_codes") else {
return;
};
let stage = StringsStage::new();
assert_eq!(expect_err(&stage, &ctx, br#""\x41""#, ERR_STRING_ESCAPE), 1);
assert_eq!(
expect_err(&stage, &ctx, "ed(&[&u_esc("12")]), ERR_STRING_ESCAPE),
1, );
assert_eq!(expect_err(&stage, &ctx, br#""\uZZZZ""#, ERR_STRING_ESCAPE), 1);
assert_eq!(expect_err(&stage, &ctx, br#""ab\q""#, ERR_STRING_ESCAPE), 3);
for parts in [
vec![u_esc("D800")],
vec![u_esc("D800"), u_esc("0041")],
vec![u_esc("D800"), "x".to_owned()],
vec![u_esc("DC00")],
vec![u_esc("DE00"), u_esc("D83D")],
] {
let part_refs: Vec<&str> = parts.iter().map(String::as_str).collect();
let input = quoted(&part_refs);
assert_eq!(
expect_err(&stage, &ctx, &input, ERR_STRING_ESCAPE),
1,
"{parts:?}"
);
}
assert_eq!(
expect_err(&stage, &ctx, b"\"a\tb\"", ERR_STRING_CONTROL),
2
);
assert_eq!(
expect_err(&stage, &ctx, b"\"a\nb\"", ERR_STRING_CONTROL),
2
);
assert_eq!(
expect_err(&stage, &ctx, b"\"a\x01b\"", ERR_STRING_CONTROL),
2
);
assert_eq!(
expect_err(&stage, &ctx, b"\"a\x1Fb\"", ERR_STRING_CONTROL),
2
);
assert_eq!(
expect_err(&stage, &ctx, br#"["ok","\q"]"#, ERR_STRING_ESCAPE),
7
);
assert_eq!(
expect_err(&stage, &ctx, br#"["\q","\p"]"#, ERR_STRING_ESCAPE),
2
);
assert_eq!(
expect_err(&stage, &ctx, b"[\"a\x06b\",\"\\q\"]", ERR_STRING_CONTROL),
3
);
}
#[test]
fn fast_path_seams_handle_specials_at_every_offset() {
let Some(ctx) = ctx_or_skip("fast_path_seams_handle_specials_at_every_offset") else {
return;
};
let stage = StringsStage::new();
for k in 0..=33usize {
let mut input = b"\"".to_vec();
input.extend(std::iter::repeat_n(b'a', k));
input.extend_from_slice(br"\n");
input.extend(std::iter::repeat_n(b'b', 40));
input.push(b'"');
let mut want = vec![b'a'; k];
want.push(b'\n');
want.extend(std::iter::repeat_n(b'b', 40));
assert_eq!(root_content(&stage, &ctx, &input), want, "escape at {k}");
let mut input = b"\"".to_vec();
input.extend(std::iter::repeat_n(b'a', k));
input.extend_from_slice(u_esc("D83D").as_bytes());
input.extend_from_slice(u_esc("DE00").as_bytes());
input.extend(std::iter::repeat_n(b'c', 20));
input.push(b'"');
let mut want = vec![b'a'; k];
want.extend_from_slice("\u{1F600}".as_bytes());
want.extend(std::iter::repeat_n(b'c', 20));
assert_eq!(root_content(&stage, &ctx, &input), want, "pair at {k}");
let mut input = b"\"".to_vec();
input.extend(std::iter::repeat_n(b'a', k));
input.push(0x01);
input.extend(std::iter::repeat_n(b'b', 8));
input.push(b'"');
assert_eq!(
expect_err(&stage, &ctx, &input, ERR_STRING_CONTROL),
1 + k as u64,
"control at {k}"
);
}
}
#[test]
fn strings_spanning_bitmap_word_seams_unescape_fine() {
let Some(ctx) = ctx_or_skip("strings_spanning_bitmap_word_seams_unescape_fine") else {
return;
};
let stage = StringsStage::new();
let mut input = b"\"".to_vec();
input.extend(std::iter::repeat_n(b'a', 62)); input.extend_from_slice(br"\n"); input.extend_from_slice(b"b\"");
let mut want = vec![b'a'; 62];
want.push(b'\n');
want.push(b'b');
assert_eq!(root_content(&stage, &ctx, &input), want);
}
#[test]
fn empty_strings_get_empty_records() {
let Some(ctx) = ctx_or_skip("empty_strings_get_empty_records") else {
return;
};
let stage = StringsStage::new();
assert_eq!(root_content(&stage, &ctx, br#""""#), b"");
let out = stage.run(&ctx, br#"["","",""]"#).unwrap();
assert_eq!(out.error, None);
assert_eq!(out.record_offsets, vec![0, 5, 10]);
assert_eq!(out.stringbuf.len(), 15);
for s in 0..3 {
assert_eq!(out.record_content(s), b"", "record {s}");
}
}
#[test]
fn fast_path_8kb_string() {
let Some(ctx) = ctx_or_skip("fast_path_8kb_string") else {
return;
};
let stage = StringsStage::new();
let mut body = String::new();
while body.len() < 8192 {
body.push_str("abcdefgh é→😀 0123");
}
let input = quoted(&[&body]);
let started = std::time::Instant::now();
let content = root_content(&stage, &ctx, &input);
eprintln!(
"fast_path_8kb_string: {} raw bytes in {:?} (whole pipeline)",
body.len(),
started.elapsed()
);
assert_eq!(content, body.as_bytes());
}
#[test]
fn slow_path_8kb_heavily_escaped_string() {
let Some(ctx) = ctx_or_skip("slow_path_8kb_heavily_escaped_string") else {
return;
};
let stage = StringsStage::new();
let piece = format!(
"{}{}{}{}{}{}{}",
u_esc("D83D"),
u_esc("DE00"),
r"\n\t\\",
"\\\"", u_esc("0041"),
u_esc("0000"),
r"\/"
);
let mut body = String::new();
while body.len() < 8192 {
body.push_str(&piece);
}
let input = quoted(&[&body]);
let want: String = serde_json::from_slice(&input).expect("valid JSON string");
let started = std::time::Instant::now();
let content = root_content(&stage, &ctx, &input);
eprintln!(
"slow_path_8kb_heavily_escaped_string: {} raw bytes in {:?} (whole pipeline; \
thread-per-string cliff documented in shaders/13_strings.metal)",
body.len(),
started.elapsed()
);
assert_eq!(content, want.as_bytes());
}
#[test]
fn long_string_valve_threshold_boundary() {
let Some(ctx) = ctx_or_skip("long_string_valve_threshold_boundary") else {
return;
};
let stage = StringsStage::new();
let at = LONG_STRING_THRESHOLD as usize;
let body = "a".repeat(at);
let out = stage.run(&ctx, "ed(&[&body])).unwrap();
assert_eq!(out.error, None);
assert!(
out.long_string_fixups.is_empty(),
"at-threshold strings stay on the GPU"
);
assert_eq!(out.record_content(0), body.as_bytes());
assert_eq!(out.tape[1], make_string(0));
let body = "a".repeat(at + 1);
let out = stage.run(&ctx, "ed(&[&body])).unwrap();
assert_eq!(out.error, None);
assert_eq!(
out.long_string_fixups,
vec![0],
"just-over strings take the valve"
);
assert_eq!(out.record_content(0), body.as_bytes());
assert_eq!(out.tape[1], make_string(0));
let body = format!("{}{}", "a".repeat(at - 1), r"\n");
let out = stage.run(&ctx, "ed(&[&body])).unwrap();
assert_eq!(out.error, None);
assert_eq!(out.long_string_fixups, vec![0]);
let mut want = vec![b'a'; at - 1];
want.push(b'\n');
assert_eq!(out.record_content(0), want);
assert_eq!(out.stringbuf.len(), at + 6);
assert_eq!(out.stringbuf[at + 5], 0, "CPU-patched slot gap is zero");
}
#[test]
fn long_string_valve_rejects_with_reference_offsets() {
let Some(ctx) = ctx_or_skip("long_string_valve_rejects_with_reference_offsets") else {
return;
};
let stage = StringsStage::new();
let n = LONG_STRING_THRESHOLD as usize + 100;
let mut input = b"\"".to_vec();
input.extend(std::iter::repeat_n(b'a', n));
input.extend_from_slice(br"\q");
input.extend(std::iter::repeat_n(b'b', 8));
input.push(b'"');
let out = stage.run(&ctx, &input).unwrap();
assert_eq!(
out.error_offset_code(),
Some(((1 + n) as u64, ERR_STRING_ESCAPE)),
"escape error past the threshold, at the backslash"
);
assert_eq!(out.long_string_fixups, vec![0], "the valve was exercised");
assert_strings_empty(&out, "long escape rejection");
let mut input = b"\"".to_vec();
input.extend(std::iter::repeat_n(b'a', n));
input.push(0x01);
input.push(b'"');
let out = stage.run(&ctx, &input).unwrap();
assert_eq!(
out.error_offset_code(),
Some(((1 + n) as u64, ERR_STRING_CONTROL)),
"control error past the threshold, at the byte"
);
assert_eq!(out.long_string_fixups, vec![0]);
assert_strings_empty(&out, "long control rejection");
let mut input = b"[\"".to_vec();
input.extend(std::iter::repeat_n(b'a', n));
input.extend_from_slice(b"\\q\",\"\\p\"]");
let out = stage.run(&ctx, &input).unwrap();
assert_eq!(
out.error_offset_code(),
Some(((2 + n) as u64, ERR_STRING_ESCAPE)),
"the long string's earlier backslash wins the merge"
);
assert_eq!(out.long_string_fixups, vec![0]);
}
#[test]
fn long_and_short_strings_interleave_correctly() {
let Some(ctx) = ctx_or_skip("long_and_short_strings_interleave_correctly") else {
return;
};
let stage = StringsStage::new();
let t = LONG_STRING_THRESHOLD as usize;
let long_clean = "x".repeat(t + 7);
let long_escaped = format!("{}{}", "y".repeat(t), r"\n\t"); let bodies: [&str; 5] = ["a", &long_clean, r"b\n", &long_escaped, "cc"];
let mut input = b"[".to_vec();
let mut want_offsets = Vec::new();
let mut offset = 0u64;
for (i, &body) in bodies.iter().enumerate() {
if i > 0 {
input.push(b',');
}
input.extend_from_slice("ed(&[body]));
want_offsets.push(offset);
offset += body.len() as u64 + 5; }
input.push(b']');
let out = stage.run(&ctx, &input).unwrap();
assert_eq!(out.error, None);
assert_eq!(
out.long_string_fixups,
vec![1, 3],
"exactly the two long strings took the valve"
);
assert_eq!(out.record_offsets, want_offsets);
assert_eq!(out.stringbuf.len() as u64, offset);
let mut want_yy = vec![b'y'; t];
want_yy.extend_from_slice(b"\n\t");
let want_contents: Vec<Vec<u8>> = vec![
b"a".to_vec(),
long_clean.clone().into_bytes(),
b"b\n".to_vec(),
want_yy,
b"cc".to_vec(),
];
assert_eq!(contents(&out), want_contents);
for (s, &tok) in out.stage2.string_tokens.iter().enumerate() {
assert_eq!(
out.tape[out.stage2.tape_ofs[tok as usize] as usize],
make_string(want_offsets[s]),
"tape word of record {s}"
);
}
}
#[test]
fn shrunk_records_leave_gaps_and_later_offsets_hold() {
let Some(ctx) = ctx_or_skip("shrunk_records_leave_gaps_and_later_offsets_hold") else {
return;
};
let stage = StringsStage::new();
let mut input = b"[".to_vec();
input.extend_from_slice("ed(&[&u_esc("D83D"), &u_esc("DE00")]));
input.extend_from_slice(b",\"x\",\"yy\"]");
let out = stage.run(&ctx, &input).unwrap();
assert_eq!(out.error, None);
assert_eq!(out.record_offsets, vec![0, 17, 23]);
assert_eq!(out.stringbuf.len(), 30);
assert_eq!(out.record_content(0), "\u{1F600}".as_bytes());
assert_eq!(out.record_content(1), b"x");
assert_eq!(out.record_content(2), b"yy");
assert_eq!(&out.stringbuf[0..4], &[4, 0, 0, 0]);
assert_eq!(&out.stringbuf[4..8], "\u{1F600}".as_bytes());
assert_eq!(out.stringbuf[8], 0);
assert_eq!(&out.stringbuf[9..17], &[0u8; 8], "gap bytes are zero");
assert_eq!(&out.stringbuf[17..23], &[1, 0, 0, 0, b'x', 0]);
let t0 = out.stage2.string_tokens[0] as usize;
let t1 = out.stage2.string_tokens[1] as usize;
let t2 = out.stage2.string_tokens[2] as usize;
assert_eq!(out.tape[out.stage2.tape_ofs[t0] as usize], make_string(0));
assert_eq!(out.tape[out.stage2.tape_ofs[t1] as usize], make_string(17));
assert_eq!(out.tape[out.stage2.tape_ofs[t2] as usize], make_string(23));
}
#[test]
fn duplicate_keys_corpus_keeps_every_record() {
let Some(ctx) = ctx_or_skip("duplicate_keys_corpus_keeps_every_record") else {
return;
};
let input = std::fs::read(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("corpus/duplicate_keys.json"),
)
.expect("corpus fixture");
let out = StringsStage::new().run(&ctx, &input).unwrap();
assert_eq!(out.error, None);
let want: &[&str] = &[
"k", "k", "k", "other", "d", "d", "arr", "x", "first", "x", "second",
];
assert_eq!(
contents(&out),
want.iter().map(|s| s.as_bytes().to_vec()).collect::<Vec<_>>()
);
let mut offset = 0u64;
for (s, w) in want.iter().enumerate() {
assert_eq!(out.record_offsets[s], offset, "record {s}");
offset += w.len() as u64 + 5;
}
assert_eq!(out.stringbuf.len() as u64, offset);
}
#[test]
fn unicode_keys_corpus_round_trips() {
let Some(ctx) = ctx_or_skip("unicode_keys_corpus_round_trips") else {
return;
};
let input = std::fs::read(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("corpus/unicode_keys.json"),
)
.expect("corpus fixture");
let out = StringsStage::new().run(&ctx, &input).unwrap();
assert_eq!(out.error, None);
let want: &[&str] = &[
"héllo",
"日本語のキー",
"😀",
"éscaped",
"",
"ключ",
"中",
"値",
"😁 paired",
];
assert_eq!(
contents(&out),
want.iter().map(|s| s.as_bytes().to_vec()).collect::<Vec<_>>()
);
let mut offset = 0u64;
for (s, w) in want.iter().enumerate() {
assert_eq!(out.record_offsets[s], offset, "record {s}");
offset += w.len() as u64 + 5;
}
}
#[test]
fn multi_chunk_string_lists_offset_and_unescape_correctly() {
let Some(ctx) = ctx_or_skip("multi_chunk_string_lists_offset_and_unescape_correctly")
else {
return;
};
let n = 900usize; let mut input = b"[".to_vec();
let mut want_contents: Vec<Vec<u8>> = Vec::with_capacity(n);
let mut want_offsets: Vec<u64> = Vec::with_capacity(n);
let mut offset = 0u64;
for i in 0..n {
if i > 0 {
input.push(b',');
}
input.push(b'"');
let body = format!("s{i}");
input.extend_from_slice(body.as_bytes());
let raw_len = if i % 7 == 0 {
input.extend_from_slice(br"\n");
let mut c = body.clone().into_bytes();
c.push(b'\n');
want_contents.push(c);
body.len() + 2
} else {
let len = body.len();
want_contents.push(body.into_bytes());
len
};
input.push(b'"');
want_offsets.push(offset);
offset += raw_len as u64 + 5;
}
input.push(b']');
let out = StringsStage::new().run(&ctx, &input).unwrap();
assert_eq!(out.error, None);
assert_eq!(out.record_offsets, want_offsets);
assert_eq!(out.stringbuf.len() as u64, offset);
assert_eq!(contents(&out), want_contents);
let mut want_tape = vec![0u64; out.tape.len()];
for (s, &t) in out.stage2.string_tokens.iter().enumerate() {
let pos = out.stage2.tape_ofs[t as usize] as usize;
want_tape[pos] = make_string(out.record_offsets[s]);
}
assert_eq!(out.tape, want_tape);
let mut bad = input.clone();
let len = bad.len();
bad[len - 4] = b'\\';
bad[len - 3] = b'q';
let out = StringsStage::new().run(&ctx, &bad).unwrap();
let (off, code) = out.error_offset_code().expect("late escape error");
assert_eq!(code, ERR_STRING_ESCAPE);
assert_eq!(off as usize, len - 4, "the backslash of the last string");
}
#[test]
fn earlier_stage_rejections_carry_forward() {
let Some(ctx) = ctx_or_skip("earlier_stage_rejections_carry_forward") else {
return;
};
let stage = StringsStage::new();
let out = stage.run(&ctx, b"ab\x80").unwrap();
assert_eq!(out.error_offset_code(), Some((2, super::super::ERR_UTF8)));
assert_strings_empty(&out, "utf8");
let out = stage.run(&ctx, b"\"abc").unwrap();
assert_eq!(
out.error_offset_code(),
Some((4, super::super::ERR_STRING))
);
assert_strings_empty(&out, "odd quotes");
let out = stage.run(&ctx, b"[1 true]").unwrap();
assert_eq!(
out.error_offset_code(),
Some((3, super::super::ERR_MISSING_COMMA))
);
assert_strings_empty(&out, "layer1");
let out = stage.run(&ctx, b" \t\n").unwrap();
assert_eq!(
out.error_offset_code(),
Some((0, super::super::ERR_EMPTY_INPUT))
);
assert_strings_empty(&out, "empty");
}
#[test]
fn number_problems_pass_the_string_stage() {
let Some(ctx) = ctx_or_skip("number_problems_pass_the_string_stage") else {
return;
};
let stage = StringsStage::new();
let out = stage.run(&ctx, br#"["a",01]"#).unwrap();
assert_eq!(out.error, None, "bad number grammar is not a string error");
assert_eq!(contents(&out), vec![b"a".to_vec()]);
let out = stage.run(&ctx, b"[1e+]").unwrap();
assert_eq!(out.error, None);
assert!(out.record_offsets.is_empty());
assert_eq!(out.tape.len() as u64, out.stage2.tape_word_total + 2);
assert!(out.tape.iter().all(|&w| w == 0), "no strings, all holes");
}
#[cfg(feature = "cpu-reference")]
mod vs_reference {
use super::super::super::{
ERR_EMPTY_INPUT, ERR_INVALID_LITERAL, ERR_MISSING_COLON, ERR_MISSING_COMMA,
ERR_STRING, ERR_UNBALANCED, ERR_UNEXPECTED_TOKEN, ERR_UNTERMINATED_STRING, ERR_UTF8,
};
use super::*;
use crate::reference::{
stage1_classify, stage2_tokens, stage3_validate_local, stage6_strings,
};
use crate::tape::STRING_RECORD_HEADER_BYTES;
use crate::{Error as CrateError, SyntaxErrorKind};
fn layer1_code(kind: SyntaxErrorKind) -> u32 {
match kind {
SyntaxErrorKind::MissingColon => ERR_MISSING_COLON,
SyntaxErrorKind::MissingComma => ERR_MISSING_COMMA,
SyntaxErrorKind::UnexpectedToken => ERR_UNEXPECTED_TOKEN,
SyntaxErrorKind::InvalidLiteral => ERR_INVALID_LITERAL,
SyntaxErrorKind::UnbalancedBrackets => ERR_UNBALANCED,
SyntaxErrorKind::UnterminatedString => ERR_UNTERMINATED_STRING,
SyntaxErrorKind::EmptyInput => ERR_EMPTY_INPUT,
other => panic!("reference stage 3 cannot produce {other:?}"),
}
}
fn string_code(kind: SyntaxErrorKind) -> u32 {
match kind {
SyntaxErrorKind::InvalidStringEscape => ERR_STRING_ESCAPE,
SyntaxErrorKind::ControlCharacterInString => ERR_STRING_CONTROL,
other => panic!("reference stage 6 cannot produce {other:?}"),
}
}
fn diff(stage: &StringsStage, ctx: &MetalContext, input: &[u8], label: &str) {
let got = stage
.run(ctx, input)
.unwrap_or_else(|e| panic!("{label}: GPU strings stage failed: {e}"));
let bitmaps = match stage1_classify(input) {
Ok(bitmaps) => bitmaps,
Err(CrateError::Utf8 { offset }) => {
assert_eq!(
got.error_offset_code(),
Some((offset, ERR_UTF8)),
"{label}: UTF-8 verdict"
);
return;
}
Err(other) => panic!("{label}: unexpected reference error {other:?}"),
};
let quote_total: u64 = bitmaps
.quote_real
.iter()
.map(|w| u64::from(w.count_ones()))
.sum();
if quote_total % 2 == 1 {
assert_eq!(
got.error_offset_code(),
Some((input.len() as u64, ERR_STRING)),
"{label}: odd-quote verdict"
);
let tokens = stage2_tokens(&bitmaps, input);
assert!(
stage3_validate_local(&tokens, input).is_err(),
"{label}: reference must also reject an odd-quote input"
);
return;
}
let tokens = stage2_tokens(&bitmaps, input);
if let Err(err) = stage3_validate_local(&tokens, input) {
let CrateError::Syntax { offset, kind } = err else {
panic!("{label}: unexpected reference error {err:?}");
};
assert_eq!(
got.error_offset_code(),
Some((offset, layer1_code(kind))),
"{label}: Layer-1 verdict for reference {kind:?}"
);
assert_strings_empty(&got, label);
return;
}
match stage6_strings(&tokens, input) {
Err(CrateError::Syntax { offset, kind }) => {
assert_eq!(
got.error_offset_code(),
Some((offset, string_code(kind))),
"{label}: string verdict for reference {kind:?}"
);
assert_strings_empty(&got, label);
assert!(
!got.stage2.string_tokens.is_empty(),
"{label}: stage-2 outputs kept"
);
}
Err(other) => panic!("{label}: unexpected reference error {other:?}"),
Ok(records) => {
assert_eq!(got.error, None, "{label}: spurious GPU error");
assert_eq!(
got.record_offsets.len(),
records.len(),
"{label}: record count"
);
assert_eq!(
got.tape.len() as u64,
got.stage2.tape_word_total + 2,
"{label}: tape length"
);
assert_eq!(
got.stringbuf.len() as u64,
got.stage2.stringbuf_total,
"{label}: string buffer size"
);
let want_tokens: Vec<u32> =
records.iter().map(|r| r.token_index).collect();
assert_eq!(
got.stage2.string_tokens, want_tokens,
"{label}: string token list"
);
let mut is_string_pos = vec![false; got.tape.len()];
for (s, rec) in records.iter().enumerate() {
assert_eq!(
got.record_offsets[s], rec.record_offset,
"{label}: offset of record {s}"
);
let off = usize::try_from(rec.record_offset).unwrap();
let header: [u8; STRING_RECORD_HEADER_BYTES] = got.stringbuf
[off..off + STRING_RECORD_HEADER_BYTES]
.try_into()
.unwrap();
assert_eq!(
u32::from_le_bytes(header) as usize,
rec.bytes.len(),
"{label}: length prefix of record {s}"
);
let content = off + STRING_RECORD_HEADER_BYTES;
assert_eq!(
&got.stringbuf[content..content + rec.bytes.len()],
&rec.bytes[..],
"{label}: content of record {s}"
);
assert_eq!(
got.stringbuf[content + rec.bytes.len()],
0,
"{label}: NUL of record {s}"
);
let pos = got.stage2.tape_ofs[rec.token_index as usize] as usize;
assert_eq!(
got.tape[pos],
make_string(rec.record_offset),
"{label}: tape word of record {s}"
);
is_string_pos[pos] = true;
}
for (i, &word) in got.tape.iter().enumerate() {
if !is_string_pos[i] {
assert_eq!(word, 0, "{label}: hole at tape[{i}]");
}
}
for (s, rec) in records.iter().enumerate() {
let off = usize::try_from(rec.record_offset).unwrap();
let gap_start =
off + STRING_RECORD_HEADER_BYTES + rec.bytes.len() + 1;
let slot_end = records.get(s + 1).map_or(got.stringbuf.len(), |n| {
usize::try_from(n.record_offset).unwrap()
});
assert!(
got.stringbuf[gap_start..slot_end].iter().all(|&b| b == 0),
"{label}: gap bytes of record {s} ({gap_start}..{slot_end}) \
must be zero-filled"
);
}
}
}
}
#[test]
fn corpus_files_match_reference_stage6() {
let Some(ctx) = ctx_or_skip("corpus_files_match_reference_stage6") else {
return;
};
let stage = StringsStage::new();
let corpus = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("corpus");
let mut paths: Vec<_> = std::fs::read_dir(&corpus)
.expect("corpus/ is checked in")
.map(|e| e.expect("readable corpus entry").path())
.filter(|p| p.extension().is_some_and(|e| e == "json"))
.collect();
paths.sort();
assert!(!paths.is_empty(), "corpus/ must contain fixtures");
for path in paths {
let name = path.file_name().unwrap().to_string_lossy().into_owned();
let bytes = std::fs::read(&path).expect("readable corpus fixture");
diff(&stage, &ctx, &bytes, &name);
}
}
#[test]
fn jsontestsuite_files_match_reference_stage6() {
let Some(ctx) = ctx_or_skip("jsontestsuite_files_match_reference_stage6") else {
return;
};
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("data/JSONTestSuite/test_parsing");
if !dir.is_dir() {
eprintln!(
"SKIP jsontestsuite_files_match_reference_stage6: {} not fetched \
(scripts/fetch_jsontestsuite.sh)",
dir.display()
);
return;
}
let stage = StringsStage::new();
let mut paths: Vec<_> = std::fs::read_dir(&dir)
.expect("readable test_parsing dir")
.map(|e| e.expect("readable entry").path())
.filter(|p| p.extension().is_some_and(|e| e == "json"))
.collect();
paths.sort();
assert!(paths.len() >= 300, "the fetched suite has 318 files");
for path in paths {
let name = path.file_name().unwrap().to_string_lossy().into_owned();
let bytes = std::fs::read(&path).expect("readable suite file");
diff(&stage, &ctx, &bytes, &name);
}
}
#[test]
fn string_fixtures_match_reference_stage6() {
let Some(ctx) = ctx_or_skip("string_fixtures_match_reference_stage6") else {
return;
};
let stage = StringsStage::new();
let mut cases: Vec<Vec<u8>> = vec![
br#""""#.to_vec(),
br#""hello""#.to_vec(),
br#""\" \\ \/ \b \f \n \r \t""#.to_vec(),
quoted(&[&u_esc("0000")]),
quoted(&[&u_esc("0041"), "mid", &u_esc("00e9")]),
quoted(&[&u_esc("d83d"), &u_esc("de00")]),
quoted(&[&u_esc("DBFF"), &u_esc("DFFF")]),
quoted(&[&u_esc("D800")]),
quoted(&[&u_esc("DC00")]),
quoted(&[&u_esc("D800"), &u_esc("0041")]),
quoted(&[&u_esc("DE00"), &u_esc("D83D")]),
quoted(&[&u_esc("12")]),
br#""\uZZZZ""#.to_vec(),
br#""\x41""#.to_vec(),
br#""ab\q""#.to_vec(),
b"\"a\tb\"".to_vec(),
b"\"a\x01b\"".to_vec(),
b"\"a\x1F\"".to_vec(),
b"\"a\x7Fb\"".to_vec(),
"\"héllo 😀\"".as_bytes().to_vec(),
br#"["", "x", ""]"#.to_vec(),
br#"{"k":"v","k":"v2"}"#.to_vec(),
br#"["ok","\q"]"#.to_vec(),
br#"["\q","\p"]"#.to_vec(),
b"[\"a\x06b\",\"\\q\"]".to_vec(),
br#"["a","b""#.to_vec(), br#"{"a":"b"}"#.to_vec(),
br#"["a",01]"#.to_vec(),
br#"{"k":1e999}"#.to_vec(),
br#"[01,"\q"]"#.to_vec(),
b"".to_vec(),
b" \t\n\r".to_vec(),
];
for k in 0..=17usize {
let pad = "a".repeat(k);
cases.push(quoted(&[&pad, r"\n", "bb"]));
cases.push(quoted(&[&pad, &u_esc("D83D"), &u_esc("DE00"), "c"]));
let mut ctl = b"\"".to_vec();
ctl.extend(std::iter::repeat_n(b'a', k));
ctl.push(0x02);
ctl.extend_from_slice(b"b\"");
cases.push(ctl);
}
let mut seam = b"\"".to_vec();
seam.extend(std::iter::repeat_n(b'a', 62));
seam.extend_from_slice(br"\nb");
seam.push(b'"');
cases.push(seam);
let mut gap = b"[".to_vec();
gap.extend_from_slice("ed(&[&u_esc("D83D"), &u_esc("DE00")]));
gap.extend_from_slice(b",\"x\",\"yy\"]");
cases.push(gap);
let mut long_clean = String::new();
while long_clean.len() < 8192 {
long_clean.push_str("abcdefgh é→😀 0123");
}
cases.push(quoted(&[&long_clean]));
let mut long_escaped = String::new();
while long_escaped.len() < 8192 {
long_escaped.push_str(&u_esc("D83D"));
long_escaped.push_str(&u_esc("DE00"));
long_escaped.push_str(r"\n\t\\");
long_escaped.push_str("\\\""); long_escaped.push_str(&u_esc("0000"));
}
cases.push(quoted(&[&long_escaped]));
let mut big = b"[".to_vec();
for i in 0..900 {
if i > 0 {
big.push(b',');
}
big.push(b'"');
big.extend_from_slice(format!("s{i}").as_bytes());
if i % 7 == 0 {
big.extend_from_slice(br"\n");
}
big.push(b'"');
}
big.push(b']');
cases.push(big.clone());
let mut bad = big;
let len = bad.len();
bad[len - 4] = b'\\';
bad[len - 3] = b'q';
cases.push(bad);
for input in &cases {
let label = format!(
"{:?}",
String::from_utf8_lossy(&input[..input.len().min(48)])
);
diff(&stage, &ctx, input, &label);
}
}
#[test]
fn proptest_random_strings_match_reference() {
use proptest::prelude::*;
use proptest::test_runner::{Config, TestRunner};
let Some(ctx) = ctx_or_skip("proptest_random_strings_match_reference") else {
return;
};
let stage = StringsStage::new();
let piece = prop_oneof![
4 => "[a-zA-Z0-9 _.,:;<>~é中😀%-]{0,12}".prop_map(String::into_bytes),
2 => proptest::sample::select(vec![
&br#"\""#[..], &br"\\"[..], &br"\/"[..], &br"\b"[..],
&br"\f"[..], &br"\n"[..], &br"\r"[..], &br"\t"[..],
]).prop_map(<[u8]>::to_vec),
2 => (0u32..=0xFFFF).prop_map(|cp| format!("{}u{cp:04X}", '\\').into_bytes()),
1 => (0x10000u32..=0x10FFFF).prop_map(|cp| {
let c = cp - 0x10000;
format!(
"{}u{:04X}{}u{:04X}",
'\\', 0xD800 + (c >> 10), '\\', 0xDC00 + (c & 0x3FF)
).into_bytes()
}),
1 => proptest::char::range(' ', '~').prop_map(|c| vec![b'\\', c as u8]),
1 => (0u8..0x20).prop_map(|b| vec![b]),
];
let string = proptest::collection::vec(piece, 0..6).prop_map(|pieces| {
let mut s = vec![b'"'];
for p in pieces {
s.extend_from_slice(&p);
}
s.push(b'"');
s
});
let doc = proptest::collection::vec(string, 0..8).prop_map(|strings| {
let mut d = vec![b'['];
for (i, s) in strings.iter().enumerate() {
if i > 0 {
d.push(b',');
}
d.extend_from_slice(s);
}
d.push(b']');
d
});
let mut runner = TestRunner::new(Config {
cases: 64,
..Config::default()
});
runner
.run(&doc, |input| {
diff(&stage, &ctx, &input, "proptest");
Ok(())
})
.unwrap();
}
}
}