Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/patterns/NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down
45 changes: 44 additions & 1 deletion packages/patterns/src/patterns/patternMatchers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -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);
Comment on lines +1662 to +1664
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rest is constructed by destructuring. The Jessie Must freeze API Surface Before Use don't seem to capture this case.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The specimen must already be passable and therefore hardened. Given that, only the top level of rest is not frozen. Everything deeper is frozen. Therefore, the freeze here means the code does uphold the purpose "Must freeze API Surface Before Use". The lack is on the Jessie side, where we don't have any syntactic machinery to recognize that this code conforms.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, this code conforms.

My comment aims to say: the Jessie rules don't seem to say that you need a harden(...) here.

},

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<string, Pattern>]`),

getRankCover: (patts, encodePassable) => getPassStyleCover('copyRecord'),
});

/**
* @param {Passable[]} specimen
* @param {Pattern[]} requiredPatt
Expand Down Expand Up @@ -1869,6 +1909,7 @@ const makePatternKit = () => {
'match:any': matchAnyHelper,
'match:and': matchAndHelper,
'match:or': matchOrHelper,
'match:choose': matchChooseHelper,
'match:not': matchNotHelper,

'match:scalar': matchScalarHelper,
Expand Down Expand Up @@ -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',
Expand Down
7 changes: 7 additions & 0 deletions packages/patterns/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Pattern>) => Matcher;

/**
* Matches any array --- typically an arguments list --- consisting of
* - an initial portion matched by `required`, and
Expand Down
119 changes: 118 additions & 1 deletion packages/patterns/test/patterns.test.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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<string, Pattern>]',
});

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<Pattern>} */ (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<string, Pattern>\]$/,
});
}
}),
);
});
Loading