diff --git a/README.md b/README.md index 6769a30..d15d21f 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ These hold across every part of the design below, and any implementation change - **No assumptions about consumer data.** The only places this package touches real data are three named resolver contracts (see [Resolvers](#resolvers)). The schema stores *what to pass* to a resolver, never any resolver logic itself, and never interprets the meaning of an opaque key, table identifier, or collection reference. - **Three outcomes, never two.** Every evaluation produces a definite result or an indeterminate result carrying a reason — never a bare `boolean`/`number`, and never a thrown exception for a data-quality problem. See [The evaluation model](#the-evaluation-model). -- **Derived constructs are compositions, not new logic.** Anything describable as "some other primitive, wired together" is implemented that way, so its correctness is inherited rather than requiring separate proof. See [Derived connectives](#derived-connectives), [Derived aggregates](#derived-aggregates), [Derived values](#derived-values), and [Defining your own named presets](#defining-your-own-named-presets). +- **Derived constructs are compositions, not new logic.** Anything describable as "some other primitive, wired together" is implemented that way, so its correctness is inherited rather than requiring separate proof. See [Derived connectives](#derived-connectives), [Derived aggregates](#derived-aggregates), [Derived values](#derived-values), [Pattern-matching builders](#pattern-matching-builders), and [Defining your own named presets](#defining-your-own-named-presets). - **One schema, mechanically derived artefacts.** A single canonical type definition produces the runtime validator and the portable wire-format schema; they cannot drift apart because there is only one source. See [Schema strategy](#schema-strategy). - **A numeric extension that stays closed-form is in scope; a different kind of computation is not.** When something the current numeric model does not cover comes up, the test is whether evaluating it is still closed-form numeric evaluation — no solving, no simplification, no code execution. If it is, it belongs here, however unlike the existing kinds it looks: [Complex values](#complex-values) were once listed under [Out of scope](#out-of-scope) on a sizing judgement that turned out to be wrong, since complex arithmetic is exactly the closed-form evaluation this evaluator already does for every other kind. What stays behind [`delegate`](#delegate) is a genuinely different *kind* of computation — symbolic algebra, arbitrary external computation — not merely a kind of number the model has not reached yet. - **Generic examples only.** Every example in this document uses invented, placeholder field names (`temperature`, `orderTotal`, `isActive`, `x`, `y`, `amount`, `items`) with no resemblance to any particular company, product, or industry's real data model. @@ -374,6 +374,38 @@ A relational-comparison leaf: compares two computed values using `gt`/`gte`/`lt` A text-matching leaf, symmetric in the same way as `compare`: both `left` and `right` are `ExpressionNode`, and either may be a literal or an arbitrary formula. `equals`/`notEquals` are exact string equality; `matches`/`notMatches` interpret `right` as a pattern (an ECMAScript-style regular expression) tested against `left`'s text. Both operands must resolve to the `text` computed-value kind; anything else is `wrong-type`. A "small fixed category" value (e.g. a status label) is simply a `text` computed value from this leaf's point of view — no separate category kind exists. +### Pattern-matching builders + +`matches` already covers arbitrary pattern matching, but writing the regular expression by hand is where the common, narrower cases go wrong: getting the escape-then-convert ordering backwards either stops wildcards working or silently reinterprets a literal asterisk in real data as one. Three builder functions compile a pattern string into an ordinary `textCompare` node instead — never a new node kind, never an evaluator branch, exactly the same composition-not-new-logic treatment [Derived connectives](#derived-connectives), [Derived aggregates](#derived-aggregates), and [Derived values](#derived-values) already give `xor`/`sum`/`coalesce`: + +```ts +const command: ExpressionNode = { kind: "reference", key: "command" }; +const path: ExpressionNode = { kind: "reference", key: "path" }; + +// Matches "ls" and "ls -la", never "lsof". +prefixPattern(command, "ls"); +// Matches "git add file" and, by the trailing-wildcard convenience below, bare "git". +wildcardPattern(command, "git *"); +// Matches "workspace/report.txt", but not "workspace/archive/report.txt". +hierarchicalGlobPattern(path, "workspace/*"); +``` + +Each returns an ordinary predicate node — `prefixPattern(command, "ls")` is exactly `{ kind: "textCompare", op: "matches", left: command, right: { kind: "textLiteral", value: "^ls(?: [\\s\\S]*)?$" } }`. Compilation happens once, when the tree is built, so what is stored and serialised is a `textCompare` tree indistinguishable from one written out by hand — a consumer that never calls a builder loses nothing, and a serialised tree carries no dependency on the builder that produced it. + +The three are separate dialects, deliberately not one function with a mode argument, because they answer different questions and mixing them silently changes what a pattern means: + +| Builder | `*` | `**` | `?` | Escapes | Intended for | +|---|---|---|---|---|---| +| `prefixPattern` | literal | literal | literal | none — the prefix is a plain literal throughout | Command/label prefixes where `"ls"` must match `"ls"` and `"ls -la"` but never `"lsof"` | +| `wildcardPattern` | any characters | (two wildcards in a row) | literal | `\*` for a literal asterisk, `\\` for a literal backslash | Flat strings with no internal hierarchy | +| `hierarchicalGlobPattern` | any characters within one `/`-delimited segment | any characters across segments | one character within a segment | none — a backslash is a literal backslash | Path- or category-tree-shaped values | + +Two behaviours in `wildcardPattern` are worth stating rather than leaving to be inferred. Its pattern is trimmed before compiling. And a pattern whose **only** unescaped wildcard is a trailing `" *"` also matches the bare prefix, so `"git *"` matches `"git"` as well as `"git add file"` — the convenience does not apply to `"git * *"`, where both wildcards are still required. + +The compiled pattern is a fully anchored, flag-free string: "any character" is spelled `[\s\S]` rather than `.`, because the stored pattern carries no `s` flag and nothing downstream can add one, and the only characters escaped are ECMAScript's own `SyntaxCharacter` set plus `/`, which are exactly the escapes that stay valid under a `u`/`v`-flagged `RegExp` as well as an unflagged one. A compiled pattern is therefore portable in the strongest available sense — it means the same thing wherever it is compiled, including pasted verbatim into a `/.../` literal. + +Three-valued behaviour is inherited unchanged from [`textCompare`](#textcompare) and needs no separate proof: an unresolvable subject is indeterminate rather than a non-match, and a non-`text` subject is `wrong-type` — a compiled pattern never turns a data problem into a definite `false`. + ### `memberOf` A membership-test leaf, parallel to `compare` and `textCompare` rather than folded into either one's operator set: `operand` is the `ExpressionNode` being tested; `candidates` is a list of `ExpressionNode`s to test it against, every element of which may independently be an arbitrary formula, not only a literal — the same symmetry principle already applied to `compare` and `textCompare`. `op: "in"` asks whether `operand` equals any candidate; `op: "notIn"` asks whether it equals none of them. diff --git a/src/derived-patterns.test.ts b/src/derived-patterns.test.ts new file mode 100644 index 0000000..f214160 --- /dev/null +++ b/src/derived-patterns.test.ts @@ -0,0 +1,326 @@ +import { describe, expect, it } from "vitest"; +import { + hierarchicalGlobPattern, + prefixPattern, + wildcardPattern, +} from "./derived-patterns"; +import { createEvaluator, evaluatePredicate } from "./evaluator"; +import type { PredicateNode } from "./tree"; +import type { Resolvers } from "./resolvers"; + +/** + * None of the three builders ever appears as its own `kind` discriminant (see derived-patterns.ts) -- every behavioural test below compiles a pattern and then runs the resulting tree through the genuine public `evaluatePredicate` entry point against a resolver-backed subject, exactly as if the builder were an opaque black box, so what is proved is that the compiled regex genuinely matches under the real evaluator rather than merely that it looks plausible as a string. Two deliberate exceptions sit at the bottom: a structural-equality assertion pinning the composition itself, and a small set of assertions on the compiled pattern string, which is the one thing black-box matching cannot show is portable. + */ + +const subjectKey = "subject"; + +/** Resolves `subject` to the text under test and nothing else, so an unresolved key stays a genuine `not-found` rather than a hand-built indeterminate. */ +function resolversFor(subject: string): Resolvers { + return { + resolveValue: async (key) => + Promise.resolve( + key === subjectKey + ? { found: true, value: { kind: "text", value: subject } } + : { found: false }, + ), + resolveLookup: async () => Promise.resolve({ found: false }), + resolveCollection: async () => Promise.resolve([]), + }; +} + +const subject = { kind: "reference", key: subjectKey } as const; + +/** Runs a built pattern node through the real evaluator and asserts a definite outcome, failing loudly rather than coercing an indeterminate result into `false`. */ +async function matches(node: PredicateNode, text: string): Promise { + const result = await evaluatePredicate(node, undefined, resolversFor(text)); + expect(result.status).toBe("definite"); + if (result.status !== "definite") { + throw new Error("expected a definite match outcome"); + } + return result.value; +} + +function compiledPattern(node: PredicateNode): string { + expect(node.kind).toBe("textCompare"); + if (node.kind !== "textCompare") { + throw new Error("expected a textCompare node"); + } + expect(node.right.kind).toBe("textLiteral"); + if (node.right.kind !== "textLiteral") { + throw new Error("expected a textLiteral pattern operand"); + } + return node.right.value; +} + +describe("prefixPattern", () => { + it.each<[string, string, boolean]>([ + ["ls", "ls", true], + ["ls", "ls -la", true], + ["ls", "ls two spaces", true], + ["ls", "ls ", true], + ["ls", "lsof", false], + ["ls", "xls", false], + ["ls", "", false], + ["git commit", "git commit -m message", true], + ["git commit", "git committed", false], + ])("prefix %o against %o => %s", async (prefix, text, expected) => { + expect(await matches(prefixPattern(subject, prefix), text)).toBe(expected); + }); + + it("treats the prefix as a plain literal, so a regex metacharacter in it matches only itself", async () => { + const node = prefixPattern(subject, "a.b"); + expect(await matches(node, "a.b")).toBe(true); + expect(await matches(node, "axb")).toBe(false); + }); + + it("treats an asterisk in the prefix as a literal asterisk, since the prefix dialect has no wildcard syntax", async () => { + const node = prefixPattern(subject, "run*"); + expect(await matches(node, "run*")).toBe(true); + expect(await matches(node, "run* now")).toBe(true); + expect(await matches(node, "running")).toBe(false); + }); +}); + +describe("wildcardPattern", () => { + it.each<[string, string, boolean]>([ + ["git status", "git status", true], + ["git status", "git status --short", false], + ["git *", "git add file", true], + ["git *", "git", true], + ["git *", "gitx", false], + ["*.ts", "index.ts", true], + ["*.ts", "src/index.ts", true], + ["*.ts", "index.tsx", false], + ["npm run *", "npm run build", true], + ["npm run *", "npm run", true], + ["a*c", "abc", true], + ["a*c", "ac", true], + ["a*c", "abd", false], + ])("pattern %o against %o => %s", async (pattern, text, expected) => { + expect(await matches(wildcardPattern(subject, pattern), text)).toBe( + expected, + ); + }); + + it("matches a literal asterisk via \\*, never treating it as a wildcard", async () => { + const node = wildcardPattern(subject, String.raw`a\*b`); + expect(await matches(node, "a*b")).toBe(true); + expect(await matches(node, "axb")).toBe(false); + expect(await matches(node, "ab")).toBe(false); + }); + + it("matches a literal backslash via \\\\", async () => { + const node = wildcardPattern(subject, String.raw`a\\b`); + expect(await matches(node, String.raw`a\b`)).toBe(true); + expect(await matches(node, "ab")).toBe(false); + }); + + it("keeps an escaped asterisk literal while a neighbouring unescaped one still wildcards", async () => { + const node = wildcardPattern(subject, String.raw`\**`); + expect(await matches(node, "*")).toBe(true); + expect(await matches(node, "*anything")).toBe(true); + expect(await matches(node, "anything")).toBe(false); + }); + + it("applies the trailing-single-wildcard convenience only when that wildcard is the pattern's sole unescaped one", async () => { + const single = wildcardPattern(subject, "git *"); + expect(await matches(single, "git")).toBe(true); + const two = wildcardPattern(subject, "git * *"); + expect(await matches(two, "git")).toBe(false); + expect(await matches(two, "git a b")).toBe(true); + }); + + it("does not apply the convenience when the trailing wildcard is not preceded by a space", async () => { + const node = wildcardPattern(subject, "git*"); + expect(await matches(node, "git")).toBe(true); + expect(await matches(node, "gitx")).toBe(true); + }); + + it("trims surrounding whitespace from the pattern before compiling it", async () => { + expect(await matches(wildcardPattern(subject, " git * "), "git")).toBe( + true, + ); + }); + + it("matches across line terminators, since the compiled pattern carries no flags to enable that behaviour later", async () => { + expect(await matches(wildcardPattern(subject, "a*b"), "a\nb")).toBe(true); + }); + + it("escapes regex metacharacters in the literal parts of the pattern", async () => { + const node = wildcardPattern(subject, "v1.0 (*)"); + expect(await matches(node, "v1.0 (beta)")).toBe(true); + expect(await matches(node, "v1x0 (beta)")).toBe(false); + }); +}); + +describe("hierarchicalGlobPattern", () => { + it.each<[string, string, boolean]>([ + ["src/*", "src/index.ts", true], + ["src/*", "src/nested/index.ts", false], + ["src/*", "src/", true], + ["src/**", "src/index.ts", true], + ["src/**", "src/nested/deep/index.ts", true], + ["**/*.ts", "src/index.ts", true], + ["**/*.ts", "src/nested/index.ts", true], + ["*", "one", true], + ["*", "one/two", false], + ["**", "one/two/three", true], + ["docs/readme.md", "docs/readme.md", true], + ["docs/readme.md", "docs/readme_md", false], + ["docs/readme.md", "docs/readme.md.bak", false], + ])("glob %o against %o => %s", async (pattern, text, expected) => { + expect(await matches(hierarchicalGlobPattern(subject, pattern), text)).toBe( + expected, + ); + }); + + it("distinguishes * from ** at the same position: one segment versus every segment", async () => { + const single = hierarchicalGlobPattern(subject, "electrical/*/cables"); + const double = hierarchicalGlobPattern(subject, "electrical/**/cables"); + expect(await matches(single, "electrical/lv/cables")).toBe(true); + expect(await matches(single, "electrical/lv/underground/cables")).toBe( + false, + ); + expect(await matches(double, "electrical/lv/cables")).toBe(true); + expect(await matches(double, "electrical/lv/underground/cables")).toBe( + true, + ); + }); + + it("requires ** to still be followed by its literal separator, so 'src/**/x' does not match 'src/x'", async () => { + const node = hierarchicalGlobPattern(subject, "src/**/x"); + expect(await matches(node, "src/a/x")).toBe(true); + expect(await matches(node, "src/x")).toBe(false); + }); + + it("matches exactly one within-segment character with ?", async () => { + const node = hierarchicalGlobPattern(subject, "v?"); + expect(await matches(node, "v1")).toBe(true); + expect(await matches(node, "v12")).toBe(false); + expect(await matches(node, "v")).toBe(false); + expect(await matches(node, "v/")).toBe(false); + }); + + it("reads an odd run of asterisks as ** followed by *", async () => { + const node = hierarchicalGlobPattern(subject, "a/***"); + expect(await matches(node, "a/b/c")).toBe(true); + expect(await matches(node, "a/b")).toBe(true); + }); + + it("treats a backslash as a literal backslash, since this dialect has no escape syntax", async () => { + const node = hierarchicalGlobPattern(subject, String.raw`a\b`); + expect(await matches(node, String.raw`a\b`)).toBe(true); + expect(await matches(node, "ab")).toBe(false); + }); +}); + +describe("three-valued behaviour is inherited from textCompare, not re-decided", () => { + it("an unresolvable subject stays indeterminate rather than compiling to a non-match", async () => { + const result = await evaluatePredicate( + prefixPattern({ kind: "reference", key: "absent" }, "ls"), + undefined, + resolversFor("ls"), + ); + expect(result.status).toBe("indeterminate"); + }); + + it("a non-text subject is wrong-type, exactly as a hand-written textCompare would be", async () => { + const result = await evaluatePredicate( + wildcardPattern({ kind: "numberLiteral", value: 1 }, "*"), + undefined, + resolversFor("anything"), + ); + expect(result.status).toBe("indeterminate"); + if (result.status === "indeterminate") { + expect(result.reason.code).toBe("wrong-type"); + } + }); +}); + +describe("end-to-end through createEvaluator, composed with other node kinds", () => { + it("evaluates a compiled pattern inside an and/not composition via the evaluator factory", async () => { + const evaluator = createEvaluator({}); + const isGitCommand = wildcardPattern(subject, "git *"); + const isPush = prefixPattern(subject, "git push"); + const readOnlyGitCommand: PredicateNode = { + kind: "and", + left: isGitCommand, + right: { kind: "not", operand: isPush }, + }; + + expect( + await evaluator.evaluatePredicate( + readOnlyGitCommand, + undefined, + resolversFor("git status"), + ), + ).toEqual({ status: "definite", value: true }); + expect( + await evaluator.evaluatePredicate( + readOnlyGitCommand, + undefined, + resolversFor("git push origin main"), + ), + ).toEqual({ status: "definite", value: false }); + expect( + await evaluator.evaluatePredicate( + readOnlyGitCommand, + undefined, + resolversFor("npm run build"), + ), + ).toEqual({ status: "definite", value: false }); + }); +}); + +describe("derived = composition, not separate logic", () => { + it("prefixPattern(text, prefix) is an ordinary textCompare 'matches' node with the compiled pattern as a textLiteral", () => { + expect(prefixPattern(subject, "ls")).toEqual({ + kind: "textCompare", + op: "matches", + left: subject, + right: { kind: "textLiteral", value: "^ls(?: [\\s\\S]*)?$" }, + }); + }); + + it("compiles to a flag-free, fully anchored pattern that stays valid unflagged and under both a u- and a v-flagged RegExp", () => { + const compiled = [ + compiledPattern(prefixPattern(subject, "a.b/c")), + compiledPattern(wildcardPattern(subject, String.raw`a.b/c\**`)), + compiledPattern(hierarchicalGlobPattern(subject, "a.b/**/c?")), + // The within-segment classes on their own: `*` and `?` are the only constructs that emit one, and a glob is far likelier to consist of nothing else than to also carry the literals above. + compiledPattern(hierarchicalGlobPattern(subject, "*")), + compiledPattern(hierarchicalGlobPattern(subject, "?")), + compiledPattern(hierarchicalGlobPattern(subject, "*/?")), + ]; + for (const pattern of compiled) { + expect(pattern.startsWith("^")).toBe(true); + expect(pattern.endsWith("$")).toBe(true); + // `v` is checked alongside `u` because the two disagree about what a character class may contain: `v` reserves `/` as a ClassSetReservedPunctuator, so a bare `[^/]` compiles unflagged and under `u` but is a SyntaxError under `v`. Checking `u` alone would let exactly that through. + expect(() => new RegExp(pattern)).not.toThrow(); + expect(() => new RegExp(pattern, "u")).not.toThrow(); + expect(() => new RegExp(pattern, "v")).not.toThrow(); + } + }); + + it("escapes the separator inside a hierarchical glob's within-segment character classes, which a v-flagged RegExp rejects unescaped", () => { + expect(compiledPattern(hierarchicalGlobPattern(subject, "*"))).toBe( + "^[^\\/]*$", + ); + expect(compiledPattern(hierarchicalGlobPattern(subject, "?"))).toBe( + "^[^\\/]$", + ); + // The escape is purely a syntactic requirement of `v` mode: it must not change what the class actually matches under any flag. + for (const flag of ["", "u", "v"]) { + const single = new RegExp( + compiledPattern(hierarchicalGlobPattern(subject, "a/*")), + flag, + ); + expect(single.test("a/b")).toBe(true); + expect(single.test("a/b/c")).toBe(false); + } + }); + + it("spells 'any character' without relying on a dotAll flag the stored pattern cannot carry", () => { + expect(compiledPattern(wildcardPattern(subject, "*"))).toBe("^[\\s\\S]*$"); + }); +}); diff --git a/src/derived-patterns.ts b/src/derived-patterns.ts new file mode 100644 index 0000000..34a6e8a --- /dev/null +++ b/src/derived-patterns.ts @@ -0,0 +1,118 @@ +import type { ExpressionNode, PredicateNode } from "./tree"; + +/** + * `prefixPattern`/`wildcardPattern`/`hierarchicalGlobPattern` are never their own node kind and add no evaluator branch -- each is a builder function that compiles its pattern string into a portable regular-expression string and assembles an ordinary `textCompare` node with `op: "matches"` around it, exactly the same treatment `derived-connectives.ts`/`derived-aggregates.ts`/`derived-values.ts` already give `xor`/`sum`/`coalesce` (see the "Pattern-matching builders" section of README.md). The compilation happens once, when the tree is built, so what is stored and evaluated is an ordinary `textCompare` tree indistinguishable from one written out by hand. + */ + +/** The characters a compiled pattern must backslash-escape to match literally. Deliberately exactly ECMAScript's own `SyntaxCharacter` set plus `/`: those are the only escapes that stay valid under a `u`/`v`-flagged `RegExp` as well as an unflagged one, so a compiled pattern string remains usable however a consumer chooses to compile it -- including pasted verbatim into a `/.../` literal, which is what `/` earns its place for. Escaping anything outside this set (a quote, say) would be inert under the evaluator's own unflagged `new RegExp` but a `SyntaxError` under `u`. */ +const REGEX_SYNTAX_CHARACTERS = "^$\\.*+?()[]{}|/"; + +/** `[\s\S]*` rather than `.*` because the compiled string carries no flags of its own: `.` excludes line terminators unless the `s` flag is set, and `textCompare`'s evaluator compiles the pattern with `new RegExp(right.value)` and no flags at all. A character class covering both `\s` and `\S` is the flag-free spelling of "any character", so the same string behaves identically wherever it is compiled. */ +const ANY_CHARACTERS = "[\\s\\S]*"; +/** A hierarchical glob's single `*`: any run of characters that stays inside one `/`-delimited segment. The separator is written `\/` rather than a bare `/` because `v`-mode reserves `/` as a `ClassSetReservedPunctuator` and rejects it unescaped inside a character class -- a bare `[^/]` is a `SyntaxError` under `v`, while `[^\/]` compiles identically under `v`, `u` and no flags at all, which is the portability `REGEX_SYNTAX_CHARACTERS` already promises for every literal character. */ +const ANY_CHARACTERS_WITHIN_SEGMENT = "[^\\/]*"; +/** A hierarchical glob's `?`: exactly one character, still constrained to a single segment. Escaped for the same `v`-mode reason as `ANY_CHARACTERS_WITHIN_SEGMENT` above. */ +const ANY_CHARACTER_WITHIN_SEGMENT = "[^\\/]"; + +function escapeRegexLiteral(character: string): string { + return REGEX_SYNTAX_CHARACTERS.includes(character) + ? `\\${character}` + : character; +} + +/** A prefix match that respects word boundaries: the subject is either the prefix exactly, or the prefix followed by a space and anything at all. `"ls"` therefore matches `"ls"` and `"ls -la"` but never `"lsof"`. The prefix itself is a plain literal -- every character in it is escaped, so it carries no wildcard or escape syntax of its own. */ +function compilePrefixPattern(prefix: string): string { + const escaped = Array.from(prefix, escapeRegexLiteral).join(""); + return `^${escaped}(?: ${ANY_CHARACTERS})?$`; +} + +/** + * A flat wildcard dialect with no notion of path segments: an unescaped `*` matches any run of characters, `\*` matches a literal asterisk, and `\\` matches a literal backslash. Compiled in a single left-to-right pass rather than by successive `String.replace` phases over sentinel placeholders, so a pattern containing whatever string a sentinel happened to use cannot be corrupted by its own restoration step. + * + * The trailing-single-wildcard convenience is the one non-obvious rule: a pattern whose only wildcard is a trailing `" *"` also matches the bare prefix, so `"git *"` matches `"git"` as well as `"git add file"`. It applies only when that wildcard is the pattern's sole unescaped one -- `"git * *"` keeps requiring both. + */ +function compileWildcardPattern(pattern: string): string { + const trimmed = pattern.trim(); + let compiled = ""; + let unescapedWildcards = 0; + let index = 0; + while (index < trimmed.length) { + const character = trimmed.charAt(index); + if (character === "\\" && index + 1 < trimmed.length) { + const next = trimmed.charAt(index + 1); + if (next === "*" || next === "\\") { + compiled += `\\${next}`; + index += 2; + continue; + } + } + if (character === "*") { + compiled += ANY_CHARACTERS; + unescapedWildcards += 1; + index += 1; + continue; + } + compiled += escapeRegexLiteral(character); + index += 1; + } + // A trailing `" *"` in the source always compiles to a literal space followed by ANY_CHARACTERS, and the star there can never be an escaped one (the character before it is a space, not a backslash), so the tail to replace has a known, computed length rather than needing to be re-parsed. + if (unescapedWildcards === 1 && trimmed.endsWith(" *")) { + const trailingSpaceAndWildcard = ANY_CHARACTERS.length + 1; + compiled = `${compiled.slice(0, -trailingSpaceAndWildcard)}(?: ${ANY_CHARACTERS})?`; + } + return `^${compiled}$`; +} + +/** A hierarchy-aware glob dialect, distinct from `compileWildcardPattern`'s flat one and never mixed with it: `*` matches within a single `/`-delimited segment, `**` matches across segments, and `?` matches one character within a segment. `**` is consumed as a unit during the same left-to-right pass, so an odd run such as `"***"` reads as `**` followed by `*` -- exactly what successive-replacement would produce, without needing a placeholder to survive between passes. This dialect has no escape syntax: a backslash in the pattern is a literal backslash. */ +function compileHierarchicalGlobPattern(pattern: string): string { + let compiled = ""; + let index = 0; + while (index < pattern.length) { + const character = pattern.charAt(index); + if (character === "*") { + if (pattern.charAt(index + 1) === "*") { + compiled += ANY_CHARACTERS; + index += 2; + continue; + } + compiled += ANY_CHARACTERS_WITHIN_SEGMENT; + index += 1; + continue; + } + if (character === "?") { + compiled += ANY_CHARACTER_WITHIN_SEGMENT; + index += 1; + continue; + } + compiled += escapeRegexLiteral(character); + index += 1; + } + return `^${compiled}$`; +} + +const matchesCompiledPattern = ( + text: ExpressionNode, + compiled: string, +): PredicateNode => ({ + kind: "textCompare", + op: "matches", + left: text, + right: { kind: "textLiteral", value: compiled }, +}); + +export const prefixPattern = ( + text: ExpressionNode, + prefix: string, +): PredicateNode => matchesCompiledPattern(text, compilePrefixPattern(prefix)); + +export const wildcardPattern = ( + text: ExpressionNode, + pattern: string, +): PredicateNode => + matchesCompiledPattern(text, compileWildcardPattern(pattern)); + +export const hierarchicalGlobPattern = ( + text: ExpressionNode, + pattern: string, +): PredicateNode => + matchesCompiledPattern(text, compileHierarchicalGlobPattern(pattern)); diff --git a/src/index.ts b/src/index.ts index 9f5481e..33890b7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,6 +44,11 @@ export { } from "./derived-connectives"; export { average, count, presenceOf, sum } from "./derived-aggregates"; export { coalesce } from "./derived-values"; +export { + hierarchicalGlobPattern, + prefixPattern, + wildcardPattern, +} from "./derived-patterns"; export { complexFromPolar, complexLiteralFromPolar, diff --git a/test/integration/pattern-matching.test.ts b/test/integration/pattern-matching.test.ts new file mode 100644 index 0000000..6cbc215 --- /dev/null +++ b/test/integration/pattern-matching.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from "vitest"; +import { + and, + evaluatePredicate, + hierarchicalGlobPattern, + none, + prefixPattern, + PredicateNodeSchema, + wildcardPattern, +} from "../../src/index"; +import type { Evaluation, PredicateNode, Resolvers } from "../../src/index"; + +/** + * The three pattern builders exercised as part of larger composed trees through the public API surface, against a resolver-backed multi-record dataset -- the tier that proves a compiled `textCompare` cooperates with quantifiers, connectives and per-item evaluation contexts as a system, rather than only matching correctly in isolation (see src/derived-patterns.test.ts for the exhaustive per-dialect coverage this builds on top of). + */ + +interface Command { + readonly instruction: string; + readonly target: string; +} + +const commands: Record = { + listing: { instruction: "ls -la", target: "workspace/reports/summary.txt" }, + nestedRead: { + instruction: "cat notes.md", + target: "workspace/reports/archive/2020/notes.md", + }, + publish: { instruction: "publish --now", target: "workspace/index.html" }, + unrelated: { instruction: "sync-all", target: "elsewhere/data.bin" }, +}; + +function isCommand(value: unknown): value is Command { + return ( + typeof value === "object" && + value !== null && + "instruction" in value && + "target" in value + ); +} + +const resolvers: Resolvers = { + resolveValue: async (key, context) => { + if (!isCommand(context) || typeof key !== "string") { + return Promise.resolve({ found: false }); + } + if (key === "instruction") { + return Promise.resolve({ + found: true, + value: { kind: "text", value: context.instruction }, + }); + } + if (key === "target") { + return Promise.resolve({ + found: true, + value: { kind: "text", value: context.target }, + }); + } + return Promise.resolve({ found: false }); + }, + resolveLookup: async () => Promise.resolve({ found: false }), + resolveCollection: async (collection) => + collection === "commands" + ? Promise.resolve(Object.values(commands)) + : Promise.resolve([]), +}; + +function expectDefinite(evaluation: Evaluation, expected: T): void { + expect(evaluation).toEqual({ status: "definite", value: expected }); +} + +const instruction = { kind: "reference", key: "instruction" } as const; +const target = { kind: "reference", key: "target" } as const; + +describe("pattern builders composed with connectives and quantifiers", () => { + /** A read-only instruction confined to the top level of one directory tree: a word-boundary prefix or a flat wildcard on the instruction, and a single-segment hierarchical glob on the target. */ + const isShallowReadOnly: PredicateNode = and( + { + kind: "anyOf", + operands: [ + prefixPattern(instruction, "ls"), + wildcardPattern(instruction, "cat *"), + ], + }, + hierarchicalGlobPattern(target, "workspace/reports/*"), + ); + + it.each<[string, boolean]>([ + ["listing", true], + ["nestedRead", false], + ["publish", false], + ["unrelated", false], + ])( + "classifies the %s command as shallow-read-only: %s", + async (name, expected) => { + const command = commands[name]; + expect(command).toBeDefined(); + expectDefinite( + await evaluatePredicate(isShallowReadOnly, command, resolvers), + expected, + ); + }, + ); + + it("widening the glob from * to ** admits the nested target the single-segment form rejected", async () => { + const deep = and( + { kind: "anyOf", operands: [wildcardPattern(instruction, "cat *")] }, + hierarchicalGlobPattern(target, "workspace/reports/**"), + ); + expectDefinite( + await evaluatePredicate(deep, commands.nestedRead, resolvers), + true, + ); + }); + + it("drives the derived `none` quantifier over the whole collection, so each item's own context feeds the compiled pattern", async () => { + const noCommandLeavesTheWorkspace = none("commands", { + kind: "not", + operand: hierarchicalGlobPattern(target, "workspace/**"), + }); + expectDefinite( + await evaluatePredicate( + noCommandLeavesTheWorkspace, + undefined, + resolvers, + ), + false, + ); + + const noCommandPublishes = none( + "commands", + prefixPattern(instruction, "publish"), + ); + expectDefinite( + await evaluatePredicate(noCommandPublishes, undefined, resolvers), + false, + ); + + const noCommandDeletes = none( + "commands", + prefixPattern(instruction, "delete"), + ); + expectDefinite( + await evaluatePredicate(noCommandDeletes, undefined, resolvers), + true, + ); + }); + + it("propagates an unresolvable operand as indeterminate rather than collapsing the whole rule to false", async () => { + const result = await evaluatePredicate( + prefixPattern({ kind: "reference", key: "absent" }, "ls"), + commands.listing, + resolvers, + ); + expect(result.status).toBe("indeterminate"); + }); + + it("survives a round trip through JSON and the wire-format schema, since a compiled pattern is ordinary serialisable tree data", async () => { + const built = wildcardPattern(instruction, "cat *"); + const roundTripped: unknown = JSON.parse(JSON.stringify(built)); + const revalidated = PredicateNodeSchema.parse(roundTripped); + expect(revalidated).toEqual(built); + expectDefinite( + await evaluatePredicate(revalidated, commands.nestedRead, resolvers), + true, + ); + }); +}); diff --git a/test/smoke.test.ts b/test/smoke.test.ts index 5d8858e..8c89378 100644 --- a/test/smoke.test.ts +++ b/test/smoke.test.ts @@ -57,6 +57,9 @@ const expectedIndexExports: readonly [ ["average", "function"], ["presenceOf", "function"], ["coalesce", "function"], + ["prefixPattern", "function"], + ["wildcardPattern", "function"], + ["hierarchicalGlobPattern", "function"], ["complexFromPolar", "function"], ["complexLiteralFromPolar", "function"], ["complexMagnitude", "function"], diff --git a/test/workers/trilean.test.ts b/test/workers/trilean.test.ts index bf4ecb7..ec6d112 100644 --- a/test/workers/trilean.test.ts +++ b/test/workers/trilean.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; import { coalesce } from "../../src/derived-values"; +import { + hierarchicalGlobPattern, + prefixPattern, + wildcardPattern, +} from "../../src/derived-patterns"; import { evaluatePredicate, evaluateValue } from "../../src/evaluator"; import { goldenExampleData, @@ -412,3 +417,56 @@ describe("treeReference, conditional's 'unique' hit policy, and coalesce under w }); }); }); + +/** + * The three pattern builders compile a pattern string to a regular-expression string and nothing else -- no Node API is involved even indirectly -- but "no Node API is involved" is exactly the kind of claim this tier exists to turn from an assertion into a runtime-checked fact, so the compiled output is matched here under workerd rather than only under Vitest's own Node-hosted unit project. + */ +describe("pattern-matching builders under workerd", () => { + const patternResolvers: Resolvers = { + resolveValue: async (key, context) => + Promise.resolve( + key === "subject" && typeof context === "string" + ? { found: true, value: { kind: "text", value: context } } + : { found: false }, + ), + resolveLookup: async () => Promise.resolve({ found: false }), + resolveCollection: async () => Promise.resolve([]), + }; + + const subject: ExpressionNode = { kind: "reference", key: "subject" }; + + it("prefixPattern enforces a word boundary rather than a bare startsWith", async () => { + const tree = prefixPattern(subject, "ls"); + expect(await evaluatePredicate(tree, "ls -la", patternResolvers)).toEqual({ + status: "definite", + value: true, + }); + expect(await evaluatePredicate(tree, "lsof", patternResolvers)).toEqual({ + status: "definite", + value: false, + }); + }); + + it("wildcardPattern keeps an escaped asterisk literal while an unescaped one still wildcards", async () => { + const tree = wildcardPattern(subject, String.raw`\**`); + expect(await evaluatePredicate(tree, "*beta", patternResolvers)).toEqual({ + status: "definite", + value: true, + }); + expect(await evaluatePredicate(tree, "beta", patternResolvers)).toEqual({ + status: "definite", + value: false, + }); + }); + + it("hierarchicalGlobPattern distinguishes one segment from every segment", async () => { + const single = hierarchicalGlobPattern(subject, "workspace/*"); + const across = hierarchicalGlobPattern(subject, "workspace/**"); + expect( + await evaluatePredicate(single, "workspace/a/b", patternResolvers), + ).toEqual({ status: "definite", value: false }); + expect( + await evaluatePredicate(across, "workspace/a/b", patternResolvers), + ).toEqual({ status: "definite", value: true }); + }); +});