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:
William Chargin 2020-05-30 15:51:08 -07:00 committed by GitHub
parent 297c4e9156
commit c5bdfcd174
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 114 additions and 0 deletions

View File

@ -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)) {

View File

@ -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);