char/avril
(wip) why'd you have to go and make things so complicaaaaated?
git clone https://git.t4t.associates/char/avril
75a6060
main
1mod common; 2 3# [ test ] 4fn fjson_parses_all_roots_and_always_prints_readable_fjson () { 5let output = common:: run ( 6r#" 7import {parse, stringify} from 'avril:fjson'; 8const value = parse("// title\nname: 'fJSON'\noptions: {enabled: true\ntargets: ['linux', 'macos']}\n"); 9if (value.name !== 'fJSON' || !value.options.enabled) throw Error('Parse'); 10const text = stringify(value); 11if (!text.startsWith('name: "fJSON"\noptions: {\n enabled: true\n')) throw Error(text); 12if (JSON.stringify(parse(text)) !== JSON.stringify(value)) throw Error('Round trip'); 13for (const source of ['null','false','42','"😀"','[1, 2,]','{}']) { 14const parsed = parse(source); 15if (JSON.stringify(parse(stringify(parsed))) !== JSON.stringify(parsed)) throw Error(source); 16} 17const proto = parse('__proto__: {safe: true}'); 18if (!Object.hasOwn(proto, '__proto__') || Object.getPrototypeOf(proto) !== Object.prototype 19|| proto.__proto__.safe !== true) throw Error('Prototype'); 20console.log('fjson'); 21"# , 22& [], 23); 24assert_eq! ( output. stdout , b"fjson\n" ); 25} 26 27# [ test ] 28fn fjson_conversion_errors_are_normal_javascript_errors () { 29 common:: run ( 30r#" 31import {parse, stringify} from 'avril:fjson'; 32for (const source of ['[1 2]','1 2','a: 1\na: 2','a: 1e9999']) { 33try {parse(source); throw Error('Accepted invalid source');} 34catch(e) {if (!(e instanceof SyntaxError) || !e.message.includes('<fjson>:')) throw e;} 35} 36for (const value of [undefined, Symbol('x'), () => {}, 1n]) { 37try {stringify(value); throw Error('Accepted invalid value');} 38catch(e) {if (!(e instanceof TypeError)) throw e;} 39} 40const cycle = {}; cycle.self = cycle; 41try {stringify(cycle); throw Error('Accepted cycle');} 42catch(e) {if (!(e instanceof TypeError)) throw e;} 43const value = {toJSON() {return {result: [NaN, Infinity, undefined]};}}; 44if (stringify(value) !== 'result: [null, null, null]') throw Error('JSON conversion'); 45const marker = {}; 46try {stringify({get value() {throw marker;}}); throw Error('Lost exception');} 47catch(e) {if (e !== marker) throw e;} 48"# , 49& [], 50); 51} 52 53# [ test ] 54fn fjson_rejects_unpaired_surrogates_without_lossy_replacement () { 55 common:: run ( 56r#" 57import {parse, stringify} from 'avril:fjson'; 58for (const invalid of ['\ud800', '\udfff', '\ud800a', '\ud800\ud800', '\udfff\ud800']) { 59for (const source of [`'${invalid}'`, `"${invalid}": 1`, `// ${invalid}\ntrue`]) { 60try {parse(source); throw Error('Accepted unpaired surrogate');} 61catch(e) { 62if (!(e instanceof SyntaxError) || !e.message.includes('Unpaired Unicode surrogate') 63|| !e.message.includes('<fjson>:')) throw e; 64} 65} 66for (const value of [invalid, {value: invalid}, {[invalid]: true}]) { 67try {stringify(value); throw Error('Serialized unpaired surrogate');} 68catch(e) {if (!(e instanceof TypeError) || !e.message.includes('surrogate')) throw e;} 69} 70} 71for (const source of ['"\\ud800"', '"\\udfff"', '"\\ud800\\u0041"']) { 72try {parse(source); throw Error('Accepted escaped unpaired surrogate');} 73catch(e) {if (!(e instanceof SyntaxError) || !e.message.includes('surrogate')) throw e;} 74} 75const source = "label: '😀'\r\ntext: '\ud800'"; 76try {parse(source); throw Error('Accepted invalid Unicode');} 77catch(e) {if (!e.message.startsWith('<fjson>:2:8: Unpaired Unicode surrogate')) throw e;} 78for (const valid of ['😀', '\ufffd', '\ud800\udc00']) { 79if (parse(`'${valid}'`) !== valid || parse(stringify(valid)) !== valid) throw Error('Lost valid Unicode'); 80} 81"# , 82& [], 83); 84} 85 86# [ test ] 87fn fjson_depth_errors_are_typed_and_successful_prints_round_trip () { 88 common:: run ( 89r#" 90import {parse, stringify} from 'avril:fjson'; 91let value = null; 92for (let depth = 0; depth < 129; depth++) value = [value]; 93const text = stringify(value); 94if (JSON.stringify(parse(text)) !== JSON.stringify(value)) throw Error('Depth round trip'); 95value = [value]; 96try {stringify(value); throw Error('Serialized excessive nesting');} 97catch(e) {if (!(e instanceof RangeError) || !e.message.includes('fJSON nesting')) throw e;} 98try {parse(JSON.stringify(value)); throw Error('Parsed excessive nesting');} 99catch(e) {if (!(e instanceof SyntaxError) || !e.message.includes('fJSON nesting')) throw e;} 100"# , 101& [], 102); 103} 104 105# [ test ] 106fn fjson_apply_merges_leaves_without_rewriting_untouched_source () { 107 common:: run ( 108r#" 109import {apply, parse} from 'avril:fjson'; 110const source = "// heading\r\noptions: {\r\n\tenabled: /* choice */ false // reason\r\n\tname: 'old'\r\n\tkeep: [1, /* inside */ 2,]\r\n}\r\nother: 1e3\r\n"; 111const updated = apply(source, {options: {enabled: true, name: "it's new"}}); 112const expected = source.replace('false', 'true').replace("'old'", "'it\\'s new'"); 113if (updated !== expected) throw Error(updated); 114if (apply(source) !== source || apply(source, {}, {options: {keep: [1, 2], name: 'old'}}) !== source) { 115throw Error('Reformatted unchanged values'); 116} 117const added = apply(updated, {options: {added: {active: true}}}); 118if (!added.includes('\tadded: {\r\n\t active: true\r\n\t}')) throw Error(added); 119if (!added.includes('\tkeep: [1, /* inside */ 2,]') || !added.endsWith('other: 1e3\r\n')) throw Error(added); 120if (parse(added).options.added.active !== true) throw Error('Insertion'); 121for (const empty of ['', '// heading', '{/* empty */}']) { 122if (parse(apply(empty, {options: {enabled: true}})).options.enabled !== true) throw Error(empty); 123} 124for (const newline of ['\n', '\r\n', '\r']) { 125const original = `options: {${newline}\tkeep: 'same'${newline}}${newline}`; 126const edited = apply(original, {options: {new: 'value'}}); 127if (!edited.includes(`\tkeep: 'same'${newline}`) || !edited.includes(`\tnew: "value"${newline}`)) { 128throw Error(edited); 129} 130if (newline !== '\n' && edited.split(newline).join('').includes('\n')) throw Error('Changed line endings'); 131} 132"# , 133& [], 134); 135} 136 137# [ test ] 138fn fjson_apply_deletes_resets_and_applies_overrides_left_to_right () { 139 common:: run ( 140r#" 141import {apply, parse} from 'avril:fjson'; 142const source = "options: /* before */ {enabled: false, keep: 1 /* inside */} // after\nlist: [1, 2]\nscalar: false\n"; 143const merged = parse(apply(source, {options: {enabled: true}})); 144if (!merged.options.enabled || merged.options.keep !== 1) throw Error('Lost sibling'); 145const reset = apply(source, {options: undefined}, {options: {enabled: true}}); 146const value = parse(reset); 147if (JSON.stringify(value.options) !== '{"enabled":true}') throw Error(reset); 148for (const comment of ['/* before */', '// after']) if (!reset.includes(comment)) throw Error(reset); 149if (reset.includes('/* inside */')) throw Error(reset); 150const removed = parse(apply(source, {options: {enabled: undefined}, missing: undefined})); 151if (Object.hasOwn(removed.options, 'enabled') || removed.options.keep !== 1) throw Error('Deletion'); 152const changed = parse(apply(source, 153{list: [3], scalar: {a: 1}, options: null}, 154{scalar: {b: 2}, options: {new: true}}, 155)); 156if (JSON.stringify(changed.list) !== '[3]' || JSON.stringify(changed.scalar) !== '{"a":1,"b":2}' 157|| JSON.stringify(changed.options) !== '{"new":true}') throw Error(JSON.stringify(changed)); 158if (apply(source, {options: {}}) !== source) throw Error('Empty merge cleared object'); 159const empty = parse(apply('a: 1', {a: {}, b: {missing: undefined}})); 160if (JSON.stringify(empty) !== '{"a":{},"b":{}}') throw Error('Empty objects'); 161const shared = {value: 1}; 162const repeated = parse(apply('', {a: shared, b: shared})); 163if (repeated.a.value !== 1 || repeated.b.value !== 1) throw Error('Shared reference'); 164"# , 165& [], 166); 167} 168 169# [ test ] 170fn fjson_apply_removing_members_leaves_no_whitespace_only_lines () { 171 common:: run ( 172r#" 173import {apply, parse} from 'avril:fjson'; 174const source = "tasks: {\n a: {\n command: \"x\",\n },\n b: {\n command: \"y\",\n },\n},\n"; 175if (apply(source, {tasks: {a: undefined}}) !== 176"tasks: {\n b: {\n command: \"y\",\n },\n},\n") throw Error('Remove first'); 177if (apply(source, {tasks: {b: undefined}}) !== 178"tasks: {\n a: {\n command: \"x\",\n },\n},\n") throw Error('Remove last'); 179let current = source; 180for (let round = 0; round < 3; round++) { 181current = apply(current, 182{tasks: {a: undefined, b: undefined}}, 183{tasks: {a: {command: `x${round}`}, b: {command: 'y'}}}); 184} 185if (/^[ \t]+$/m.test(current)) throw Error(current); 186if (parse(current).tasks.a.command !== 'x2') throw Error('Regeneration'); 187const inline = apply('tasks: { a: 1, b: 2 }, // done\n', {tasks: {a: undefined}}); 188if (parse(inline).tasks.b !== 2 || !inline.includes('// done') || inline.includes('\n\n')) { 189throw Error(inline); 190} 191"# , 192& [], 193); 194} 195 196# [ test ] 197fn fjson_apply_uses_own_keys_and_json_conversion_for_replacements () { 198 common:: run ( 199r#" 200import {apply, parse} from 'avril:fjson'; 201const overrides = Object.create({inherited: 1}); 202Object.defineProperty(overrides, 'hidden', {value: 2}); 203overrides[Symbol('ignored')] = 3; 204overrides.fn = () => {}; overrides.symbol = Symbol('ignored'); 205overrides.values = [undefined, NaN, Infinity, {toJSON() {return 'converted';}}]; 206overrides.nullable = null; 207const value = parse(apply('', overrides)); 208if (JSON.stringify(value) !== '{"values":[null,null,null,"converted"],"nullable":null}') { 209throw Error(JSON.stringify(value)); 210} 211const special = Object.create(null); 212special.__proto__ = {safe: true}; special['a.b'] = {'日本語': '😀'}; 213const result = parse(apply('__proto__: {kept: 1}', special)); 214if (!Object.hasOwn(result, '__proto__') || result.__proto__.kept !== 1 || !result.__proto__.safe 215|| Object.getPrototypeOf(result) !== Object.prototype || result['a.b']['日本語'] !== '😀' 216|| Object.prototype.safe !== undefined) throw Error('Keys'); 217const indexed = parse(apply('', {0: 'zero', nested: {1: 'one'}})); 218if (indexed['0'] !== 'zero' || indexed.nested['1'] !== 'one') throw Error('Index keys'); 219const omitted = []; omitted.toJSON = () => undefined; 220if (apply('value: true', {value: omitted}) !== 'value: true') throw Error('Unsupported JSON root'); 221const converted = () => {}; converted.toJSON = () => 'converted'; 222if (parse(apply('', {value: converted})).value !== 'converted') throw Error('Function toJSON'); 223const marker = {}; 224const direct = {value: true, toJSON() {throw marker;}}; 225if (parse(apply('', direct)).value !== true) throw Error('Converted overrides'); 226const failedArray = []; failedArray.toJSON = () => {throw marker;}; 227for (const overrides of [ 228{get value() {throw marker;}}, 229new Proxy({}, {ownKeys() {throw marker;}}), 230{value: failedArray}, 231]) { 232try {apply('', overrides); throw Error('Lost exception');} 233catch(e) {if (e !== marker) throw e;} 234} 235"# , 236& [], 237); 238} 239 240# [ test ] 241fn fjson_apply_reports_source_conversion_and_depth_errors () { 242 common:: run ( 243r#" 244import {apply, parse} from 'avril:fjson'; 245for (const source of ['[]', 'null', 'a:', 'a: 1\na: 2', 'a: 1e9999', "// \ud800\na: 1"]) { 246try {apply(source); throw Error('Accepted invalid source');} 247catch(e) {if (!(e instanceof SyntaxError) || !e.message.includes('<fjson>:')) throw e;} 248} 249const cyclic = {}; cyclic.self = cyclic; 250for (const overrides of [null, [], 1, undefined, () => {}, Symbol('x'), 1n, cyclic, {value: 1n}, {value: '\ud800'}, {'\udfff': 1}]) { 251try {apply('', overrides); throw Error('Accepted invalid overrides');} 252catch(e) {if (!(e instanceof TypeError)) throw e;} 253} 254const arrayCycle = []; arrayCycle.push(arrayCycle); 255try {apply('', {value: arrayCycle}); throw Error('Accepted cyclic array');} 256catch(e) {if (!(e instanceof TypeError)) throw e;} 257let object = {leaf: true}; 258for (let depth = 0; depth < 128; depth++) object = {child: object}; 259const text = apply('', object); 260if (JSON.stringify(parse(text)) !== JSON.stringify(object)) throw Error('Object depth round trip'); 261object = {child: object}; 262try {apply('', object); throw Error('Accepted excessive object nesting');} 263catch(e) {if (!(e instanceof RangeError)) throw e;} 264let array = null; 265for (let depth = 0; depth < 128; depth++) array = [array]; 266const arrays = apply('', {value: array}); 267if (JSON.stringify(parse(arrays).value) !== JSON.stringify(array)) throw Error('Array depth round trip'); 268array = [array]; 269try {apply('', {value: array}); throw Error('Accepted excessive array nesting');} 270catch(e) {if (!(e instanceof RangeError)) throw e;} 271"# , 272& [], 273); 274} 275 276# [ test ] 277fn fjson_is_permissionless_private_and_survives_poisoned_globals () { 278let dir = tempfile:: tempdir (). unwrap (); 279let path = dir. path (). join ( "main.js" ); 280 std:: fs:: write ( & path, r#" 281import {apply, parse, stringify} from 'avril:fjson'; 282import {pledge} from 'avril:std'; 283const parseError = SyntaxError, typeError = TypeError, rangeError = RangeError; 284if (Symbol.for('__avril.fjson') in globalThis) throw Error('Registry'); 285const poison = () => {throw Error('Poisoned')}; 286JSON.parse = poison; JSON.stringify = poison; 287globalThis.JSON = null; globalThis.SyntaxError = poison; globalThis.TypeError = poison; 288globalThis.RangeError = poison; 289Object.prototype.Err = 'poison'; Array.prototype[Symbol.iterator] = poison; 290Symbol.for = poison; Object.keys = poison; Array.isArray = poison; 291Set.prototype.has = poison; Set.prototype.add = poison; Set.prototype.delete = poison; 292pledge({net: false, read: false, write: false, jsExec: false}); 293if ((await import('avril:fjson')).parse !== parse) throw Error('Lost module'); 294if (parse(stringify({answer: 42})).answer !== 42) throw Error('Poisoned internals'); 295const patched = parse(apply('options: {keep: 1, old: true}', {options: {old: undefined, answer: 42}})); 296if (patched.options.keep !== 1 || patched.options.answer !== 42 || patched.options.old !== undefined) { 297throw Error('Poisoned apply'); 298} 299try {apply('a:'); throw Error('Accepted invalid source');} 300catch(e) {if (!(e instanceof parseError)) throw e;} 301try {apply('', {value: '\ud800'}); throw Error('Applied invalid Unicode');} 302catch(e) {if (!(e instanceof typeError)) throw e;} 303try {parse('a:'); throw Error('Accepted invalid source');} 304catch(e) {if (!(e instanceof parseError)) throw e;} 305try {parse("'\ud800'"); throw Error('Lost Unicode validation');} 306catch(e) {if (!(e instanceof parseError)) throw e;} 307try {stringify('\ud800'); throw Error('Serialized invalid Unicode');} 308catch(e) {if (!(e instanceof typeError)) throw e;} 309let nested = null; 310for (let depth = 0; depth < 130; depth++) nested = [nested]; 311try {stringify(nested); throw Error('Serialized excessive nesting');} 312catch(e) {if (!(e instanceof rangeError)) throw e;} 313try {await import('ext:fjson/main.js'); throw Error('Escaped');} 314catch(e) {if (!e.message.includes('private')) throw e;} 315console.log('isolated'); 316"# ). unwrap (); 317let output = std:: process:: Command :: new ( env! ( "CARGO_BIN_EXE_avril" )) 318. args ([ "run" , "-0" ]) 319. arg ( path) 320. output () 321. unwrap (); 322assert! ( 323 output. status . success (), 324"{}" , 325String :: from_utf8_lossy ( & output. stderr ) 326); 327assert_eq! ( output. stdout , b"isolated\n" ); 328}