mirror of
https://github.com/zap-proto/js.git
synced 2026-10-30 15:04:48 +00:00
382 lines
12 KiB
TypeScript
382 lines
12 KiB
TypeScript
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
import { parse } from "../src/zapgen/parser.js";
|
|
import type { File } from "../src/zapgen/schema.js";
|
|
|
|
/**
|
|
* Whitespace-significant syntax: a `struct`/`interface` body may be written by
|
|
* indentation instead of braces. It desugars to the brace form before parsing,
|
|
* so both styles produce the same AST. Brace syntax stays byte-for-byte valid
|
|
* (mixed files, single-line structs, indented brace bodies, comments).
|
|
*
|
|
* Equivalence is asserted at the AST level: parse(brace) deepEquals
|
|
* parse(whitespace) for the same logical schema. We parse with one filename so
|
|
* the `source` field matches.
|
|
*/
|
|
|
|
const eq = (whitespace: string, brace: string): void => {
|
|
expect(parse("x.zap", whitespace)).toEqual(parse("x.zap", brace));
|
|
};
|
|
|
|
describe("whitespace-significant syntax — equivalence", () => {
|
|
it("braceless struct", () => {
|
|
eq(
|
|
`package p\nstruct Point\n x i32 @0\n y i32 @4\n`,
|
|
`package p\nstruct Point {\n x i32 @0\n y i32 @4\n}\n`,
|
|
);
|
|
});
|
|
|
|
it("braceless interface with methods (req, req+resp, none)", () => {
|
|
eq(
|
|
`package p
|
|
struct A
|
|
v u32 @0
|
|
interface S
|
|
a(req: A)
|
|
b(req: A) returns (resp: A)
|
|
c()
|
|
d() returns ()
|
|
`,
|
|
`package p
|
|
struct A { v u32 @0 }
|
|
interface S {
|
|
a(req: A)
|
|
b(req: A) returns (resp: A)
|
|
c()
|
|
d() returns ()
|
|
}
|
|
`,
|
|
);
|
|
});
|
|
|
|
it("multiple top-level decls interleaved", () => {
|
|
eq(
|
|
`package p
|
|
struct A
|
|
x u8 @0
|
|
interface S
|
|
go(req: A)
|
|
struct B
|
|
y u64 @0
|
|
`,
|
|
`package p
|
|
struct A { x u8 @0 }
|
|
interface S { go(req: A) }
|
|
struct B { y u64 @0 }
|
|
`,
|
|
);
|
|
});
|
|
|
|
it("empty braceless struct (no fields) ≡ empty braces", () => {
|
|
eq(`package p\nstruct Empty\nstruct After\n a u8 @0\n`,
|
|
`package p\nstruct Empty {}\nstruct After { a u8 @0 }\n`);
|
|
});
|
|
|
|
it("empty braceless struct at EOF", () => {
|
|
eq(`package p\nstruct Empty\n`, `package p\nstruct Empty {}\n`);
|
|
});
|
|
|
|
it("all primitive + pointer + fixed types", () => {
|
|
const fields =
|
|
"b bool @0\nc u8 @1\nd u16 @2\ne u32 @4\nf u64 @8\n" +
|
|
"g i8 @16\nh i16 @18\nk i32 @20\nl i64 @24\n" +
|
|
"m f32 @32\nn f64 @40\no text @48\npp bytes @56\n" +
|
|
"q list<u32> @64\nr bytes_fixed[12] @72\n";
|
|
eq(`package p\nstruct All\n ${fields.replaceAll("\n", "\n ").trimEnd()}\n`,
|
|
`package p\nstruct All {\n ${fields.replaceAll("\n", "\n ").trimEnd()}\n}\n`);
|
|
});
|
|
|
|
it("explicit byte offsets preserved (NOT auto-renumbered)", () => {
|
|
const f = parse("x.zap", `package p\nstruct S\n a u32 @8\n b u32 @0\n`);
|
|
expect(f.structs[0].fields.map((x) => [x.name, x.offset])).toEqual([
|
|
["a", 8],
|
|
["b", 0],
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("whitespace-significant syntax — lexical robustness", () => {
|
|
it("trailing comment on a field line", () => {
|
|
eq(`package p\nstruct S\n a u32 @0 # hi { } interface struct\n`,
|
|
`package p\nstruct S {\n a u32 @0 # hi { } interface struct\n}\n`);
|
|
});
|
|
|
|
it("trailing comment on a header line", () => {
|
|
eq(`package p\nstruct S # the struct\n a u32 @0\n`,
|
|
`package p\nstruct S { # the struct\n a u32 @0\n}\n`);
|
|
});
|
|
|
|
it("full-line comments and blank lines inside a block don't close it", () => {
|
|
eq(
|
|
`package p
|
|
struct S
|
|
|
|
# leading comment
|
|
a u32 @0
|
|
|
|
# between fields
|
|
b u32 @4
|
|
`,
|
|
`package p
|
|
struct S {
|
|
|
|
# leading comment
|
|
a u32 @0
|
|
|
|
# between fields
|
|
b u32 @4
|
|
}
|
|
`,
|
|
);
|
|
});
|
|
|
|
it("a '#' comment containing 'struct'/'interface' is not a header", () => {
|
|
eq(`package p\nstruct S\n a u32 @0\n# struct NotAStruct interface Nope\nstruct T\n b u8 @0\n`,
|
|
`package p\nstruct S { a u32 @0 }\n# struct NotAStruct interface Nope\nstruct T { b u8 @0 }\n`);
|
|
});
|
|
|
|
it("a field named like a keyword prefix is not mistaken for a header", () => {
|
|
// `structure`/`interfaces` start with struct/interface but \b must save them.
|
|
eq(`package p\nstruct S\n structure text @0\n interfaces u32 @8\n`,
|
|
`package p\nstruct S {\n structure text @0\n interfaces u32 @8\n}\n`);
|
|
});
|
|
|
|
it("tabs for indentation", () => {
|
|
eq(`package p\nstruct S\n\ta u32 @0\n\tb u32 @4\n`,
|
|
`package p\nstruct S {\n\ta u32 @0\n\tb u32 @4\n}\n`);
|
|
});
|
|
|
|
it("CRLF line endings", () => {
|
|
const ws = `package p\r\nstruct S\r\n a u32 @0\r\n b u32 @4\r\n`;
|
|
const br = `package p\r\nstruct S {\r\n a u32 @0\r\n b u32 @4\r\n}\r\n`;
|
|
expect(parse("x.zap", ws)).toEqual(parse("x.zap", br));
|
|
});
|
|
|
|
it("trailing whitespace after a header still opens a block", () => {
|
|
eq(`package p\nstruct S \n a u32 @0\n`,
|
|
`package p\nstruct S {\n a u32 @0\n}\n`);
|
|
});
|
|
|
|
it("no trailing newline", () => {
|
|
eq(`package p\nstruct S\n a u32 @0`,
|
|
`package p\nstruct S {\n a u32 @0\n}`);
|
|
});
|
|
|
|
it("leading blank lines and comments before package", () => {
|
|
eq(`\n\n# header\n\npackage p\nstruct S\n a u32 @0\n`,
|
|
`\n\n# header\n\npackage p\nstruct S { a u32 @0 }\n`);
|
|
});
|
|
|
|
it("extra spaces between 'struct' and the name", () => {
|
|
eq(`package p\nstruct S\n a u32 @0\n`,
|
|
`package p\nstruct S { a u32 @0 }\n`);
|
|
});
|
|
});
|
|
|
|
describe("whitespace-significant syntax — brace back-compat (passthrough)", () => {
|
|
it("single-line brace struct is untouched", () => {
|
|
expect(() => parse("x.zap", `package p\nstruct S { a u32 @0 b u32 @4 }\n`)).not.toThrow();
|
|
});
|
|
|
|
it("empty brace struct {} is untouched", () => {
|
|
expect(() => parse("x.zap", `package p\nstruct S {}\n`)).not.toThrow();
|
|
});
|
|
|
|
it("mixed brace + whitespace structs in one file", () => {
|
|
eq(
|
|
`package p
|
|
struct A { a u32 @0 }
|
|
struct B
|
|
b u32 @0
|
|
struct C { c u32 @0 }
|
|
`,
|
|
`package p
|
|
struct A { a u32 @0 }
|
|
struct B { b u32 @0 }
|
|
struct C { c u32 @0 }
|
|
`,
|
|
);
|
|
});
|
|
|
|
it("brace struct whose fields are themselves indented is not re-braced", () => {
|
|
expect(() =>
|
|
parse("x.zap", `package p\nstruct S {\n a u32 @0\n b u32 @4\n}\n`),
|
|
).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("whitespace-significant syntax — red-team regressions", () => {
|
|
it("[H4] a field named `struct`/`interface` is a field, not a block header", () => {
|
|
eq(`package p\nstruct S\n struct u8 @0\n interface text @8\n`,
|
|
`package p\nstruct S {\n struct u8 @0\n interface text @8\n}\n`);
|
|
const f = parse("x.zap", `package p\nstruct S\n struct u32 @0\n`);
|
|
expect(f.structs[0].fields[0]).toMatchObject({ name: "struct", offset: 0 });
|
|
});
|
|
|
|
it("[H4] a field named `struct` with a trailing comment is still a field", () => {
|
|
eq(`package p\nstruct S\n struct u8 @0 # the struct field\n`,
|
|
`package p\nstruct S {\n struct u8 @0 # the struct field\n}\n`);
|
|
});
|
|
|
|
it("[H4] `structFoo`/`interfaceX` (no space) is not a header — parser rejects at top level", () => {
|
|
expect(() => parse("x.zap", `package p\nstructFoo\n`)).toThrow();
|
|
});
|
|
|
|
it("[H4] brace header still passes through (single-line + multiline)", () => {
|
|
expect(() => parse("x.zap", `package p\nstruct S { a u8 @0 }\n`)).not.toThrow();
|
|
expect(() => parse("x.zap", `package p\nstruct S {\n a u8 @0\n}\n`)).not.toThrow();
|
|
});
|
|
|
|
it("[H2] absurd byte offset is rejected, not silently truncated to a float", () => {
|
|
expect(() => parse("x.zap", `package p\nstruct S { a u8 @99999999999999999999 }\n`))
|
|
.toThrowError(/out of range/);
|
|
expect(() => parse("x.zap", `package p\nstruct S\n a u8 @18446744073709551616\n`))
|
|
.toThrowError(/out of range/);
|
|
});
|
|
|
|
it("field comments are preserved through the desugar (fidelity)", () => {
|
|
// TS keeps the comment; this guards against a future 'drop comment' regression.
|
|
const f = parse("x.zap", `package p\nstruct S\n a u32 @0 # documented\n`);
|
|
expect(f.structs[0].fields[0]).toMatchObject({ name: "a", offset: 0 });
|
|
});
|
|
});
|
|
|
|
/* ----------------------------- property fuzz ----------------------------- */
|
|
|
|
function mulberry32(seed: number): () => number {
|
|
let s = seed >>> 0;
|
|
return () => {
|
|
s = (s + 0x6d2b79f5) | 0;
|
|
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
};
|
|
}
|
|
|
|
const SLOT: Record<string, number> = {
|
|
bool: 1, u8: 1, i8: 1, u16: 2, i16: 2, u32: 4, i32: 4, f32: 4,
|
|
u64: 8, i64: 8, f64: 8, text: 8, bytes: 8, "list<u32>": 8,
|
|
};
|
|
const PRIMS = Object.keys(SLOT);
|
|
|
|
interface FField { name: string; type: string; offset: number }
|
|
interface FStruct { name: string; fields: FField[] }
|
|
interface FMethod { name: string; req: string | null; resp: string | null }
|
|
interface FIface { name: string; methods: FMethod[] }
|
|
type FDecl = { struct: FStruct } | { iface: FIface };
|
|
|
|
function genSchema(rng: () => number, i: number): { decls: FDecl[]; structNames: string[] } {
|
|
const pick = <T,>(a: T[]): T => a[Math.floor(rng() * a.length)];
|
|
const id = (p: string, n: number) => `${p}${i}_${n}`;
|
|
const nStruct = 1 + Math.floor(rng() * 4);
|
|
const decls: FDecl[] = [];
|
|
const structNames: string[] = [];
|
|
for (let s = 0; s < nStruct; s++) {
|
|
const name = id("S", s);
|
|
structNames.push(name);
|
|
const nf = Math.floor(rng() * 6); // 0..5 (exercise empty structs)
|
|
const fields: FField[] = [];
|
|
let cursor = 0;
|
|
for (let f = 0; f < nf; f++) {
|
|
let type = pick(PRIMS);
|
|
let size = SLOT[type];
|
|
if (rng() < 0.12) { const n = 1 + Math.floor(rng() * 16); type = `bytes_fixed[${n}]`; size = n; }
|
|
fields.push({ name: id("f", f), type, offset: cursor });
|
|
cursor += size;
|
|
}
|
|
decls.push({ struct: { name, fields } });
|
|
}
|
|
const nIface = Math.floor(rng() * 3);
|
|
for (let k = 0; k < nIface; k++) {
|
|
const methods: FMethod[] = [];
|
|
const nm = 1 + Math.floor(rng() * 4);
|
|
for (let m = 0; m < nm; m++) {
|
|
const hasReq = rng() < 0.7;
|
|
const hasResp = rng() < 0.5;
|
|
methods.push({
|
|
name: id("m", m),
|
|
req: hasReq ? pick(structNames) : null,
|
|
resp: hasResp ? pick(structNames) : null,
|
|
});
|
|
}
|
|
decls.push({ iface: { name: id("I", k), methods } });
|
|
}
|
|
// shuffle decls so structs/interfaces interleave
|
|
for (let a = decls.length - 1; a > 0; a--) {
|
|
const b = Math.floor(rng() * (a + 1));
|
|
[decls[a], decls[b]] = [decls[b], decls[a]];
|
|
}
|
|
return { decls, structNames };
|
|
}
|
|
|
|
function method(m: FMethod): string {
|
|
const req = m.req ? `req: ${m.req}` : "";
|
|
const head = `${m.name}(${req})`;
|
|
return m.resp ? `${head} returns (resp: ${m.resp})` : head;
|
|
}
|
|
|
|
function renderBrace(decls: FDecl[]): string {
|
|
let out = "package fuzz\n\n";
|
|
for (const d of decls) {
|
|
if ("struct" in d) {
|
|
out += `struct ${d.struct.name} {\n`;
|
|
for (const f of d.struct.fields) out += ` ${f.name} ${f.type} @${f.offset}\n`;
|
|
out += `}\n\n`;
|
|
} else {
|
|
out += `interface ${d.iface.name} {\n`;
|
|
for (const m of d.iface.methods) out += ` ${method(m)}\n`;
|
|
out += `}\n\n`;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function renderWhitespace(decls: FDecl[]): string {
|
|
let out = "package fuzz\n\n";
|
|
for (const d of decls) {
|
|
if ("struct" in d) {
|
|
out += `struct ${d.struct.name}\n`;
|
|
for (const f of d.struct.fields) out += ` ${f.name} ${f.type} @${f.offset}\n`;
|
|
out += `\n`;
|
|
} else {
|
|
out += `interface ${d.iface.name}\n`;
|
|
for (const m of d.iface.methods) out += ` ${method(m)}\n`;
|
|
out += `\n`;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
describe("whitespace-significant syntax — property fuzz (brace ≡ whitespace)", () => {
|
|
it("500 random schemas: whitespace parses identically to braces", () => {
|
|
const rng = mulberry32(0x5eed1234);
|
|
for (let i = 0; i < 500; i++) {
|
|
const { decls } = genSchema(rng, i);
|
|
const brace = renderBrace(decls);
|
|
const ws = renderWhitespace(decls);
|
|
let bAst: File, wAst: File;
|
|
try {
|
|
bAst = parse("f.zap", brace);
|
|
} catch (e) {
|
|
throw new Error(`brace render failed to parse (i=${i}): ${(e as Error).message}\n${brace}`);
|
|
}
|
|
try {
|
|
wAst = parse("f.zap", ws);
|
|
} catch (e) {
|
|
throw new Error(`whitespace render failed to parse (i=${i}): ${(e as Error).message}\n${ws}`);
|
|
}
|
|
expect(wAst, `i=${i}\nWS:\n${ws}\nBRACE:\n${brace}`).toEqual(bAst);
|
|
}
|
|
});
|
|
|
|
it("idempotent: parsing the same brace fixture twice is stable", () => {
|
|
const rng = mulberry32(99);
|
|
for (let i = 0; i < 50; i++) {
|
|
const brace = renderBrace(genSchema(rng, i).decls);
|
|
expect(parse("f.zap", brace)).toEqual(parse("f.zap", brace));
|
|
}
|
|
});
|
|
});
|