diff --git a/packages/patterns/NEWS.md b/packages/patterns/NEWS.md index bd5e2e88ad..2abdf56e05 100644 --- a/packages/patterns/NEWS.md +++ b/packages/patterns/NEWS.md @@ -3,6 +3,19 @@ User-visible changes in `@endo/patterns`: # Next release - The `sloppy` option for `@endo/patterns` interface guards is deprecated. Use `defaultGuards` instead. +- PatternMatchers now includes a `M.choose(keyName, subPatternsRecord)` function to match a CopyRecord against a sub-pattern selected by the value of a specified discriminator key (i.e., for matching values of a discriminated union type). Behaviorally, the new matchers are slightly weaker than those from `M.or(...patterns)`, but more efficient, and they produce more precise error messages upon match failure. Note that the sub-patterns apply to a derived CopyRecord that lacks the discriminator property, so e.g. + ```js + M.choose('flavor', { + original: M.and({ flavor: 'original' }, M.any()), + }) + ``` + does not match anything, while + ```js + M.choose('flavor', { + original: M.any(), + }) + ``` + matches any CopyRecord with a "flavor" property whose value is "original". - `@endo/patterns` now exports a new `getNamedMethodGuards(interfaceGuard)` that returns that interface guard's record of method guards. The motivation is to support interface inheritance expressed by patterns like ```js const I2 = M.interface('I2', { diff --git a/packages/patterns/src/patterns/patternMatchers.js b/packages/patterns/src/patterns/patternMatchers.js index bb5155c229..2900eea10f 100644 --- a/packages/patterns/src/patterns/patternMatchers.js +++ b/packages/patterns/src/patterns/patternMatchers.js @@ -55,9 +55,20 @@ import { generateCollectionPairEntries } from '../keys/keycollection-operators.j * @import {KeyToDBKey} from '../types.js'; */ -const { entries, values, hasOwn } = Object; +const { entries, freeze, values, hasOwn } = Object; const { ownKeys } = Reflect; +/** + * @template O + * @template {PropertyKey} K + * @param {O} obj + * @param {K} key + * @returns {K extends keyof O ? O[K] : undefined} + */ +const getOwn = (obj, key) => + // @ts-expect-error TS doesn't let `hasOwn(obj, key)` support `obj[key]`. + hasOwn(obj, key) ? obj[key] : undefined; + const provideEncodePassableMetadata = memoize( /** @param {KeyToDBKey} encodePassable */ encodePassable => { @@ -1636,6 +1647,35 @@ const makePatternKit = () => { getPassStyleCover('tagged', encodePassable), }); + /** @type {MatchHelper} */ + const matchChooseHelper = Far('match:choose helper', { + confirmMatches: (specimen, [keyName, patts], reject) => { + if (!confirmKind(specimen, 'copyRecord', reject)) return false; + const keyValue = getOwn(specimen, keyName); + if (typeof keyValue !== 'string' || !hasOwn(patts, keyValue)) { + return ( + reject && + reject`${specimen} - Must have discriminator key ${q(keyName)} with value in ${q(ownKeys(patts))}` + ); + } + const label = `{${q(keyName)}: ${q(keyValue)}}`; + const { [keyName]: _, ...rest } = specimen; + const subPatt = patts[keyValue]; + return confirmNestedMatches(freeze(rest), subPatt, label, reject); + }, + + confirmIsWellFormed: (payload, reject) => + confirmMatches( + payload, + harden([MM.string(), MM.recordOf(MM.string(), MM.pattern())]), + false, + ) || + (reject && + reject`match:choose payload: ${payload} - Must be [string, Record]`), + + getRankCover: (patts, encodePassable) => getPassStyleCover('copyRecord'), + }); + /** * @param {Passable[]} specimen * @param {Pattern[]} requiredPatt @@ -1869,6 +1909,7 @@ const makePatternKit = () => { 'match:any': matchAnyHelper, 'match:and': matchAndHelper, 'match:or': matchOrHelper, + 'match:choose': matchChooseHelper, 'match:not': matchNotHelper, 'match:scalar': matchScalarHelper, @@ -2054,6 +2095,8 @@ const makePatternKit = () => { makeLimitsMatcher('match:containerHas', [elementPatt, countPatt, limits]), mapOf: (keyPatt = M.any(), valuePatt = M.any(), limits = undefined) => makeLimitsMatcher('match:mapOf', [keyPatt, valuePatt, limits]), + choose: (keyName, pattsRecord) => + makeMatcher('match:choose', harden([keyName, pattsRecord])), splitArray: (base, optional = undefined, rest = undefined) => makeMatcher( 'match:splitArray', diff --git a/packages/patterns/src/types.ts b/packages/patterns/src/types.ts index 38a536a2ba..0e8b79e944 100644 --- a/packages/patterns/src/types.ts +++ b/packages/patterns/src/types.ts @@ -463,6 +463,13 @@ export type PatternMatchers = { */ mapOf: (keyPatt?: Pattern, valuePatt?: Pattern, limits?: Limits) => Matcher; + /** + * Matches any CopyRecord that has a property named by `key` with a string + * value that identifies a sub-Pattern against which the CopyRecord of all + * other properties matches. + */ + choose: (keyName: string, subPatts: CopyRecord) => Matcher; + /** * Matches any array --- typically an arguments list --- consisting of * - an initial portion matched by `required`, and diff --git a/packages/patterns/test/patterns.test.js b/packages/patterns/test/patterns.test.js index 51677cd019..22eb5d9c92 100644 --- a/packages/patterns/test/patterns.test.js +++ b/packages/patterns/test/patterns.test.js @@ -1,19 +1,32 @@ /* eslint-disable no-continue */ import test from '@endo/ses-ava/test.js'; +import { fc } from '@fast-check/ava'; +import { makeArbitraries } from '@endo/pass-style/tools.js'; + import { Fail } from '@endo/errors'; import { makeTagged, Far, qp } from '@endo/marshal'; +import { passStyleOf } from '@endo/pass-style'; import { makeCopyBag, makeCopyMap, makeCopySet, getCopyMapKeys, } from '../src/keys/checkKey.js'; -import { mustMatch, matches, M } from '../src/patterns/patternMatchers.js'; +import { + isPattern, + mustMatch, + matches, + M, +} from '../src/patterns/patternMatchers.js'; const { stringify: q } = JSON; /** @import * as ava from 'ava' */ +/** @import {CopyRecord} from '@endo/pass-style' */ +/** @import {Pattern} from '../src/types.js' */ + +const { arbPassable } = makeArbitraries(fc); // TODO The desired semantics for CopyMap comparison have not yet been decided. // See https://github.com/endojs/endo/pull/1737#pullrequestreview-1596595411 @@ -237,6 +250,12 @@ const runTests = (t, successCase, failCase) => { '[0]: number 3 - Must be a string', ); + failCase( + specimen, + M.choose('0', { 3: M.any() }), + 'copyArray [3,4] - Must be a copyRecord', + ); + failCase( specimen, M.containerHas('c'), @@ -413,6 +432,73 @@ const runTests = (t, successCase, failCase) => { M.recordOf(M.string(), M.string()), 'foo: [1]: number 3 - Must be a string', ); + + failCase( + specimen, + M.choose('foo', { 3: M.any() }), + '{"bar":4,"foo":3} - Must have discriminator key "foo" with value in ["3"]', + ); + } + { + const specimen = { foo: 'bar', bar: 'baz' }; + successCase(specimen, { foo: 'bar', bar: 'baz' }); + const yesMethods = ['record', 'any', 'and', 'key', 'pattern']; + for (const [method, makeMessage] of Object.entries(simpleMethods)) { + if (yesMethods.includes(method)) { + successCase(specimen, M[method]()); + continue; + } + successCase(specimen, M.not(M[method]())); + failCase( + specimen, + M[method](), + makeMessage('{"bar":"baz","foo":"bar"}', 'copyRecord'), + ); + } + successCase(specimen, { foo: M.string(), bar: M.any() }); + successCase(specimen, { foo: M.lte('bar'), bar: M.gte('baz') }); + // Records compare pareto + successCase(specimen, M.gt({ foo: 'bar', bar: 'bat' })); + successCase(specimen, M.lt({ foo: 'bar', bar: 'bazz' })); + successCase( + specimen, + M.split( + { foo: M.string() }, + M.and(M.partial({ bar: M.string() }), M.partial({ baz: M.string() })), + ), + ); + successCase( + specimen, + M.split( + { foo: M.string() }, + M.partial({ bar: M.string(), baz: M.string() }), + ), + ); + + successCase(specimen, M.recordOf(M.string(), M.string())); + + failCase( + specimen, + { foo: M.lte('bar'), bar: M.gt('baz') }, + 'bar: "baz" - Must be > "baz"', + ); + failCase( + specimen, + M.gt({ foo: 'bar', bar: 'baz' }), + '{"bar":"baz","foo":"bar"} - Must be > {"bar":"baz","foo":"bar"}', + ); + failCase( + specimen, + M.lt({ foo: 'bar', bar: 'baz' }), + '{"bar":"baz","foo":"bar"} - Must be < {"bar":"baz","foo":"bar"}', + ); + + successCase(specimen, M.choose('foo', { bar: M.any(), baz: null })); + successCase( + specimen, + M.choose('foo', { bar: { bar: M.string() }, baz: null }), + ); + successCase(specimen, M.choose('foo', { bar: { bar: 'baz' }, baz: null })); } { const specimen = makeCopySet([3, 4]); @@ -914,3 +1000,34 @@ test('well formed patterns', t => { message: 'M.containerHas payload: [1]: 1 - Must be >= "[1n]"', }); }); + +test('M.choose well-formedness', async t => { + // @ts-expect-error purposeful type violation for testing + t.throws(() => M.choose(), { + message: + 'match:choose payload: ["[undefined]","[undefined]"] - Must be [string, Record]', + }); + + await fc.assert( + fc.property(fc.array(arbPassable, { minLength: 1, maxLength: 2 }), args => { + const [keyName, patts] = args; + if ( + typeof keyName === 'string' && + passStyleOf(patts) === 'copyRecord' && + isPattern(patts) + ) { + // This input seems valid, so we just check it (avoiding fast-check + // `.filter` which can otherwise lead to https://crbug.com/1201626 + // crashes). + const typedPatts = /** @type {CopyRecord} */ (patts); + t.truthy(M.choose(keyName, typedPatts)); + } else { + // @ts-expect-error purposeful type violation for testing + t.throws(() => M.choose(...args), { + message: + /^match:choose payload: \[.+?\] - Must be \[string, Record\]$/, + }); + } + }), + ); +});