diff --git a/packages/marshal/src/builders/builder-types.js b/packages/marshal/src/builders/builder-types.js new file mode 100644 index 0000000000..238b4a8499 --- /dev/null +++ b/packages/marshal/src/builders/builder-types.js @@ -0,0 +1,41 @@ +/** @typedef {import('@endo/pass-style').Remotable} Remotable */ + +/** + * @template N + * @template R + * @typedef {object} Builder + * @property {(node: N) => R} buildRoot + * + * @property {() => N} buildUndefined + * @property {() => N} buildNull + * @property {(num: number) => N} buildNumber + * @property {(flag: boolean) => N} buildBoolean + * @property {(bigint: bigint) => N} buildBigint + * @property {(str: string) => N} buildString + * @property {(sym: symbol) => N} buildSymbol + * + * @property {(builtEntries: [string, N][]) => 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 + * The recognizer must pass the actual tagName through. It is + * up to the builder whether it wants to encode it. + * + * @property {(error :Error) => N} buildError + * @property {(remotable: Remotable) => N} buildRemotable + * @property {(promise: Promise) => N} buildPromise + */ + +/** + * @template E + * @template N + * @template R + * @callback Recognize + * @param {E} encoding + * @param {Builder} builder + * @returns {R} + */ diff --git a/packages/marshal/src/builders/smallcapsBuilder.js b/packages/marshal/src/builders/smallcapsBuilder.js new file mode 100644 index 0000000000..0f77412508 --- /dev/null +++ b/packages/marshal/src/builders/smallcapsBuilder.js @@ -0,0 +1,223 @@ +/// + +import { + assertPassableSymbol, + Far, + getErrorConstructor, + hasOwnPropertyOf, + nameForPassableSymbol, + passableSymbolForName, +} from '@endo/pass-style'; +import { + startsSpecial, + encodeStringToSmallcaps as buildString, +} from '../encodeToSmallcaps.js'; + +/** @typedef {import('../encodeToSmallcaps.js').SmallcapsEncoding} SmallcapsEncoding */ + +const { is, fromEntries, entries } = Object; +const { isArray } = Array; +const { ownKeys } = Reflect; +const { quote: q, details: X, Fail } = assert; + +const makeSmallcapsBuilder = () => { + /** @type {Builder} */ + const smallcapsBuilder = Far('SmallcapsBuilder', { + buildRoot: node => node, + + buildUndefined: () => '#undefined', + buildNull: () => null, + buildBoolean: flag => flag, + buildNumber: num => { + // Special-case numbers with no digit-based representation. + if (Number.isNaN(num)) { + return '#NaN'; + } else if (num === Infinity) { + return '#Infinity'; + } else if (num === -Infinity) { + return '#-Infinity'; + } + // Pass through everything else, replacing -0 with 0. + return is(num, -0) ? 0 : num; + }, + buildBigint: bigint => { + const str = String(bigint); + return bigint < 0n ? str : `+${str}`; + }, + buildString, + buildSymbol: sym => { + assertPassableSymbol(sym); + const name = /** @type {string} */ (nameForPassableSymbol(sym)); + return `%${name}`; + }, + buildRecord: builtEntries => + // 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) => ({ + '#tag': buildString(tagName), + payload: builtPayload, + }), + + // TODO slots and options and all that. Also errorId + buildError: error => ({ + '#error': buildString(error.message), + name: buildString(error.name), + }), + // TODO slots and options and all that. + buildRemotable: _remotable => '$', + // TODO slots and options and all that. + buildPromise: _promise => '&', + }); + return smallcapsBuilder; +}; +harden(makeSmallcapsBuilder); + +/** + * Must be consistent with the full string recognition algorithm. + * Must return what that algorithm would pass to builder.buildString. + * + * @param {string} str + */ +const recognizeString = str => { + typeof str === 'string' || Fail`${str} must be a string`; + if (!startsSpecial(str)) { + return str; + } + const c = str.charAt(0); + c === '!' || Fail`${str} must encode a string`; + return str.slice(1); +}; + +const makeSmallcapsRecognizer = () => { + const smallcapsRecognizer = (encoding, builder) => { + switch (typeof encoding) { + case 'boolean': { + return builder.buildBoolean(encoding); + } + case 'number': { + return builder.buildNumber(encoding); + } + case 'string': { + if (!startsSpecial(encoding)) { + return builder.buildString(encoding); + } + const c = encoding.charAt(0); + switch (c) { + case '!': { + // un-hilbert-ify the string + return builder.buildString(encoding.slice(1)); + } + case '%': { + return builder.buildSymbol( + passableSymbolForName(encoding.slice(1)), + ); + } + case '#': { + switch (encoding) { + case '#undefined': { + return builder.buildUndefined(); + } + case '#NaN': { + return builder.buildNumber(NaN); + } + case '#Infinity': { + return builder.buildNumber(Infinity); + } + case '#-Infinity': { + return builder.buildNumber(-Infinity); + } + default: { + assert.fail(X`unknown constant "${q(encoding)}"`, TypeError); + } + } + } + case '+': + case '-': { + return builder.buildBigint(BigInt(encoding)); + } + case '$': { + // TODO slots and options and all that. + return builder.buildRemotable(Far('dummy')); + } + case '&': { + // TODO slots and options and all that. + return builder.buildPromise(Promise.resolve('dummy')); + } + default: { + throw Fail`Special char ${q( + c, + )} reserved for future use: ${encoding}`; + } + } + } + case 'object': { + if (encoding === null) { + return builder.buildNull(); + } + + if (isArray(encoding)) { + const builtElements = encoding.map(val => + smallcapsRecognizer(val, builder), + ); + return builder.buildArray(builtElements); + } + + 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), + ); + } + + if (hasOwnPropertyOf(encoding, '#error')) { + // TODO slots and options and all that. Also errorId + const { '#error': message, name } = encoding; + const dMessage = recognizeString(message); + const dName = recognizeString(name); + const EC = getErrorConstructor(dName) || Error; + const errorName = `Remote${EC.name}`; + const error = assert.error(dMessage, EC, { errorName }); + return builder.buildError(error); + } + + const buildEntry = ([encodedName, encodedVal]) => { + 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); + } + default: { + assert.fail( + X`internal: unrecognized JSON typeof ${q( + typeof encoding, + )}: ${encoding}`, + TypeError, + ); + } + } + }; + return smallcapsRecognizer; +}; +harden(makeSmallcapsRecognizer); + +export { + makeSmallcapsBuilder as makeBuilder, + makeSmallcapsRecognizer as makeRecognizer, +}; diff --git a/packages/marshal/src/builders/subgraphBuilder.js b/packages/marshal/src/builders/subgraphBuilder.js new file mode 100644 index 0000000000..075be8dd34 --- /dev/null +++ b/packages/marshal/src/builders/subgraphBuilder.js @@ -0,0 +1,108 @@ +/// + +import { Far, getTag, makeTagged, passStyleOf } from '@endo/pass-style'; + +const { fromEntries } = Object; +const { ownKeys } = Reflect; +const { quote: q, details: X } = assert; + +const makeSubgraphBuilder = () => { + const ident = val => val; + + /** @type {Builder} */ + const subgraphBuilder = Far('SubgraphBuilder', { + buildRoot: ident, + + buildUndefined: () => undefined, + buildNull: () => null, + buildBoolean: ident, + buildNumber: ident, + buildBigint: ident, + buildString: ident, + buildSymbol: ident, + buildRecord: builtEntries => harden(fromEntries(builtEntries)), + buildArray: ident, + buildTagged: (tagName, builtPayload) => makeTagged(tagName, builtPayload), + + buildError: ident, + buildRemotable: ident, + buildPromise: ident, + }); + return subgraphBuilder; +}; +harden(makeSubgraphBuilder); + +const makeSubgraphRecognizer = () => { + const subgraphRecognizer = (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); + switch (passStyle) { + case 'null': { + return builder.buildNull(); + } + case 'boolean': { + return builder.buildBoolean(passable); + } + case 'string': { + return builder.buildString(passable); + } + case 'undefined': { + return builder.buildUndefined(); + } + case 'number': { + return builder.buildNumber(passable); + } + case 'bigint': { + return builder.buildBigint(passable); + } + case 'symbol': { + 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), + ]), + ); + } + case 'copyArray': { + return builder.buildArray( + passable.map(el => subgraphRecognizer(el, builder)), + ); + } + case 'tagged': { + return builder.buildTagged( + getTag(passable), + subgraphRecognizer(passable.payload, builder), + ); + } + case 'remotable': { + return builder.buildRemotable(passable); + } + case 'promise': { + return builder.buildPromise(passable); + } + case 'error': { + return builder.buildError(passable); + } + default: { + assert.fail( + X`internal: Unrecognized passStyle ${q(passStyle)}`, + TypeError, + ); + } + } + }; + return subgraphRecognizer; +}; +harden(makeSubgraphRecognizer); + +export { + makeSubgraphBuilder as makeBuilder, + makeSubgraphRecognizer as makeRecognizer, +}; diff --git a/packages/marshal/src/encodeToSmallcaps.js b/packages/marshal/src/encodeToSmallcaps.js index 31993d46ce..7bb7b08157 100644 --- a/packages/marshal/src/encodeToSmallcaps.js +++ b/packages/marshal/src/encodeToSmallcaps.js @@ -76,7 +76,7 @@ const DASH = '-'.charCodeAt(0); * @param {string} encodedStr * @returns {boolean} */ -const startsSpecial = encodedStr => { +export const startsSpecial = encodedStr => { if (encodedStr === '') { return false; } @@ -86,6 +86,19 @@ const startsSpecial = encodedStr => { return BANG <= code && code <= DASH; }; +export const encodeStringToSmallcaps = str => { + if (startsSpecial(str)) { + // Strings that start with a special char are quoted with `!`. + // Since `!` is itself a special character, this trivially does + // the Hilbert hotel. Also, since the special characters are + // a continuous subrange of ascii, this quoting is sort-order + // preserving. + return `!${str}`; + } + // All other strings pass through to JSON + return str; +}; + /** * @typedef {object} EncodeToSmallcapsOptions * @property {( @@ -180,16 +193,7 @@ export const makeEncodeToSmallcaps = (encodeOptions = {}) => { return passable; } case 'string': { - if (startsSpecial(passable)) { - // Strings that start with a special char are quoted with `!`. - // Since `!` is itself a special character, this trivially does - // the Hilbert hotel. Also, since the special characters are - // a continuous subrange of ascii, this quoting is sort-order - // preserving. - return `!${passable}`; - } - // All other strings pass through to JSON - return passable; + return encodeStringToSmallcaps(passable); } case 'undefined': { return '#undefined'; @@ -216,13 +220,12 @@ export const makeEncodeToSmallcaps = (encodeOptions = {}) => { return `%${name}`; } case 'copyRecord': { - // Currently copyRecord allows only string keys so this will - // work. If we allow sortable symbol keys, this will need to - // become more interesting. + // copyRecord allows only string keys so this will + // work. const names = ownKeys(passable).sort(); return fromEntries( names.map(name => [ - encodeToSmallcapsRecur(name), + encodeStringToSmallcaps(name), encodeToSmallcapsRecur(passable[name]), ]), ); @@ -232,7 +235,7 @@ export const makeEncodeToSmallcaps = (encodeOptions = {}) => { } case 'tagged': { return { - '#tag': encodeToSmallcapsRecur(getTag(passable)), + '#tag': encodeStringToSmallcaps(getTag(passable)), payload: encodeToSmallcapsRecur(passable.payload), }; } diff --git a/packages/marshal/src/marshal-justin.js b/packages/marshal/src/marshal-justin.js index af9c112d04..e9f38897a0 100644 --- a/packages/marshal/src/marshal-justin.js +++ b/packages/marshal/src/marshal-justin.js @@ -113,9 +113,9 @@ const makeNoIndenter = () => { }); }; -const identPattern = /^[a-zA-Z]\w*$/; +export const identPattern = /^[a-zA-Z]\w*$/; harden(identPattern); -const AtAtPrefixPattern = /^@@(.*)$/; +export const AtAtPrefixPattern = /^@@(.*)$/; harden(AtAtPrefixPattern); /** diff --git a/packages/marshal/test/builders/test-smallcaps-builder.js b/packages/marshal/test/builders/test-smallcaps-builder.js new file mode 100644 index 0000000000..b27e8e0726 --- /dev/null +++ b/packages/marshal/test/builders/test-smallcaps-builder.js @@ -0,0 +1,40 @@ +import { test } from '../prepare-test-env-ava.js'; + +import * as sc from '../../src/builders/smallcapsBuilder.js'; +import * as js from '../../src/builders/subgraphBuilder.js'; +import { roundTripPairs } from '../test-marshal-capdata.js'; +import { makeSmallcapsTestMarshal } from '../test-marshal-smallcaps.js'; + +const { isFrozen } = Object; + +test('smallcaps builder', t => { + const scBuilder = sc.makeBuilder(); + const scRecognizer = sc.makeRecognizer(); + t.is(scRecognizer('#Infinity', scBuilder), '#Infinity'); + + const jsBuilder = js.makeBuilder(); + const jsRecognizer = js.makeRecognizer(); + t.is(jsRecognizer(Infinity, jsBuilder), Infinity); + t.is(scRecognizer('#Infinity', jsBuilder), Infinity); + t.is(jsRecognizer(Infinity, scBuilder), '#Infinity'); +}); + +test('smallcaps builder round trip half pairs', t => { + const scBuilder = sc.makeBuilder(); + const scRecognizer = sc.makeRecognizer(); + const jsBuilder = js.makeBuilder(); + const jsRecognizer = js.makeRecognizer(); + const { toCapData, fromCapData } = makeSmallcapsTestMarshal(); + for (const [plain, _] of roundTripPairs) { + const { body } = toCapData(plain); + const encoding = JSON.parse(body.slice(1)); + const decoding = fromCapData({ body, slots: [] }); + t.deepEqual(decoding, plain); + t.assert(isFrozen(decoding)); + + t.deepEqual(scRecognizer(encoding, scBuilder), encoding); + t.deepEqual(jsRecognizer(plain, jsBuilder), plain); + t.deepEqual(scRecognizer(encoding, jsBuilder), plain); + t.deepEqual(jsRecognizer(plain, scBuilder), encoding); + } +}); diff --git a/packages/marshal/test/test-marshal-smallcaps.js b/packages/marshal/test/test-marshal-smallcaps.js index 1b0c587525..6d44ee0fc2 100644 --- a/packages/marshal/test/test-marshal-smallcaps.js +++ b/packages/marshal/test/test-marshal-smallcaps.js @@ -13,7 +13,7 @@ const { freeze, isFrozen, create, prototype: objectPrototype } = Object; /** * @param {import('../src/types.js').MakeMarshalOptions} [opts] */ -const makeTestMarshal = (opts = { errorTagging: 'off' }) => +export const makeSmallcapsTestMarshal = (opts = { errorTagging: 'off' }) => makeMarshal(undefined, undefined, { serializeBodyFormat: 'smallcaps', marshalSaveError: _err => {}, @@ -21,18 +21,18 @@ const makeTestMarshal = (opts = { errorTagging: 'off' }) => }); test('smallcaps serialize unserialize round trip half pairs', t => { - const { serialize, unserialize } = makeTestMarshal(); + const { toCapData, fromCapData } = makeSmallcapsTestMarshal(); for (const [plain, _] of roundTripPairs) { - const { body } = serialize(plain); - const decoding = unserialize({ body, slots: [] }); + const { body } = toCapData(plain); + const decoding = fromCapData({ body, slots: [] }); t.deepEqual(decoding, plain); t.assert(isFrozen(decoding)); } }); test('smallcaps serialize static data', t => { - const { serialize } = makeTestMarshal(); - const ser = val => serialize(val); + const { toCapData } = makeSmallcapsTestMarshal(); + const ser = val => toCapData(val); // @ts-ignore `isFake` purposely omitted from type if (!harden.isFake) { @@ -56,8 +56,8 @@ test('smallcaps serialize static data', t => { }); test('smallcaps unserialize static data', t => { - const { unserialize } = makeTestMarshal(); - const uns = body => unserialize({ body, slots: [] }); + const { fromCapData } = makeSmallcapsTestMarshal(); + const uns = body => fromCapData({ body, slots: [] }); // should be frozen const arr = uns('#[1,2]'); @@ -70,8 +70,8 @@ test('smallcaps unserialize static data', t => { }); test('smallcaps serialize errors', t => { - const { serialize } = makeTestMarshal(); - const ser = val => serialize(val); + const { toCapData } = makeSmallcapsTestMarshal(); + const ser = val => toCapData(val); t.deepEqual(ser(harden(Error())), { body: '#{"#error":"","name":"Error"}', @@ -131,8 +131,8 @@ test('smallcaps serialize errors', t => { }); test('smallcaps unserialize errors', t => { - const { unserialize } = makeTestMarshal(); - const uns = body => unserialize({ body, slots: [] }); + const { fromCapData } = makeSmallcapsTestMarshal(); + const uns = body => fromCapData({ body, slots: [] }); const em1 = uns('#{"#error":"msg","name":"ReferenceError"}'); t.truthy(em1 instanceof ReferenceError); @@ -149,8 +149,8 @@ test('smallcaps unserialize errors', t => { }); test('smallcaps mal-formed @qclass', t => { - const { unserialize } = makeTestMarshal(); - const uns = body => unserialize({ body, slots: [] }); + const { fromCapData } = makeSmallcapsTestMarshal(); + const uns = body => fromCapData({ body, slots: [] }); t.throws(() => uns('#{"#foo": 0}'), { message: 'Unrecognized record type "#foo": {"#foo":0}', }); @@ -158,7 +158,7 @@ test('smallcaps mal-formed @qclass', t => { test('smallcaps records', t => { const fauxPresence = harden({}); - const { serialize: ser, unserialize: unser } = makeMarshal( + const { toCapData: ser, fromCapData: unser } = makeMarshal( _val => 'slot', _slot => fauxPresence, { @@ -252,7 +252,7 @@ test('smallcaps records', t => { * * `&` - promise */ test('smallcaps encoding examples', t => { - const { serialize, unserialize } = makeMarshal( + const { toCapData, fromCapData } = makeMarshal( val => val, slot => slot, { @@ -262,11 +262,11 @@ test('smallcaps encoding examples', t => { ); const assertSer = (val, body, slots, message) => - t.deepEqual(serialize(val), { body, slots }, message); + t.deepEqual(toCapData(val), { body, slots }, message); const assertRoundTrip = (val, body, slots, message) => { assertSer(val, body, slots, message); - const val2 = unserialize(harden({ body, slots })); + const val2 = fromCapData(harden({ body, slots })); assertSer(val2, body, slots, message); t.deepEqual(val, val2, message); }; @@ -400,7 +400,7 @@ test('smallcaps encoding examples', t => { test('smallcaps proto problems', t => { const exampleAlice = Far('Alice', {}); - const { serialize: toSmallcaps, unserialize: fromSmallcaps } = makeMarshal( + const { toCapData: toSmallcaps, fromCapData: fromSmallcaps } = makeMarshal( _val => 'slot', _slot => exampleAlice, {