diff --git a/packages/marshal/src/builders/builder-types.js b/packages/marshal/src/builders/builder-types.js index 238b4a8499..5ca9478b32 100644 --- a/packages/marshal/src/builders/builder-types.js +++ b/packages/marshal/src/builders/builder-types.js @@ -4,7 +4,7 @@ * @template N * @template R * @typedef {object} Builder - * @property {(node: N) => R} buildRoot + * @property {(buildTopFn: () => N) => R} buildRoot * * @property {() => N} buildUndefined * @property {() => N} buildNull @@ -14,14 +14,14 @@ * @property {(str: string) => N} buildString * @property {(sym: symbol) => N} buildSymbol * - * @property {(builtEntries: [string, N][]) => N} buildRecord + * @property {(names: string[], buildValuesIter: Iterable) => N} buildRecord * The recognizer must pass the actual property names through. It is * up to the builder whether it wants to encode them. * It is up to the recognizer to sort the entries by their actual * property name first, and to encode their values in the resulting * sorted order. The builder may assume that sorted order. - * @property {(builtElements: N[]) => N} buildArray - * @property {(tagName: string, builtPayload: N) => N} buildTagged + * @property {(count: number, buildElementsIter: Iterable) => N} buildArray + * @property {(tagName: string, buildPayloadFn: () => N) => N} buildTagged * The recognizer must pass the actual tagName through. It is * up to the builder whether it wants to encode it. * diff --git a/packages/marshal/src/builders/justinBuilder.js b/packages/marshal/src/builders/justinBuilder.js new file mode 100644 index 0000000000..53310e8bde --- /dev/null +++ b/packages/marshal/src/builders/justinBuilder.js @@ -0,0 +1,132 @@ +/// + +import { Far, getInterfaceOf, nameForPassableSymbol } from '@endo/pass-style'; +import { + identPattern, + AtAtPrefixPattern, + makeNoIndenter, + makeYesIndenter, +} from '../marshal-justin.js'; + +const { stringify } = JSON; +const { Fail, quote: q } = assert; +const { is } = Object; + +export const makeJustinBuilder = (shouldIndent = false, _slots = []) => { + let out; + let slotIndex; + const outNextJSON = val => out.next(stringify(val)); + + /** @type {Builder} */ + const justinBuilder = Far('JustinBuilder', { + buildRoot: buildTopFn => { + const makeIndenter = shouldIndent ? makeYesIndenter : makeNoIndenter; + out = makeIndenter(); + slotIndex = -1; + buildTopFn(); + return out.done(); + }, + + buildUndefined: () => out.next('undefined'), + buildNull: () => out.next('null'), + buildBoolean: outNextJSON, + buildNumber: num => { + if (num === Infinity) { + return out.next('Infinity'); + } else if (num === -Infinity) { + return out.next('-Infinity'); + } else if (is(num, NaN)) { + return out.next('NaN'); + } else { + return out.next(stringify(num)); + } + }, + buildBigint: bigint => out.next(`${bigint}n`), + buildString: outNextJSON, + buildSymbol: sym => { + assert.typeof(sym, 'symbol'); + const name = nameForPassableSymbol(sym); + if (name === undefined) { + throw Fail`Symbol must be either registered or well known: ${q(sym)}`; + } + const registeredName = Symbol.keyFor(sym); + if (registeredName === undefined) { + const match = AtAtPrefixPattern.exec(name); + assert(match !== null); + const suffix = match[1]; + assert(Symbol[suffix] === sym); + assert(identPattern.test(suffix)); + return out.next(`Symbol.${suffix}`); + } + return out.next(`Symbol.for(${stringify(registeredName)})`); + }, + + buildRecord: (names, buildValuesIter) => { + if (names.length === 0) { + return out.next('{}'); + } + out.open('{'); + const iter = buildValuesIter[Symbol.iterator](); + for (const name of names) { + out.line(); + if (name === '__proto__') { + // JavaScript interprets `{__proto__: x, ...}` + // as making an object inheriting from `x`, whereas + // in JSON it is simply a property name. Preserve the + // JSON meaning. + out.next(`["__proto__"]:`); + } else if (identPattern.test(name)) { + out.next(`${name}:`); + } else { + out.next(`${stringify(name)}:`); + } + const { value: _, done } = iter.next(); + if (done) { + break; + } + out.next(','); + } + return out.close('}'); + }, + buildArray: (count, buildElementsIter) => { + if (count === 0) { + return out.next('[]'); + } + out.open('['); + const iter = buildElementsIter[Symbol.iterator](); + for (let i = 0; ; i += 1) { + if (i < count) { + out.line(); + } + const { value: _, done } = iter.next(); + if (done) { + break; + } + out.next(','); + } + return out.close(']'); + }, + buildTagged: (tagName, buildPayloadFn) => { + out.next(`makeTagged(${stringify(tagName)},`); + buildPayloadFn(); + return out.next(')'); + }, + + buildError: error => out.next(`${error.name}(${stringify(error.message)})`), + buildRemotable: remotable => { + slotIndex += 1; + return out.next( + `slot(${slotIndex},${stringify(getInterfaceOf(remotable))})`, + ); + }, + buildPromise: _promise => { + slotIndex += 1; + return out.next(`slot(${slotIndex})`); + }, + }); + return justinBuilder; +}; +harden(makeJustinBuilder); + +export const makeBuilder = () => makeJustinBuilder(); +harden(makeBuilder); diff --git a/packages/marshal/src/builders/smallcapsBuilder.js b/packages/marshal/src/builders/smallcapsBuilder.js index 0f77412508..a332f89656 100644 --- a/packages/marshal/src/builders/smallcapsBuilder.js +++ b/packages/marshal/src/builders/smallcapsBuilder.js @@ -5,6 +5,7 @@ import { Far, getErrorConstructor, hasOwnPropertyOf, + mapIterable, nameForPassableSymbol, passableSymbolForName, } from '@endo/pass-style'; @@ -15,7 +16,7 @@ import { /** @typedef {import('../encodeToSmallcaps.js').SmallcapsEncoding} SmallcapsEncoding */ -const { is, fromEntries, entries } = Object; +const { is, fromEntries } = Object; const { isArray } = Array; const { ownKeys } = Reflect; const { quote: q, details: X, Fail } = assert; @@ -23,7 +24,7 @@ const { quote: q, details: X, Fail } = assert; const makeSmallcapsBuilder = () => { /** @type {Builder} */ const smallcapsBuilder = Far('SmallcapsBuilder', { - buildRoot: node => node, + buildRoot: buildTopFn => buildTopFn(), buildUndefined: () => '#undefined', buildNull: () => null, @@ -50,19 +51,17 @@ const makeSmallcapsBuilder = () => { const name = /** @type {string} */ (nameForPassableSymbol(sym)); return `%${name}`; }, - buildRecord: builtEntries => + buildRecord: (names, buildValuesIter) => { + const builtValues = [...buildValuesIter]; + assert(names.length === builtValues.length); // TODO Should be fromUniqueEntries, but utils needs to be // relocated first. - fromEntries( - builtEntries.map(([name, builtValue]) => [ - buildString(name), - builtValue, - ]), - ), - buildArray: builtElements => builtElements, - buildTagged: (tagName, builtPayload) => ({ + return fromEntries(names.map((name, i) => [name, builtValues[i]])); + }, + buildArray: (_count, buildElementsIter) => harden([...buildElementsIter]), + buildTagged: (tagName, buildPayloadFn) => ({ '#tag': buildString(tagName), - payload: builtPayload, + payload: buildPayloadFn(), }), // TODO slots and options and all that. Also errorId @@ -96,7 +95,12 @@ const recognizeString = str => { }; const makeSmallcapsRecognizer = () => { - const smallcapsRecognizer = (encoding, builder) => { + /** + * @param {SmallcapsEncoding} encoding + * @param {Builder} builder + * @returns {SmallcapsEncoding} + */ + const recognizeNode = (encoding, builder) => { switch (typeof encoding) { case 'boolean': { return builder.buildBoolean(encoding); @@ -115,9 +119,9 @@ const makeSmallcapsRecognizer = () => { return builder.buildString(encoding.slice(1)); } case '%': { - return builder.buildSymbol( - passableSymbolForName(encoding.slice(1)), - ); + const sym = passableSymbolForName(encoding.slice(1)); + assert(sym !== undefined); + return builder.buildSymbol(sym); } case '#': { switch (encoding) { @@ -163,20 +167,18 @@ const makeSmallcapsRecognizer = () => { } if (isArray(encoding)) { - const builtElements = encoding.map(val => - smallcapsRecognizer(val, builder), + const buildElementsIter = mapIterable(encoding, val => + recognizeNode(val, builder), ); - return builder.buildArray(builtElements); + return builder.buildArray(encoding.length, buildElementsIter); } if (hasOwnPropertyOf(encoding, '#tag')) { const { '#tag': tag, payload, ...rest } = encoding; ownKeys(rest).length === 0 || Fail`#tag record unexpected properties: ${q(ownKeys(rest))}`; - return builder.buildTagged( - recognizeString(tag), - smallcapsRecognizer(payload, builder), - ); + const buildPayloadFn = () => recognizeNode(payload, builder); + return builder.buildTagged(recognizeString(tag), buildPayloadFn); } if (hasOwnPropertyOf(encoding, '#error')) { @@ -190,18 +192,22 @@ const makeSmallcapsRecognizer = () => { return builder.buildError(error); } - const buildEntry = ([encodedName, encodedVal]) => { + const encodedNames = /** @type {string[]} */ (ownKeys(encoding)).sort(); + for (const encodedName of encodedNames) { typeof encodedName === 'string' || Fail`Property name ${q( encodedName, )} of ${encoding} must be a string`; !encodedName.startsWith('#') || Fail`Unrecognized record type ${q(encodedName)}: ${encoding}`; - const name = recognizeString(encodedName); - return [name, smallcapsRecognizer(encodedVal, builder)]; - }; - const builtEntries = entries(encoding).map(buildEntry); - return builder.buildRecord(builtEntries); + } + const buildValuesIter = mapIterable(encodedNames, encodedName => + recognizeNode(encoding[encodedName], builder), + ); + return builder.buildRecord( + encodedNames.map(recognizeString), + buildValuesIter, + ); } default: { assert.fail( @@ -213,7 +219,14 @@ const makeSmallcapsRecognizer = () => { } } }; - return smallcapsRecognizer; + /** + * @param {SmallcapsEncoding} encoding + * @param {Builder} builder + * @returns {SmallcapsEncoding} + */ + const recognizeSmallcaps = (encoding, builder) => + builder.buildRoot(() => recognizeNode(encoding, builder)); + return harden(recognizeSmallcaps); }; harden(makeSmallcapsRecognizer); diff --git a/packages/marshal/src/builders/subgraphBuilder.js b/packages/marshal/src/builders/subgraphBuilder.js index 075be8dd34..3304408319 100644 --- a/packages/marshal/src/builders/subgraphBuilder.js +++ b/packages/marshal/src/builders/subgraphBuilder.js @@ -1,6 +1,14 @@ /// -import { Far, getTag, makeTagged, passStyleOf } from '@endo/pass-style'; +import { + Far, + getTag, + makeTagged, + mapIterable, + passStyleOf, +} from '@endo/pass-style'; + +/** @typedef {import('@endo/pass-style').Passable} Passable */ const { fromEntries } = Object; const { ownKeys } = Reflect; @@ -9,9 +17,9 @@ const { quote: q, details: X } = assert; const makeSubgraphBuilder = () => { const ident = val => val; - /** @type {Builder} */ + /** @type {Builder} */ const subgraphBuilder = Far('SubgraphBuilder', { - buildRoot: ident, + buildRoot: buildTopFn => buildTopFn(), buildUndefined: () => undefined, buildNull: () => null, @@ -20,9 +28,15 @@ const makeSubgraphBuilder = () => { buildBigint: ident, buildString: ident, buildSymbol: ident, - buildRecord: builtEntries => harden(fromEntries(builtEntries)), - buildArray: ident, - buildTagged: (tagName, builtPayload) => makeTagged(tagName, builtPayload), + buildRecord: (names, buildValuesIter) => { + const builtValues = [...buildValuesIter]; + assert(names.length === builtValues.length); + const builtEntries = names.map((name, i) => [name, builtValues[i]]); + return harden(fromEntries(builtEntries)); + }, + buildArray: (_count, buildElementsIter) => harden([...buildElementsIter]), + buildTagged: (tagName, buildPayloadFn) => + makeTagged(tagName, buildPayloadFn()), buildError: ident, buildRemotable: ident, @@ -33,7 +47,12 @@ const makeSubgraphBuilder = () => { harden(makeSubgraphBuilder); const makeSubgraphRecognizer = () => { - const subgraphRecognizer = (passable, builder) => { + /** + * @param {Passable} passable + * @param {Builder} builder + * @returns {Passable} + */ + const recognizeNode = (passable, builder) => { // First we handle all primitives. Some can be represented directly as // JSON, and some must be encoded into smallcaps strings. const passStyle = passStyleOf(passable); @@ -60,26 +79,21 @@ const makeSubgraphRecognizer = () => { return builder.buildSymbol(passable); } case 'copyRecord': { - // copyRecord allows only string keys so this will - // work. - const names = ownKeys(passable).sort(); - return builder.buildRecord( - names.map(name => [ - name, - subgraphRecognizer(passable[name], builder), - ]), + const names = /** @type {string[]} */ (ownKeys(passable)).sort(); + const buildValuesIter = mapIterable(names, name => + recognizeNode(passable[name], builder), ); + return builder.buildRecord(names, buildValuesIter); } case 'copyArray': { - return builder.buildArray( - passable.map(el => subgraphRecognizer(el, builder)), + const buildElementsIter = mapIterable(passable, el => + recognizeNode(el, builder), ); + return builder.buildArray(passable.length, buildElementsIter); } case 'tagged': { - return builder.buildTagged( - getTag(passable), - subgraphRecognizer(passable.payload, builder), - ); + const buildPayloadFn = () => recognizeNode(passable.payload, builder); + return builder.buildTagged(getTag(passable), buildPayloadFn); } case 'remotable': { return builder.buildRemotable(passable); @@ -98,7 +112,14 @@ const makeSubgraphRecognizer = () => { } } }; - return subgraphRecognizer; + /** + * @param {Passable} passable + * @param {Builder} builder + * @returns {Passable} + */ + const recognizeSubgraph = (passable, builder) => + builder.buildRoot(() => recognizeNode(passable, builder)); + return harden(recognizeSubgraph); }; harden(makeSubgraphRecognizer); diff --git a/packages/marshal/src/marshal-justin.js b/packages/marshal/src/marshal-justin.js index e9f38897a0..70ae962e3a 100644 --- a/packages/marshal/src/marshal-justin.js +++ b/packages/marshal/src/marshal-justin.js @@ -30,7 +30,7 @@ const { quote: q, details: X, Fail } = assert; * * @returns {Indenter} */ -const makeYesIndenter = () => { +export const makeYesIndenter = () => { const strings = []; let level = 0; let needSpace = false; @@ -85,7 +85,7 @@ const badPairPattern = /^(?:\w\w|<<|>>|\+\+|--|)$/; * * @returns {Indenter} */ -const makeNoIndenter = () => { +export const makeNoIndenter = () => { /** @type {string[]} */ const strings = []; return harden({ diff --git a/packages/marshal/test/builders/test-justin-builder.js b/packages/marshal/test/builders/test-justin-builder.js new file mode 100644 index 0000000000..0047dd86b3 --- /dev/null +++ b/packages/marshal/test/builders/test-justin-builder.js @@ -0,0 +1,54 @@ +import { test } from '../prepare-test-env-ava.js'; + +import * as js from '../../src/builders/subgraphBuilder.js'; +import { makeJustinBuilder } from '../../src/builders/justinBuilder.js'; +import { makeMarshal } from '../../src/marshal.js'; +import { decodeToJustin } from '../../src/marshal-justin.js'; +import { fakeJustinCompartment, justinPairs } from '../test-marshal-justin.js'; + +// this only includes the tests that do not use liveSlots + +test('justin builder round trip pairs', t => { + const jsRecognizer = js.makeRecognizer(); + const { toCapData } = makeMarshal(undefined, undefined, { + // We're turning `errorTagging`` off only for the round trip tests, not in + // general. + errorTagging: 'off', + // TODO retire the old format in justin test cases + serializeBodyFormat: 'capdata', + }); + for (const [body, justinSrc, slots] of justinPairs) { + const c = fakeJustinCompartment(); + const encoding = JSON.parse(body); + const justinExpr = decodeToJustin(encoding, false, slots); + t.is(justinExpr, justinSrc); + const value = harden(c.evaluate(`(${justinExpr})`)); + const { body: newBody } = toCapData(value); + t.is(newBody, body); + + const justinBuilder = makeJustinBuilder(false, slots); + t.is(jsRecognizer(value, justinBuilder), justinExpr); + } +}); + +test('justin indented builder round trip pairs', t => { + const jsRecognizer = js.makeRecognizer(); + const { toCapData } = makeMarshal(undefined, undefined, { + // We're turning `errorTagging`` off only for the round trip tests, not in + // general. + errorTagging: 'off', + // TODO retire the old format in justin test cases + serializeBodyFormat: 'capdata', + }); + for (const [body, _, slots] of justinPairs) { + const c = fakeJustinCompartment(); + const encoding = JSON.parse(body); + const justinExpr = decodeToJustin(encoding, true, slots); + const value = harden(c.evaluate(`(${justinExpr})`)); + const { body: newBody } = toCapData(value); + t.is(newBody, body); + + const justinBuilder = makeJustinBuilder(true, slots); + t.is(jsRecognizer(value, justinBuilder), justinExpr); + } +}); diff --git a/packages/marshal/test/test-marshal-justin.js b/packages/marshal/test/test-marshal-justin.js index 0980037304..ceac87dfdb 100644 --- a/packages/marshal/test/test-marshal-justin.js +++ b/packages/marshal/test/test-marshal-justin.js @@ -16,7 +16,7 @@ import { decodeToJustin } from '../src/marshal-justin.js'; * * @type {([string, string] | [string, string, unknown[]])[]} */ -export const jsonPairs = harden([ +export const justinPairs = harden([ // Justin is the same as the JSON encoding but without unnecessary quoting ['[1,2]', '[1,2]'], ['{"foo":1}', '{foo:1}'], @@ -88,7 +88,7 @@ export const jsonPairs = harden([ ], ]); -const fakeJustinCompartment = () => { +export const fakeJustinCompartment = () => { const slots = []; const slotVals = new Map(); const populateSlot = (index, iface) => { @@ -117,20 +117,20 @@ const fakeJustinCompartment = () => { }; test('serialize decodeToJustin eval round trip pairs', t => { - const { serialize } = makeMarshal(undefined, undefined, { + const { toCapData } = makeMarshal(undefined, undefined, { // We're turning `errorTagging`` off only for the round trip tests, not in // general. errorTagging: 'off', // TODO make Justin work with smallcaps serializeBodyFormat: 'capdata', }); - for (const [body, justinSrc, slots] of jsonPairs) { + for (const [body, justinSrc, slots] of justinPairs) { const c = fakeJustinCompartment(); const encoding = JSON.parse(body); const justinExpr = decodeToJustin(encoding, false, slots); t.is(justinExpr, justinSrc); const value = harden(c.evaluate(`(${justinExpr})`)); - const { body: newBody } = serialize(value); + const { body: newBody } = toCapData(value); t.is(newBody, body); } }); @@ -141,20 +141,20 @@ test('serialize decodeToJustin eval round trip pairs', t => { // that the decoder passes the extra `level` balancing diagnostic in // `makeYesIndenter`. test('serialize decodeToJustin indented eval round trip', t => { - const { serialize } = makeMarshal(undefined, undefined, { + const { toCapData } = makeMarshal(undefined, undefined, { // We're turning `errorTagging`` off only for the round trip tests, not in // general. errorTagging: 'off', // TODO make Justin work with smallcaps serializeBodyFormat: 'capdata', }); - for (const [body, _, slots] of jsonPairs) { + for (const [body, _, slots] of justinPairs) { const c = fakeJustinCompartment(); t.log(body); const encoding = JSON.parse(body); const justinExpr = decodeToJustin(encoding, true, slots); const value = harden(c.evaluate(`(${justinExpr})`)); - const { body: newBody } = serialize(value); + const { body: newBody } = toCapData(value); t.is(newBody, body); } });