Skip to content

Optimize constructors & combinators #1

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Apr 27, 2025
Merged
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
215 changes: 107 additions & 108 deletions src/combinators.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,27 @@
import type { Either } from '@matt.kantor/either'
import type { Either, Right } from '@matt.kantor/either'
import * as either from '@matt.kantor/either'
import { nothing } from './constructors.js'
import type {
InvalidInputError,
Parser,
ParserResult,
ParserWhichAlwaysSucceeds,
Success,
} from './parser.js'

/**
* Substitute the output of a successful parse.
*/
export const as =
<NewOutput>(
parser: Parser<unknown>,
newOutput: NewOutput,
): Parser<NewOutput> =>
input =>
either.map(parser(input), success => ({
output: newOutput,
remainingInput: success.remainingInput,
}))
export const as = <NewOutput>(
parser: Parser<unknown>,
newOutput: NewOutput,
): Parser<NewOutput> => {
const replaceOutput = (success: Success<unknown>) => ({
output: newOutput,
remainingInput: success.remainingInput,
})
return input => either.map(parser(input), replaceOutput)
}

/**
* Attempt to parse input with `parser`. If successful, ensure the same input
Expand All @@ -31,39 +32,40 @@ export const as =
* butNot(anySingleCharacter, literal('a'), 'the letter a') // parses any character besides 'a'
* ```
*/
export const butNot =
<Output>(
parser: Parser<Output>,
not: Parser<unknown>,
notName: string,
): Parser<Output> =>
input =>
export const butNot = <Output>(
parser: Parser<Output>,
not: Parser<unknown>,
notName: string,
): Parser<Output> => {
const errorMessage = `input was unexpectedly ${notName}`
return input =>
either.flatMap(parser(input), success => {
const notResult = not(input)
if (!either.isLeft(notResult)) {
return either.makeLeft({
input,
message: `input was unexpectedly ${notName}`,
message: errorMessage,
})
} else {
return either.makeRight(success)
}
})
}

/**
* Map the output of `parser` to another `Parser` which is then applied to the
* remaining input, flattening the parse results.
*/
export const flatMap =
<Output, NewOutput>(
parser: Parser<Output>,
f: (output: Output) => Parser<NewOutput>,
): Parser<NewOutput> =>
input =>
either.flatMap(parser(input), success => {
const nextParser = f(success.output)
return nextParser(success.remainingInput)
})
export const flatMap = <Output, NewOutput>(
parser: Parser<Output>,
f: (output: Output) => Parser<NewOutput>,
): Parser<NewOutput> => {
const applyF = (success: Success<Output>) => {
const nextParser = f(success.output)
return nextParser(success.remainingInput)
}
return input => either.flatMap(parser(input), applyF)
}

/**
* Create a `Parser` from a thunk. This can be useful for recursive parsers.
Expand All @@ -82,60 +84,61 @@ export const lazy =
* lookaheadNot(anySingleCharacter, literal('a'), 'the letter a') // parses the first character of 'ab', but not 'aa'
* ```
*/
export const lookaheadNot =
<Output>(
parser: Parser<Output>,
notFollowedBy: Parser<unknown>,
followedByName: string,
): Parser<Output> =>
input =>
export const lookaheadNot = <Output>(
parser: Parser<Output>,
notFollowedBy: Parser<unknown>,
followedByName: string,
): Parser<Output> => {
const errorMessage = `input was unexpectedly followed by ${followedByName}`
return input =>
either.flatMap(parser(input), success =>
either.match(notFollowedBy(success.remainingInput), {
left: _ => either.makeRight(success),
right: _ =>
either.makeLeft({
input,
message: `input was unexpectedly followed by ${followedByName}`,
message: errorMessage,
}),
}),
)
}

/**
* Map the output of `parser` to new output.
*/
export const map =
<Output, NewOutput>(
parser: Parser<Output>,
f: (output: Output) => NewOutput,
): Parser<NewOutput> =>
input =>
either.map(parser(input), success => ({
output: f(success.output),
remainingInput: success.remainingInput,
}))
export const map = <Output, NewOutput>(
parser: Parser<Output>,
f: (output: Output) => NewOutput,
): Parser<NewOutput> => {
const applyF = (success: Success<Output>) => ({
output: f(success.output),
remainingInput: success.remainingInput,
})
return input => either.map(parser(input), applyF)
}

/**
* Apply the given `parsers` to the same input until one succeeds or all fail.
*/
export const oneOf =
<
Parsers extends readonly [
Parser<unknown>,
Parser<unknown>,
...(readonly Parser<unknown>[]),
],
>(
parsers: Parsers,
): Parser<OneOfOutput<Parsers>> =>
input =>
parsers.reduce(
export const oneOf = <
Parsers extends readonly [
Parser<unknown>,
Parser<unknown>,
...(readonly Parser<unknown>[]),
],
>(
parsers: Parsers,
): Parser<OneOfOutput<Parsers>> => {
const [firstParser, ...otherParsers] = parsers
return input => {
const firstResult = firstParser(input)
return otherParsers.reduce(
(result: ReturnType<Parser<OneOfOutput<Parsers>>>, parser) =>
either.match(result, {
right: either.makeRight,
left: _ => parser(input),
}),
either.makeLeft({ input, message: '' }), // `parsers` is non-empty so this is never returned
either.isLeft(result) ? parser(input) : result,
firstResult,
)
}
}
type OneOfOutput<Parsers extends readonly Parser<unknown>[]> = {
[Index in keyof Parsers]: OutputOf<Parsers[Index]>
}[number]
Expand All @@ -162,62 +165,55 @@ export const sequence =
>(
parsers: Parsers,
): Parser<SequenceOutput<Parsers>> =>
input =>
either.map(
parsers.reduce(
(
results: ReturnType<
Parser<readonly SequenceOutput<Parsers>[number][]>
>,
parser,
) =>
either.match(results, {
right: successes =>
either.map(parser(successes.remainingInput), newSuccess => ({
remainingInput: newSuccess.remainingInput,
output: [...successes.output, newSuccess.output],
})),
left: either.makeLeft,
}),
either.makeRight({ remainingInput: input, output: [] }), // `parsers` is non-empty so this is never returned
),
({ output, remainingInput }) => ({
// The above `reduce` callback constructs `output` such that its
// elements align with `Parsers`, but TypeScript doesn't know that.
output: output as SequenceOutput<Parsers>,
remainingInput,
}),
input => {
const parseResult = parsers.reduce(
(
results: ReturnType<Parser<readonly SequenceOutput<Parsers>[number][]>>,
parser,
) =>
either.isRight(results)
? either.map(parser(results.value.remainingInput), newSuccess => ({
remainingInput: newSuccess.remainingInput,
output: [...results.value.output, newSuccess.output],
}))
: results,
either.makeRight({ remainingInput: input, output: [] }), // `parsers` is non-empty so this is never returned
)
// The above `reduce` callback constructs `output` such that its
// elements align with `Parsers`, but TypeScript doesn't know that.
return parseResult as ParserResult<SequenceOutput<Parsers>>
}
type SequenceOutput<Parsers extends readonly Parser<unknown>[]> = {
[Index in keyof Parsers]: OutputOf<Parsers[Index]>
}

/**
* Refine/transform the output of `parser` via a function which may fail.
*/
export const transformOutput =
<Output, NewOutput>(
parser: Parser<Output>,
f: (output: Output) => Either<InvalidInputError, NewOutput>,
): Parser<NewOutput> =>
input =>
either.flatMap(parser(input), success =>
either.map(f(success.output), output => ({
output,
remainingInput: success.remainingInput,
})),
)
export const transformOutput = <Output, NewOutput>(
parser: Parser<Output>,
f: (output: Output) => Either<InvalidInputError, NewOutput>,
): Parser<NewOutput> => {
const transformation = (success: Success<Output>) =>
either.map(f(success.output), output => ({
output,
remainingInput: success.remainingInput,
}))
return input => either.flatMap(parser(input), transformation)
}

/**
* Repeatedly apply `parser` to the input as long as it keeps succeeding.
* Outputs are collected in an array.
*/
export const zeroOrMore =
<Output>(
parser: Parser<Output>,
): ParserWhichAlwaysSucceeds<readonly Output[]> =>
input => {
const result = oneOf([parser, nothing])(input)
export const zeroOrMore = <Output>(
parser: Parser<Output>,
): ParserWhichAlwaysSucceeds<readonly Output[]> => {
const parserOrNothing = oneOf([parser, nothing])

// Give this a name so it can be recursively referenced.
const thisParser = (input: string): Right<Success<readonly Output[]>> => {
const result = parserOrNothing(input)
const success = either.match(result, {
left: _ => ({
output: [],
Expand All @@ -230,7 +226,7 @@ export const zeroOrMore =
remainingInput: lastSuccess.remainingInput,
}
} else {
const nextResult = zeroOrMore(parser)(lastSuccess.remainingInput)
const nextResult = thisParser(lastSuccess.remainingInput)
return {
output: [lastSuccess.output, ...nextResult.value.output],
remainingInput: nextResult.value.remainingInput,
Expand All @@ -241,6 +237,9 @@ export const zeroOrMore =
return either.makeRight(success)
}

return thisParser
}

type OutputOf<SpecificParser extends Parser<unknown>> = Extract<
ReturnType<SpecificParser>['value'],
Success<unknown>
Expand Down
22 changes: 13 additions & 9 deletions src/constructors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,30 +17,33 @@ export const anySingleCharacter: Parser<string> = input => {
}
}

export const literal =
<Text extends string>(text: Text): Parser<Text> =>
input =>
export const literal = <Text extends string>(text: Text): Parser<Text> => {
const errorMessage = `input did not begin with "${text}"`
return input =>
input.startsWith(text)
? either.makeRight({
remainingInput: input.slice(text.length),
output: text,
})
: either.makeLeft({
input,
message: `input did not begin with "${text}"`,
message: errorMessage,
})
}

export const nothing: ParserWhichAlwaysSucceeds<undefined> = input =>
either.makeRight({
remainingInput: input,
output: undefined,
})

export const regularExpression =
(pattern: RegExp): Parser<string> =>
input => {
const match = pattern.exec(input)
return match === null || match.index !== 0
export const regularExpression = (pattern: RegExp): Parser<string> => {
const patternAnchoredToStartOfString = pattern.source.startsWith('^')
? pattern
: new RegExp(`^${pattern.source}`, pattern.flags)
return input => {
const match = patternAnchoredToStartOfString.exec(input)
return match === null
? either.makeLeft({
input,
message: 'input did not match regular expression',
Expand All @@ -50,3 +53,4 @@ export const regularExpression =
output: match[0],
})
}
}
6 changes: 3 additions & 3 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ export type InvalidInputError = {
readonly message: string
}

export type Parser<Output> = (
input: string,
) => Either<InvalidInputError, Success<Output>>
export type Parser<Output> = (input: string) => ParserResult<Output>

export type ParserWhichAlwaysSucceeds<Output> = (
input: string,
) => Right<Success<Output>>

export type ParserResult<Output> = Either<InvalidInputError, Success<Output>>

export type Success<Output> = {
readonly remainingInput: string
readonly output: Output
Expand Down