combo: add `pure` and `fmap` combinators (#1817)
Summary: These are commonly used for building large parsers from small pieces, especially `fmap`, which is instrumental for transformation and validation. We pick the name `fmap` rather than `map` to avoid confusion with ES6 `Map` values, which are unrelated. Test Plan: Unit tests and type-checking tests included, with full coverage. wchargin-branch: combo-pure-fmap
This commit is contained in:
parent
297c4e9156
commit
c5bdfcd174
|
@ -85,6 +85,60 @@ export const null_: Parser<null> = new Parser((x) => {
|
|||
return success(x);
|
||||
});
|
||||
|
||||
// Lift a plain value into a parser that always returns that value,
|
||||
// ignoring its input.
|
||||
export function pure<T>(t: T): Parser<T> {
|
||||
return new Parser((_) => success(t));
|
||||
}
|
||||
|
||||
// Transform the output of a parser with a pure function. For instance,
|
||||
// if `p: Parser<number>` and `f = (n: number) => n % 2 === 0`, then
|
||||
// `fmap(p, f)` is a `Parser<boolean>` that first uses `p` to parse its
|
||||
// input to a number and then checks whether the number is even.
|
||||
//
|
||||
// If the function `f` throws, the thrown value will be converted to
|
||||
// string and returned as a parse error. (The string conversion takes
|
||||
// `e.message` if the thrown value `e` is an `Error`, else just converts
|
||||
// with the `String` builtin.)
|
||||
//
|
||||
// This can be used for "strong validation". If `U` is a (possibly
|
||||
// opaque) subtype of `T`, and `f: (T) => U` is a checked downcast that
|
||||
// either returns a `U` or throws an error, then `fmap` can transform a
|
||||
// `Parser<T>` into a validating `Parser<U>`, where the fact that the
|
||||
// validation has been performed is encoded at the type level. Thus:
|
||||
//
|
||||
// import * as C from ".../combo";
|
||||
// import {NodeAddress, type NodeAddressT} from ".../core/graph";
|
||||
//
|
||||
// const addressParser: Parser<NodeAddressT> =
|
||||
// C.fmap(C.array(C.string), NodeAddress.fromParts);
|
||||
//
|
||||
// As a degenerate case, it can also be used for "weak validation",
|
||||
// where the types `T` and `U` are the same and the function `f` simply
|
||||
// returns its argument or throws, but in this case there is nothing
|
||||
// preventing a user of a `Parser<T>` from simply forgetting to
|
||||
// validate. Prefer strong validation when possible.
|
||||
export function fmap<T, U>(p: Parser<T>, f: (T) => U): Parser<U> {
|
||||
return new Parser((x) => {
|
||||
const maybeT = p.parse(x);
|
||||
if (!maybeT.ok) {
|
||||
return failure(maybeT.err);
|
||||
}
|
||||
const t = maybeT.value;
|
||||
let u: U;
|
||||
try {
|
||||
u = f(t);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
return failure(e.message);
|
||||
} else {
|
||||
return failure(String(e));
|
||||
}
|
||||
}
|
||||
return success(u);
|
||||
});
|
||||
}
|
||||
|
||||
export function array<T>(p: Parser<T>): Parser<T[]> {
|
||||
return new Parser((x) => {
|
||||
if (!Array.isArray(x)) {
|
||||
|
|
|
@ -75,6 +75,66 @@ describe("src/util/combo", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("pure", () => {
|
||||
it("does what it says on the tin", () => {
|
||||
type Color = "RED" | "GREEN" | "BLUE";
|
||||
const p: C.Parser<Color> = C.pure("GREEN");
|
||||
expect(p.parseOrThrow(p)).toEqual("GREEN");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fmap", () => {
|
||||
type Color = "RED" | "GREEN" | "BLUE";
|
||||
function stringToColor(s: string): Color {
|
||||
const c = s.toLowerCase().charAt(0);
|
||||
switch (c) {
|
||||
case "r":
|
||||
return "RED";
|
||||
case "g":
|
||||
return "GREEN";
|
||||
case "b":
|
||||
return "BLUE";
|
||||
default:
|
||||
throw new Error("unknown color: " + JSON.stringify(s));
|
||||
}
|
||||
}
|
||||
it("handles the success case", () => {
|
||||
const p: C.Parser<Color> = C.fmap(C.string, stringToColor);
|
||||
expect(p.parseOrThrow("blu")).toEqual("BLUE");
|
||||
});
|
||||
it("handles failure of the base parser", () => {
|
||||
const p: C.Parser<Color> = C.fmap(C.string, stringToColor);
|
||||
const thunk = () => p.parseOrThrow(77);
|
||||
expect(thunk).toThrow("expected string, got number");
|
||||
});
|
||||
it("handles `Error`s thrown by the mapping function", () => {
|
||||
const p: C.Parser<Color> = C.fmap(C.string, stringToColor);
|
||||
// Avoid `.toThrow` because that checks for a substring, and we
|
||||
// want to ensure no "Error: " prefix is included.
|
||||
expect(p.parse("wat")).toEqual({ok: false, err: 'unknown color: "wat"'});
|
||||
});
|
||||
it("handles failure of the mapping function", () => {
|
||||
const p: C.Parser<Color> = C.fmap(C.string, () => {
|
||||
throw 123;
|
||||
});
|
||||
expect(p.parse("wat")).toEqual({ok: false, err: "123"});
|
||||
});
|
||||
it("composes", () => {
|
||||
const raw: C.Parser<string> = C.string;
|
||||
const trimmed: C.Parser<string> = C.fmap(raw, (s) => s.trim());
|
||||
const color: C.Parser<Color> = C.fmap(trimmed, stringToColor);
|
||||
expect(color.parseOrThrow(" blu\n\n")).toEqual("BLUE");
|
||||
});
|
||||
it("is type-safe", () => {
|
||||
// input safety
|
||||
// $ExpectFlowError
|
||||
C.fmap(C.string, (n: number) => n.toFixed());
|
||||
// output safety
|
||||
// $ExpectFlowError
|
||||
(C.fmap(C.number, (n: number) => n.toFixed()): C.Parser<number>);
|
||||
});
|
||||
});
|
||||
|
||||
describe("array", () => {
|
||||
it("accepts an empty array", () => {
|
||||
const p: C.Parser<string[]> = C.array(C.string);
|
||||
|
|
Loading…
Reference in New Issue