diff --git a/packages/marshal/src/builders/builder-types.js b/packages/marshal/src/builders/builder-types.js new file mode 100644 index 0000000000..127340d69d --- /dev/null +++ b/packages/marshal/src/builders/builder-types.js @@ -0,0 +1,41 @@ +/** @typedef {import('../types').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/justinBuilder.js b/packages/marshal/src/builders/justinBuilder.js new file mode 100644 index 0000000000..ebc24e0fd5 --- /dev/null +++ b/packages/marshal/src/builders/justinBuilder.js @@ -0,0 +1,52 @@ +/// + +import { Far, nameForPassableSymbol } from '@endo/pass-style'; +import { identPattern, AtAtPrefixPattern } from '../marshal-justin.js'; + +const { stringify } = JSON; +const { Fail, quote: q } = assert; + +export const makeJustinBuilder = out => { + const outNextJSON = val => out.next(stringify(val)); + + /** @type {Builder} */ + const justinBuilder = Far('JustinBuilder', { + buildRoot: _node => out.done(), + + buildUndefined: () => out.next('undefined'), + buildNull: () => out.next('null'), + buildBoolean: outNextJSON, + buildNumber: outNextJSON, + 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: _builtEntries => 'x', + buildArray: _builtElements => 'x', + buildTagged: (_tagName, _builtPayload) => 'x', + + buildError: _error => 'x', + buildRemotable: _remotable => 'x', + buildPromise: _promise => 'x', + }); + return justinBuilder; +}; +harden(makeJustinBuilder); + +export { makeJustinBuilder as makeBuilder }; diff --git a/packages/marshal/src/builders/smallcapsBuilder.js b/packages/marshal/src/builders/smallcapsBuilder.js new file mode 100644 index 0000000000..6bafd0e768 --- /dev/null +++ b/packages/marshal/src/builders/smallcapsBuilder.js @@ -0,0 +1,237 @@ +/// + +import { + assertPassableSymbol, + Far, + hasOwnPropertyOf, + nameForPassableSymbol, + passableSymbolForName, +} from '@endo/pass-style'; +import { startsSpecial } from '../encodeToSmallcaps.js'; + +/** @typedef {import('../types.js').Encoding} Encoding */ + +const { is, fromEntries, entries } = Object; +const { isArray } = Array; +const { ownKeys } = Reflect; +const { quote: q, details: X, Fail } = assert; + +export const makeSmallcapsBuilder = () => { + const buildString = 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; + }; + + /** @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 => + fromEntries( + builtEntries.map(([name, builtValue]) => [ + buildString(name), + builtValue, + ]), + ), + buildArray: builtElements => builtElements, + buildTagged: (tagName, builtPayload) => ({ + '#tag': buildString(tagName), + payload: builtPayload, + }), + + buildError: _error => 'x', + buildRemotable: _remotable => 'x', + buildPromise: _promise => 'x', + }); + return smallcapsBuilder; +}; +harden(makeSmallcapsBuilder); + +const makeSmallcapsRecognizer = () => { + /** + * 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 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 '+': { + return builder.buildBigint(BigInt(encoding.slice(1))); + } + case '-': { + return builder.buildBigint(BigInt(encoding)); + } + // case '$': { + // const result = decodeRemotableFromSmallcaps( + // encoding, + // decodeFromSmallcaps, + // ); + // if (passStyleOf(result) !== 'remotable') { + // Fail`internal: decodeRemotableFromSmallcaps option must return a remotable: ${result}`; + // } + // return result; + // } + // case '&': { + // const result = decodePromiseFromSmallcaps( + // encoding, + // decodeFromSmallcaps, + // ); + // if (passStyleOf(result) !== 'promise') { + // Fail`internal: decodePromiseFromSmallcaps option must return a promise: ${result}`; + // } + // return result; + // } + 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')) { + // const result = decodeErrorFromSmallcaps( + // encoding, + // decodeFromSmallcaps, + // ); + // passStyleOf(result) === 'error' || + // Fail`internal: decodeErrorFromSmallcaps option must return an error: ${result}`; + // return result; + // } + + 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..86a2161f0a --- /dev/null +++ b/packages/marshal/src/builders/subgraphBuilder.js @@ -0,0 +1,126 @@ +/// + +import { Far, getTag, makeTagged, passStyleOf } from '@endo/pass-style'; + +const { fromEntries } = Object; +const { ownKeys } = Reflect; +const { quote: q, details: X } = assert; + +export 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); + +export 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': { + // Currently copyRecord allows only string keys so this will + // work. If we allow sortable symbol keys, this will need to + // become more interesting. + const names = ownKeys(passable).sort(); + return builder.buildCopyRecord( + names.map(name => [ + subgraphRecognizer(name, builder), + subgraphRecognizer(passable[name], builder), + ]), + ); + } + case 'copyArray': { + return builder.buildCopyRecord( + passable.map(el => subgraphRecognizer(el, builder)), + ); + } + case 'tagged': { + return builder.buildTagged( + subgraphRecognizer(getTag(passable), builder), + subgraphRecognizer(passable.payload, builder), + ); + } + // case 'remotable': { + // const result = encodeRemotableToSmallcaps( + // passable, + // encodeToSmallcapsRecur, + // ); + // if (typeof result === 'string' && result.startsWith('$')) { + // return result; + // } + // // `throw` is noop since `Fail` throws. But linter confused + // throw Fail`internal: Remotable encoding must start with "$": ${result}`; + // } + // case 'promise': { + // const result = encodePromiseToSmallcaps( + // passable, + // encodeToSmallcapsRecur, + // ); + // if (typeof result === 'string' && result.startsWith('&')) { + // return result; + // } + // throw Fail`internal: Promise encoding must start with "&": ${result}`; + // } + // case 'error': { + // const result = encodeErrorToSmallcaps(passable, encodeToSmallcapsRecur); + // assertEncodedError(result); + // return result; + // } + 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..4aeb179fd1 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; } 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); /**