Skip to content

Instantly share code, notes, and snippets.

@js-choi
Last active August 25, 2021 22:50
Show Gist options
  • Save js-choi/661874875fc7b119db13d3b50f06b12b to your computer and use it in GitHub Desktop.
Save js-choi/661874875fc7b119db13d3b50f06b12b to your computer and use it in GitHub Desktop.
Pipe adverbs

Pipe adverbs for JavaScript

This experiment is deprecated. Go see block adverbs instead.

This document is an experiment, exploring pipe adverbs as a potential way to allow developers to extend the Hack pipe operator |> in JavaScript.

Pipe adverbs probably would not be formally proposed until we gain more real-world experience with the pipe operator in the ecosystem. But it is worth looking toward the future.

Description

inputExpr |> bodyExpr
inputExpr |@adverbExpr> bodyExpr
inputExpr |@adverbExpr:> bodyExpr
inputExpr |@adverbExpr: topicIdent> bodyExpr

inputExpr

The pipe’s input expression, which will be evaluated first.

adverbExpr

The pipe’s adverb expression, which has the same syntax as decorator expressions: a variable chained with property access (. only, no []) and calls (). To use an arbitrary expression as a decorator, |@(expression)> is an escape hatch.

topicIdent

An optional new topic variable to which the topic value will be bound, in addition to %.

bodyExpr

The pipe’s body expression, which will be passed as a callback argument to the pipe’s adverb function.

The Hack-pipe operator |> is still right-associative and has the same precedence as =>.

With this proposal, the operator |> would optionally contain, between the | and >, an adverb expression, which must evaluate to a reference to an adverb function (or null, which is the same as omitting the adverb).

An adverb is any function that – when given (input, body) arguments, where input is some type of value and body is a callback function – will somehow apply the body to the input (or to something created by input). When the adverb calls the body, the argument that the body receives becomes the body’s topic value (to which any % in the body will refer).

The entire pipe expression inputExpr |@adverbExpr> bodyExpr will evaluate to the result of adverb(input, body), where input is the result to which inputExpr evaluates, and where body is a function enclosing the bodyExpr, with the topic reference % bound to body’s argument.

For example, x |@adverb> % + 1 would be roughly equivalent to adverb(x, topic => topic |> % + 1).

Transparent functions

In fact, the body function that the operator passes to the adverb is a new kind of function: a transparent function. Transparent functions can only be created by |>. When they are called, they evaluate their body within its outer function context, which affects the await and yield operators. (this and super are also not affected by transparent functions, as with arrow functions.)

For example, async () => x |@adverb> await f(%) would be roughly equivalent to async () => adverb(x, topic => topic |> await f(%)), except that the await will evaluate within the context of the async arrow function.

Naming a topic variable

In addition, a colon : then a new variable identifier may optionally follow the adverb, such as value in |@each: value> or in |@(A.each ?? B.each): value>. Like %, this variable is bound to the topic value, except that it is in scope for any following expression in the same pipeline. Unlike %, the variable does not have to be used somewhere in the pipe body.

If a colon is included after the adverb but without a new topic variable identifier, then the pipe body does not have to use %; the developer is opting out of the topic-is-not-used early error.

Useful adverbs

Examples of useful adverbs include:

Adverb input adverb(input, body) Topic value (i.e., body’s argument) Result of body(topic)
null Any value Returns body(input) input Any value
maybe Nullish values versus any other value Returns body(input) – unless input is nullish, in which case returns input input but only when it is not nullish Any value
each Iterable value Returns a new iterator, which applies body to each value yielded by input, and which yields the resulting new values The values yielded by input An iterable object, whose values will be yielded by the new iterator
asyncEach Async-iterable value Returns a new async generator, which applies body to each value yielded by input, and which yields the resulting new values The values yielded by input An iterable or async-iterable object, whose values will be yielded by the new iterator
awaiting Promise or other thenable value Returns another thenable, which will resolve to body(await input) – or which will reject if input or body(await input) throws an error The value to which input resolves Any value
cat Parser-rule function Returns another parser-rule function that concatenates input and body(successfulInputMatch.value), whenever it is called with a str input(str).value Another parser-rule that will be called with input(str).remainder

An Adverb built-in global object would have several static methods with some of these adverbs:

globalThis.Adverb = {
  maybe (input, body) {
    return input != null ? body(input) : input;
  },

  each (input, body) {
    function generate * () {
      for (const value of input)
        yield * body(value) ?? [];
    }
    return generate();
  },

  asyncEach (input, body) {
    async function asyncGenerate * () {
      for (const value of input)
        yield * body(value) ?? [];
    }
    return generate();
  },

  awaiting (input, body) {
    return input.then(body);
  },
};

Why pipe adverbs

The pipe operator |> allows JavaScript developers to untangle deeply nested expressions into linear pipelines (i.e., pipe expressions), which may often be more humanly readable and editable.

Pipe adverbs would allow developers to extend the pipe operator. A pipe operator augmented with an adverb could automatically check its input for special values; conditionally short-circuit the pipeline; implicitly extract values from the input, bind them to a variable, and then rewrap them in a container object; and tacitly transfer ancillary state information.

Pipe adverbs could flatten many kinds of callback hell.
Adverbs would be to nested callbacks
as await would be to promise.then.

Many flows of data have repetitive boilerplate

However, long pipelines (and flows of data in general) may show recurring, repetitive patterns that mix essential details of the program together with boilerplate logic at every step of these pipelines. This boilerplate logic might include checking for null, failures, or other special values, extracting values from containers such as iterators, or passing state data.

Handling nullish values

For example, the pipeline in processMaybeNumber transforms an input (which may be a number or a nullish value), but the first two steps of this pipeline wrap the essential logic % + 1 and %.toString(16) with the repetitive boilerplate % != null ? … : %. Such repetitive handling of special values is common.

// `input` may be a number or a nullish value.
// Returns a JSON string or the nullish value.
function processMaybeNumber (input) {
  return input
    |> (% != null ? % + 1 : %)
    |> (% != null ? %.toString(16) : %)
    |> (% != null ? JSON.stringify(%) : %);
}
// This is `'3c'`:
processMaybeNumber(59);
// This is `'null'`:
processMaybeNumber(null);

With a helper function maybe, we could rewrite this less repetitively as:

// `input` may be a number or a nullish value.
// Returns a JSON string or the nullish value.
function processMaybeNumber (input) {
  return maybe(input, x =>
    return x + 1
    |> %.toString(16)
    |> JSON.stringify(%));
}

function maybe(input, callback) {
  if (input != null)
    return callback(input);
  else
    return input;
}

…but this breaks up our linear pipeline into a small pyramid of nested callbacks, compromising its readability and editability. If we ever need to add more steps that require checking for nullish inputs, then each of those steps would require a callback.

Extracting and repackaging iterator data

Likewise, the pipeline in processItemsA transforms an input iterable value but wraps much of the essential logic (like value + 1 and value > 1) with repetitive callback boilerplate. Likewise, the iterator-helper functions map, flatMap, and filter perform repetitive extraction of values from input iterators (i.e., their for loops) before processing and then repackaging the values into output iterators.

// `input` may be an iterable value.
// Returns a new iterator.
function * map (input, callback) {
  for (const value of input)
    yield callback(value);
}

// `input` may be an iterable value.
// Returns a new iterator.
function * flatMap (input, callback) {
  for (const value of input)
    yield * callback(value);
}

// `input` may be an iterable value.
// Returns a new iterator.
function * filter (input, callback) {
  for (const value of input)
    yield * callback(value) ? [ value ] : [];
}

// `input` may be an iterable value.
// Returns a new iterator.
function processItemsA (input) {
  return input
    |> map(%, value => value + 1)
    |> filter(%, value => value > 1)
    |> flatMap(%, value => [ value * 2, value * 3 ]);
}

function * generate () {
  yield * [ 0, 1, 2, 3, 4, 5 ];
}

// This is `[ 4, 6, 6, 9, 8, 12, 18 ]`:
[ ...processItemsA(generate()) ];

The next example is similar to the previous example – it must use the same iterator-helper functions to repetitively extract values from input iterators and to repackage processed values back into output iterators. And, in this case, because a downstream processing step depends on the values extracted from multiple preceding pipeline steps, the pipeline must be split up into nested callbacks, one for each pipeline step.

// `input` may be an iterable value.
// Returns an iterator of iterators.
function processItemsB (input) {
  return input |> flatMap(%, arr =>
    arr |> flatMap(%, number =>
      [ arr.length * number ]));
}

function * generate () {
  yield [ 0, 1, 2 ];
  yield [ 3, 4 ];
}

// This is `[ 0, 3, 6, 6, 8 ]`:
[ ...processItemsB(generate()) ];

Combining parser functions

This problem of repetitive extraction/repackaging becomes all the more starker the longer a flow of data becomes, the more complicated the data extraction becomes, and the more dependent processing steps become on upstream steps.

Consider this function that combines several simple parser rules. (The either, $zero, $int, $fracPart, etc. functions used here are defined in an accompanying document.)

// This rule matches a JSON number.
// Its match’s value is its numerical value.
const $number =
  $optSign |> cat(%, sign =>
    either($zero, $int) |> cat(%, intPart, =>
      opt($fracPart) |> cat(%, fracPart =>
        opt($expPart) |> cat(%, expPart =>
          meaning(
            intPart + fracPart
            |> % ** expPart
            |> sign * %)))));

This function – and the other parser rules that it uses – are just functions that accept input strings and which return match objects that look like { value, remainder }. value can be any value representing the “meaning” of the match, but remainder must be a substring of the input string. Also, when value is not nullish, then the match is considered to be successful; otherwise when value is nullish, then the match is considered to have failed.

These parser rules form a data flow made of sequential steps: first applying $optSign, then either($zero, $int), then $opt($expPart), and lastly meaning(sign * (intPart + fracPart) ** expPart).

The value and remainder from each consecutive rule’s match must flow into each consecutive parser rule. There is a lot of boilerplate involved in correctly extracting values and remainders from each step. The cat function hides this boilerplate: cat combines an input rule with a callback that returns a rule. The callback receives the value of the input rule’s match.

Theoretically, this sequential data flow should be able to become a linear pipeline of sequential steps. In spite of this, a deeply nested callback structure results from our need to refer to the values of multiple previous steps before combining them in the final step, meaning(sign * (intPart + fracPart) ** expPart). The longer the data flow becomes, the deeper the pyramid of callbacks becomes. These deeply nested callbacks are difficult to read and difficult to edit – in a way much analogous to the callback hell of asynchronous JavaScript before await syntax.

The repetitive boilerplate follows a recurring pattern

In each processing step from the previous examples, the same operations need to be repetitively applied to input data before the important data inside can be processed. Likewise, other operations need to be repetitively applied after the processing, before the output data from that step is returned.

A flow of data might need to repeatedly testing the input data of each step for nullish values, errors, failure objects, or other special types of value, before performing the important details on that input data.

A flow of data might need to also repeatedly extract data from the input data of each step, when that input data are iterators, promises, futures, state functions, or other container types that wrap the important data. Furthermore, after performing the important details on the important values, each step of the pipeline may need to rewrap the data back into a new instance of that container type.

This repetitive boilerplate can be extracted into helper functions (such as map/filter/flatMap for iterators and cat for parser functions). However, oftentimes these helper functions require deeply nested callbacks when any step of the data flow is dependent on multiple previous In a flow of data, many steps of the flow might need to refer to the results of previous steps. When this occurs, linear flows of data must become deeply nested callbacks: the “callback hell” of pre-await asynchronous JavaScript persists in these other contexts.

Pipe adverbs abstract and flatten away the boilerplate

Regular pipes only With pipe adverbs
// `input` may be a number or a nullish value.
// Returns a JSON string.
function processMaybeNumber (input) {
  return maybe(input, x =>
    x
    |> % + 1
    |> %.toString(16));
}
// This is `'3c'`:
processMaybeNumber(59);
// This is `'null'`:
processMaybeNumber(null);
// `input` may be a number or a nullish value.
// Returns a JSON string or the nullish value.
function processMaybeNumber (input) {
  return input
  |@maybe> % + 1
  |> %.toString(16)
  |> JSON.stringify(%);
}
// This is `'3c'`:
processMaybeNumber(59);
// This is `'null'`:
processMaybeNumber(null);

The first |@maybe> % + 1 short-circuits: if we input a nullish value into it, none of its following pipeline steps will evaluate.

// `input` may be an iterable value.
// Returns a new iterator.
function processItemsA (input) {
  return input
    |> map(%, value => value + 1)
    |> filter(%, value => value > 1)
    |> flatMap(%, value =>
      [ value * 2, value * 3 ]);
}
// `input` may be an iterable value.
// Returns a new iterator.
function processItemsA (input) {
  return input
  |@each> [ % + 1 ]
  |@each> % > 1 ? [ % ] : []
  |@each> [ % * 2, % * 3 ];
}

With each, the pipeline becomes a list comprehension over iterators. The functionality of three helper generatorsmap, filter, and flatMap – are combined into one adverb that yields zero or more values per value per pipeline step.

// `input` may be an iterable value.
// Returns an iterator of iterators.
function processItemsB (input) {
  return input |> flatMap(%, arr =>
    arr |> flatMap(%, number =>
      [ number * arr.length ]));
}
// `input` may be an iterable value.
// Returns an iterator of iterators.
function processItemsB (input) {
  return input |@each: arr>
    arr |@each>
    [ % * arr.length ];
}

A downstream pipe step here ([ % * arr.length ]) needs to refer to extracted values from an earlier step (referred to by arr). The “list comprehension” that |@each: arr> forms becomes particularly convenient in this case. The nested callbacks are flattened into a linear pipeline.

// This rule matches a JSON number.
// Its match’s value is its numerical value.
const $number =
  $optSign |> cat(%, sign =>
    either($zero, $int) |> cat(%, intPart, =>
      opt($fracPart) |> cat(%, fracPart =>
        opt($expPart) |> cat(%, expPart =>
          meaning(
            intPart + fracPart
            |> % ** expPart
            |> sign * %)))));
// This rule matches a JSON number.
// Its match’s value is its numerical value.
const $number =
  $optSign |@cat: sign>
  either($zero, $int) |@cat: intPart>
  opt($fracPart) |@cat: fracPart>
  opt($expPart) |@cat: expPart>
  meaning(
    intPart + fracPart
    |> % ** expPart
    |> sign * %);

The difference is particularly dramatic with these concatenated parser rules. When cat can act as a pipe adverb, the deep pyramid of callbacks is flattened down to a linear pipeline. The data flow is consequently clearer to read: parser rules on the left and value variables on the right.

Pipe adverbs use monads

In the abstract, adverbs actually act like duck-typed monads (or more specifically the bind operation of various monadic types), arranged in a syntax similar to Haskell’s do comprehension syntax.

Monads are a recurring pattern from category theory that appear in many functional programming languages, and which savvy developers may combine in many ways. This essay tries to avoid dwelling on monads’ abstract theory, in favor of focusing on concrete examples of adverbs. Nevertheless, the pattern is useful to simplify real-world code, by separating pipelines’ essential details from boilerplate logic (such as checking for failures or explicitly passing state data) at every step of each pipeline.

Each pipe adverb takes two arguments – a pipe input and a pipe body callback – and somehow converts the input into a topic value, before calling body(topic) (or not, if the adverb’s conditions are not met) and returning the result as its output. This is analogous to both the bind operation (>>= in Haskell) and the unit operation (return in Haskell) of monads. The bind operation has a type signature 𝑀𝑥 → (𝑥 → 𝑀𝑦) → 𝑀𝑦; with function adverb (input, body), 𝑀𝑥 corresponds to the pipe input, 𝑥 → 𝑀𝑦 corresponds to the body callback, 𝑥 is the topic value that the adverb feeds to body, and 𝑀𝑦 is the output of body (and of the adverb).

Many existing systems with monads are statically typed and use static type dispatch on a single polymorphic monad bind operator and a static “monadic type”. In contrast, much of JavaScript is dynamically typed and generally duck typed. Types are often only informally extended ad hoc, using duck-typed functions. We have therefore adapted the monadic operations into duck-typed functions that extend the pipe operator. This duck typing requires specifying the monadic bind function with each step, rather than relying on an implicit polymorphic method call on a data type.

For example, rather than relying on a Maybe data type, pipe adverbs use a maybe function that covers all data types but treats nullish values specially.

This design allows pipe adverbs to be mixed with regular pipe operations in the same pipeline. It also allows multiple adverbs to be applied to the same data types, even in the same pipeline.

Example parser library

This experiment is deprecated. Go see block adverbs instead.

As a real-world practical example of the power of pipe adverbs, we can make simple text parsers for both context-free and context-sensitive grammars. Pipe adverbs would enable the creation of many advanced domain-specific languages such as this.

All parser rules here are just functions that accept input strings and which return match objects that look like { value, remainder }. value can be any value representing the “meaning” of the match, but remainder must be a substring of the input string. Also, when value is not nullish, then the match is considered to be successful; otherwise when value is nullish, then the match is considered to have failed.

For clarity, parser rules are named starting with $. Functions that create parser rules are not named starting with $ (though their names might start with make$).

Example JSON parser

JSON has a simple, unambiguous context-free grammar. With the following example parser, $document('[ 0, 1, 2, { "hello": "goodbye" }]') would evaluate to { value: [ 0, 1, 2, { hello: "goodbye" }, remainder: '' }. The remainder: '' means that the input text was completely matched from start to finish.

Regular pipes only With pipe adverbs
// This rule matches
// a single whitespace character.
const $wsChar = either(
  literal(' '),
  literal('\n'),
  literal('\r'),
  literal('\t'));

// This rule matches
// zero or more whitespace characters.
const $ws = atLeast0($wsChar);
// This rule matches
// a single whitespace character.
const $wsChar = either(
  literal(' '),
  literal('\n'),
  literal('\r'),
  literal('\t'));

// This rule matches
// zero or more whitespace characters.
const $ws = atLeast0($wsChar);
// This rule matches
// a `null` literal.
// Its match’s value is `null`.
const $null =
  literal('null')
  |> cat(%, () =>
    meaning(null));

// This rule matches
// a `false` literal.
// Its match’s value is `false`.
const $false =
  literal('false')
  |> cat(%, () =>
    meaning(false);

// This rule matches
// a `true` literal.
// Its match’s value is `true`.
const $true =
  literal('true')
  |> cat(%, () =>
    meaning(true));
// This rule matches
// a `null` literal.
// Its match’s value is `null`.
const $null =
  literal('null')
  |@cat:> meaning(null);

// This rule matches
// a `false` literal.
// Its match’s value is `false`.
const $false =
  literal('false')
  |@cat:> meaning(false);

// This rule matches
// a `true` literal.
// Its match’s value is `true`.
const $true =
  literal('true')
  |@cat:> meaning(true);
// This rule matches
// a `-` sign.
// Its match’s value is `-1`.
const $minus =
  literal('-')
  |> cat(%, () =>
    meaning(-1));

// This rule matches
// a `+` sign.
// Its match’s value is `+1`.
const $plus =
  literal('+')
  |> cat(%, () =>
    meaning(+1));

// This rule matches
// always matches on no text.
// Its match’s value is `+1`.
const $optPlus = $empty
  |> cat(%, () =>
    meaning(+1));

// This rule optionally matches
// a `-` or `+` sign.
// Its match’s value is `-1` or `+1`.
// If there is no sign, the value is `+1`.
const $optSign = either(
  $minus, $plus, $implicitPlus);

// This rule matches
// a `0` digit.
// Its match’s value is `0`.
const $zero =
  literal('0')
  |> cat(%, () =>
    meaning(0));

const digitValues = [
  0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
];

// This rule matches
// any decimal digit between `0` and `9`.
// Its match’s value is its numerical value.
const $int =
  digitValues.map(v =>
    String(v)
    |> literal(%)
    |> cat(%, () => meaning(v)))
  |> either(...%);
// This rule matches
// a `-` sign.
// Its match’s value is `-1`.
const $minus =
  literal('-')
  |> cat(%, () => meaning(-1));

// This rule matches
// a `+` sign.
// Its match’s value is `+1`.
const $plus =
  literal('+')
  |> cat(%, () => meaning(+1));

// This rule matches
// always matches on no text.
// Its match’s value is `+1`.
const $optPlus = $empty
  |> cat(%, () => meaning(+1));

// This rule optionally matches
// a `-` or `+` sign.
// Its match’s value is `-1` or `+1`.
// If there is no sign, the value is `+1`.
const $optSign = either(
  $minus, $plus, $implicitPlus);

// This rule matches
// a `0` digit.
// Its match’s value is `0`.
const $zero =
  literal('0') |@cat:> meaning(0);

const digitValues = [
  0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
];

// This rule matches
// any decimal digit between `0` and `9`.
// Its match’s value is its numerical value.
const $digit =
  digitValues.map(v =>
    String(v) |>
    literal(%) |@cat:>
    meaning(v))
  |> either(...%);
const decimalBase = 10;

// This function combines the given
// `digitArr` into an integer
// in little-endian order.
function combineDigits (digitArr) {
  return digitArr
    .map((digit, index) =>
      digit * decimalBase ** index)
    .reduce((v0, v1) => v0 + v1);
}

// This rule matches
// any sequence of decimal digits.
// Its match’s value is its integer value.
const $int = atLeast1($digit)
  |> cat(%, digitArr =>
    meaning(
      digitArr.reverse()
      |> combineDigits(%, decimalBase)));
const decimalBase = 10;

// This function combines the given
// `digitArr` into an integer
// in little-endian order
// with the given `base`.
function combineDigits (digitArr, base) {
  return digitArr
    .map((digit, index) =>
      digit * base ** index)
    .reduce((v0, v1) => v0 + v1);
}

// This rule matches
// any sequence of decimal digits.
// Its match’s value is its integer value.
const $int =
  atLeast1($digit) |> meaning(
    %.reverse()
    |> combineDigits(%, decimalBase));
// This rule matches
// the fractional portion of a JSON number.
// Its match’s value is its number value,
// which is always between 0 and 1 exclusive.
const $fracPart =
  literal('.') |> cat(%, () =>
    atLeast1($digit) |> cat(%, v =>
      meaning(combineDigits(v, decimalBase))));
// This rule matches
// the fractional portion of a JSON number.
// Its match’s value is its number value,
// which is always between 0 and 1 exclusive.
const $fracPart =
  literal('.') |@cat:>
  atLeast1($digit) |@cat>
  meaning(combineDigits(%, decimalBase));
// This rule matches
// the exponential sign of a JSON number,
// a case-insensitive E.
const expSign =
  either(literal('e'), literal('E'));

// This rule matches
// the exponential portion of a JSON number.
// Its match’s value is its integer power.
const $expPart =
  $expSign |> cat(%, () =>
    $optSign |> cat(%, sign =>
      $int |> cat(%, absValue =>
        meaning(sign * absValue))));
// This rule matches
// the exponential sign of a JSON number,
// a case-insensitive E.
const expSign =
  either(literal('e'), literal('E'));

// This rule matches
// the exponential portion of a JSON number.
// Its match’s value is its integer power.
const $expPart =
  $expSign |@cat:>
  $optSign |@cat: sign>
  $int |@cat: absValue>
  meaning(sign * absValue);
// This rule matches a JSON number.
// Its match’s value is its numerical value.
const $number =
  $optSign |> cat(%, sign =>
    either($zero, $int) |> cat(%, intPart, =>
      opt($fracPart) |> cat(%, fracPart =>
        opt($expPart) |> cat(%, expPart =>
          meaning(
            intPart + fracPart
            |> % ** expPart
            |> sign * %)))));
// This rule matches a JSON number.
// Its match’s value is its numerical value.
const $number =
  $optionalSign |@cat: sign>
  either($zero, $int) |@cat: intPart>
  opt($fracPart) |@cat: fracPart>
  opt($expPart) |@cat: expPart>
  meaning(
    intPart + fracPart
    |> % ** expPart
    |> sign * %);
const singularUnEscaper = {
  '"': '"', '\': '\', '/': '/',
  b: '\b', f: '\f', n: '\n', r: '\r',
  t: '\t',
}

// This rule matches
// a single-character escape sequence.
// Its match’s value is its code unit
// as a string.
const $singularEscape =
  singularUnEscaper
    |> Object.entries(%)
    |> %.map(([ key, val ]) => key
      |> literal(%)
      |> cat(%, () => meaning(val)))
    |> either(...%);
const singularUnEscaper = {
  '"': '"', '\': '\', '/': '/',
  b: '\b', f: '\f', n: '\n', r: '\r',
  t: '\t',
}

// This rule matches
// a single-character escape sequence.
// Its match’s value is its code unit
// as a string.
const $singularEscape =
  singularUnEscaper
    |> Object.entries(%)
    |> %.map(([ key, val ]) =>
      literal(key) |@cat:> meaning(val))
    |> either(...%);
const hexBase = 16;

// This rule matches
// a JSON escaped character
// with a Unicode hex sequence.
// Its match’s value is its code unit
// as a string.
const $unicodeEscape = literal('u')
  |> cat(%, () =>
    $hexDigit |> cat(%, hexDigit3 =>
      $hexDigit |> cat(%, hexDigit2 =>
        $hexDigit |> cat(%, hexDigit1 =>
          $hexDigit |> cat(%, hexDigit0 =>
            meaning(
              [ hexDigit3, hexDigit2,
                hexDigit1, hexDigit0 ]
                |> combineDigits(%, hexBase)
                |> String.fromCodePoint(%)))))));
const hexBase = 16;

// This rule matches
// a JSON escaped character
// with a Unicode hex sequence.
// Its match’s value is its code unit
// as a string.
const $unicodeEscape =
  literal('u') |@cat:>
  $hexDigit |@cat: hexDigit3>
  $hexDigit |@cat: hexDigit2>
  $hexDigit |@cat: hexDigit1>
  $hexDigit |@cat: hexDigit0>
  meaning(
    [ hexDigit3, hexDigit2,
      hexDigit1, hexDigit0 ]
      |> combineDigits(%, hexBase)
      |> String.fromCodePoint(%));
// This rule matches the escape character:
// the backslash.
const $escape = literal('\\');

// This rule matches
// a backslash-escaped string code unit.
// Its match’s value is its code unit
// as a string.
const $escapedCodeUnit =
  $escape |> cat(%, () =>
    either($unicodeEscape, $singularEscape)
      |> cat(%, v => meaning(v));
// This rule matches the escape character:
// the backslash.
const $escape = literal('\\');

// This rule matches
// a backslash-escaped string code unit.
// Its match’s value is its code unit
// as a string.
const $escapedCodeUnit =
  $escape |@cat:>
  either($unicodeEscape, $singularEscape) |@cat>
  meaning(%);
// This rule matches the string delimiter:
// the double straight quotation mark.
const $stringDelim = '"';

// This rule matches a JSON string.
// Its match’s value is its string value.
const $string =
  $stringDelim |> cat(%, () =>
    atLeast0(either(
      $escapedCodeUnit,
      $codeUnit |> except(%, $stringDelim)))
    |> cat(%, stringContent =>
      $stringDelim
      |> cat(%, () => meaning(stringContent))));
// This rule matches the string delimiter:
// the double straight quotation mark.
const $stringDelim = '"';

// This rule matches a JSON string.
// Its match’s value is its string value.
const $string =
  $stringDelim |@cat:>
  atLeast0(either(
    $escapedCodeUnit,
    $codeUnit |> except(%, $stringDelim)))
  |@cat:content>
  $stringDelim |@cat:>
  meaning(content);
// This rule matches
// the value separator.
const $valueSeparator = literal(',');

// This rule matches a JSON array.
// Its match’s value is a new array.
const $array = literal('[') |> cat(%, () =>
  opt($value
    |> cat(%, firstValue =>
      atLeast0(
        $ws |> cat(%, () =>
          $valueSeparator |> cat(%, () =>
            $ws |> cat(%, () =>
              $value |> cat(%, value =>
                meaning(value)))))))
    |> cat(%, restValues =>
      meaning([ firstValue, ...restValue ])))
  |> cat(%, content =>
    literal(']') |> cat(%, () =>
      meaning(content || []))));
// This rule matches
// the value separator.
const $valueSeparator = literal(',');

// This rule matches a JSON array.
// Its match’s value is a new array.
const $array =
  literal('[') |@cat:>
  $ws |@cat:>
  opt(
    $value |@cat: firstValue>
    atLeast0(
      $ws |@cat:>
      $valueSeparator |@cat:>
      $ws |@cat:>
      $value |@cat: value>
      meaning(value))
    |@cat: restValues>
    meaning([ firstValue, ...restValues ]))
  |@cat:content>
  $ws |@cat:>
  literal(']') |@cat:>
  meaning(content || []);
// This rule matches a JSON object entry.
// Its match’s value is a new array pair.
const $entry =
  $string |> cat(%, key =>
    $ws |> cat(%, () =>
      literal(':') |> cat(%, () =>
        $ws |> cat(%, () =>
          $value |> meaning(value =>
            [ key, value ])))));
// This rule matches a JSON object entry.
// Its match’s value is a new array pair.
const $entry =
  $string |@cat: key>
  $ws |@cat:>
  literal(':') |@cat:>
  $ws |@cat:>
  $value |@cat: value>
  meaning([ key, value ]);
// These rules match
// the object starter and ender.
const $objectStarter = literal('{');
const $objectEnder = literal('}');

// This rule matches a JSON object.
// Its match’s value is a new plain object.
const $object =
  literal('{') |> cat(%, () =>
    opt($entry
      |> cat(%, firstEntry =>
        atLeast0($valueSeparator
          |> cat(%, () =>
            $entry |> cat(%, entry =>
              meaning(entry))))
        |> cat(%, restEntries =>
          meaning([ firstEntry, ...restEntries ]))))
    |> cat(%, content =>
      literal('}') |> cat(%, () =>
        meaning(content
          |> Object.fromEntries(%)))));
// These rules match
// the object starter and ender.
const $objectStarter = literal('{');
const $objectEnder = literal('}');

// This rule matches a JSON object.
// Its match’s value is a new plain object.
const $object =
  literal('{') |@cat:>
  opt($entry |@cat: firstEntry>
    atLeast0(
      $valueSeparator |@cat:>
      $entry |@cat: entry>
      meaning(entry))
    |@cat: restEntries>
    meaning([ firstEntry, ...restEntry ]))
  |@cat:content>
  literal('}') |@cat:>
  meaning(content |> Object.fromEntries(%));
// This rule matches a JSON value.
// Its match’s value is `null`, a boolean,
// a new plain object, a new array,
// a number, or a string.
const $value =
  either(
    $null, $false, $true,
    $object, $array,
    $number, $string);
// This rule matches a JSON value.
// Its match’s value is `null`, a boolean,
// a new plain object, a new array,
// a number, or a string.
const $value =
  either(
    $null, $false, $true,
    $object, $array,
    $number, $string);
// This rule matches a JSON document.
// Its match’s value is
// a new plain object or a new array.
const $document =
  $ws |> seq(%, () =>
    either($object, $array)
      |> seq(%, value =>
        $ws |> cat(%, () => meaning(value))));
// This rule matches a JSON value.
// Its match’s value is `null`, a boolean,
// a new plain object, a new array,
// a number, or a string.
const $document =
  $ws |@cat:>
  either($object, $array) |@cat: value>
  $ws |@cat:>
  meaning(value);

Example context-sensitive parser

Another example would parse the following inputText:

START A
START B
START C
END B
START D
END A

…into this output data: [ 'A', [ 'B', [ 'C' ] ], [ 'D' ] ].

This requires a context-sensitive grammar. Each START line starts a block (like for A or B), and each END line ends its block (and, implicitly, any sub-blocks inside of it). In this case, the END B line ends both the C and B blocks, and the END D line ends both the D and A blocks.

With the following example parser, $document(inputText) would evaluate to { value: [ 'A', [ 'B', [ 'C' ] ], [ 'D' ] ], remainder: '' }. The remainder: '' means that the inputText was completely matched from start to finish.

Regular pipes only With pipe adverbs
// This rule matches
// a single newline character.
const $newline = literal('\n');
// This rule matches
// a single newline character.
const $newline = literal('\n');
// This rule matches any code unit
// other than a newline character.
const $lineUnit =
  $codeUnit |> except(%, $newline);
// This rule matches any code unit
// other than a newline character.
const $lineUnit =
  $codeUnit |> except(%, $newline);
// This rule matches a single line,
// up to and excluding any newline character.
// Its match’s `value` is the line’s string.
const $line = atLeast1($lineUnit)
  |> cat(%, value => meaning(value.join('')));
// This rule matches a single line,
// up to and excluding any newline character.
// Its match’s `value` is the line’s string.
const $line =
  atLeast1($lineUnit)
  |@cat> meaning(%.join(''));
// This function creates a rule
// that matches a block’s `START` line.
// It expects to be given an array
// of the names of any ancestor blocks.
// A start line is made of “START ”,
// a name, then a newline character.
// Its match’s `value` is the name.
// That name must not equal the names
// of any ancestor blocks,
// or the rule will fail.
function create$startLine (ancestorNames) {
  return literal('START ') |> cat(%, () =>
    $line |> cat(%, name =>
      where(!ancestorNames.includes(name))
        |> cat(%, () => meaning(name))));
}
// This function creates a rule
// that matches a block’s `START` line.
// It expects to be given an array
// of the names of any ancestor blocks.
// A start line is made of “START ”,
// a name, then a newline character.
// Its match’s `value` is the name.
// That name must not equal the names
// of any ancestor blocks,
// or the rule will fail.
function create$startLine (ancestorNames) {
  return literal('START ') |@cat:>
    $line |@cat: name>
    where(!ancestorNames.includes(name))
      |@cat:> meaning(name);
}
// This function creates a rule
// that matches a specific block’s `END` line.
// It expects to be given that block’s `name`,
// which must match the line after `END `.
// Its match’s `value` is the `expectedName`.
function create$endLine (expectedName) {
  return literal('END ') |> cat(%, () =>
    $line |> cat(%, name =>
      where(name === expectedName)
        |> cat(%, () => meaning(expectedName))));
}
// This function creates a rule
// that matches a specific block’s `END` line.
// It expects to be given that block’s `name`,
// which must match the line after `END `.
// Its match’s `value` is the `expectedName`.
function create$endLine (expectedName) {
  return literal('END ') |@cat:>
    $line |@cat: name>
    where(name === expectedName)
    |@cat:> meaning(expectedName);
}
// This function creates a rule
// that matches an entire block.
// It expects to be given an array
// of the names of any ancestor blocks.
// A block is made of a `START` line,
// at least one sub-block
// (none of which may have
// any ancestor block’s name),
// and then an optional `END` line
// whose name matches the start line’s name.
// Its match’s `value` is an array
// starting with the block’s name
// followed by any sub-blocks’ own arrays.
function create$block (ancestorNames) {
  return function $block (input) {
    const $ =
      create$startLine(ancestorNames)
        |> cat(%, name =>
          atLeast1(create$block(
            [ ...ancestorNames, name ]))
          |> cat(%, subBlocks =>
            opt($endLine(name))
              |> cat(%, () => meaning(
                [ name, ...subBlocks ]))));
    return $(input);
  }
}
// This function creates a rule
// that matches an entire block.
// It expects to be given an array
// of the names of any ancestor blocks.
// A block is made of a `START` line,
// at least one sub-block
// (none of which may have
// any ancestor block’s name),
// and then an optional `END` line
// whose name matches the start line’s name.
// Its match’s `value` is an array
// starting with the block’s name
// followed by any sub-blocks’ own arrays.
function create$block (ancestorNames) {
  return create$startLine(ancestorNames)
    |@cat: name>
    atLeast1(create$block(
      [ ...ancestorNames, name ]))
    |@cat: subBlocks>
    opt($endLine(name)) |@cat:>
    meaning([ name, ...subBlocks ]);
}
// A document is made of at least one block,
// which in turn has no ancestors
// (hence the `[]` argument).
// Its match’s `value` is an array
// starting with the root block’s name
// followed by any sub-blocks’ own arrays.
export default const $document
  = atLeast1(createBlockRule([]));
// A document is made of at least one block,
// which in turn has no ancestors
// (hence the `[]` argument).
// Its match’s `value` is an array
// starting with the root block’s name
// followed by any sub-blocks’ own arrays.
export default const $document
  = atLeast1(createBlockRule([]));

Helper functions

$codeUnit matches a single code unit

This rule tries to match a single code point (a single “character”) from an inputString, consuming that single code point when it does match. It returns a match object { value, remainder }, where:

  • When inputString contains at least one code unit, then the match succeeds: value is the first code unit, and remainder is input with the first code unit removed from its beginning.
  • When inputString is empty, then the match fails: value is null, and remainder is also the empty string.

For example, $codeUnit('XYZ') evaluates to { value: 'X', remainder: 'YZ' }, which represents a successful match. Note that the rule has removed the first X from the input string in the remainder.

$codeUnit('') evaluates to { value: null, remainder: '' }, which, since the value is null, represents a failed match.

export function $codeUnit (inputString) {
  if (inputString.length) {
    // In this case, the `inputString` is not empty:
    // it has at least one code point to be matched,
    // and this rule therefore succeeds.
    return { value: input.charAt(0), remainder: inputString };
  } else {
    // In this case, the `inputString` is empty:
    // it has no code point to match,
    // and this rule therefore fails.
    return { value: null, remainder: inputString };
  }
}

literal matches a literal string

This function creates a rule that tries to match the given expectedString on an inputString, consuming the expectedString when it does match. The rule returns a match object { value, remainder }, where:

  • When inputString starts with the expectedString, then the match succeeds: value is the expectedString, and remainder is the inputString with the expectedString removed from its beginning.
  • When inputString does not start with the expectedString, then the match fails: value is null, and remainder is the unchanged inputString.

For example, For example, this is a rule that matches single newline characters: const $newline = literal('\n');

$newline('\nSTART B\n') evaluates to { value: '\n', remainder: 'START B\n' }, which represents a successful match. Note that the rule has removed the first \n from the input string in the remainder.

$newline('START B\n') evaluates to { value: null, remainder: 'START B\n' }, which represents a failed match. Note that there is no \n at the beginning of the initial string – hence the failure.

export function literal (expectedString) {
  return function $literal (inputString) {
    if (inputString.startsWith(expectedString)) {
      const remainder = expectedString.length |> input.substring(%);
      return { value: expectedString, remainder };
    } else {
      return { value: null, remainder: inputString };
    }
  }
}

$empty matches emptiness

This rule always matches the empty string from an inputString, consuming no text. It always return a successful match object { value: false, remainder: inputString }.

For example, $empty('XYZ') evaluates to { value: false, remainder: 'XYZ' }, which represents a successful match. Note that the rule consumed no text from the input string 'XYZ'.

export function $empty (inputString) {
  return { value: false, remainder: inputString };
}

where matches depending on a flag

This function creates a rule that checks whether the given flag is truthy. It never consumes any of its given inputString. The rule returns a match object { value, remainder }, where:

  • When flag is truthy, then the match succeeds: value is flag, and remainder is the unchanged inputString.
  • When flag is not truthy, then the match fails: value is null, and remainder is the unchanged inputString.

where(true)('X') evaluates to { value: true, remainder: 'X' }, which represents a successful match.

where(false)('X') evaluates to { value: null, remainder: 'X' }, which represents a failed match.

export function where (flag) {
  return function $where (inputString) {
    if (flag)
      return { value: flag, remainder: inputString };
    } else {
      return { value: null, remainder: inputString };
    }
  }
}

except subtracts a rule from another

This function creates a rule that tries to match the given $minuend on an inputString, while also checking whether the $subtrahend does not match. It only ever consumes whatever $minuend consumes. The rule returns a match object { value, remainder }, where:

  • When the $minuend matches inputString but the $subtrahend does not, then the match succeeds: value is $minuend’s match’s value, and remainder is the $minuend’s match’s remainder.
  • When the $minuend does not match inputString, or when both $minuend and $subtrahend match inputString, then the match fails: value is null, and remainder is the unchanged inputString.

For example, this is a rule that matches any code point except newline characters: const $codePointExceptNewline = $codePoint |> except(%, literal('\n'));

$codePointExceptNewline('XY'); evaluates to { value: true, remainder: 'Y' }, which represents a successful match.

$codePointExceptNewline('') evaluates to { value: null, remainder: '' }, which represents a failed match.

$codePointExceptNewline('\nX') evaluates to { value: null, remainder: 'X' }, which also represents a failed match.

export function except ($minuend, $subtrahend) {
  return function $except (inputString) {
    // First, apply the `$minuend` to the `inputString`.
    const minuendRuleMatch = $minuend(inputString);
    if (minuendRuleMatch.value != null) {
      // In this case, the `$minuend`’s match was a success.
      // Next, apply the `$subtrahend` to the `inputString`.
      const $subtrahendMatch = $subtrahend(inputString);
      if ($subtrahendMatch.value == null) {
        // In this case, the `$subtrahend`’s match was expectedly a failure,
        // and this `except` rule is therefore successful.
        // Return the `$minuend`’s match.
        return minuendRuleMatch;
      }
        // In this case, the `$subtrahend`’s match was unexpectedly a success,
        // and this `except` rule has therefore failed.
        // Return a failure match.
        return { value: null, remainder: inputString };
      }
    } else {
      // In this case, the `$minuend`’s match was unexpectedly a failure,
      // and this `except` rule has therefore failed.
      // Return that failed match.
      return minuendRuleMatch;
    }
  }
}

either creates an ordered-choice rule

This function creates a rule that tries to match the rules from the given ruleArr, one rule at a time, on an inputString, consuming the expectedString when one of the rules does match. The rule returns a match object { value, remainder }, where:

  • When any rule from arr (in order) matches inputString, then the match succeeds: value is $input’s match’s value, and remainder is the $input’s match’s remainder.
  • When no rule from arr matches inputString, then the match fails: value is null, and remainder is the unchanged inputString.
export function either (ruleArr) {
  return function $either (inputString) {
    for (const $input of ruleArr) {
      const inputRuleMatch = $input(remainder);
      if (inputRuleMatch.value != null) {
        // In this case, the `$input`’s match was a success.
        // Return that success.
        return inputRuleMatch;
      }
    }
    // In this case, not a single rule from `ruleArr` successfully matched.
    // Return a failed match.
    return { value: null, remainder: inputString };
  }
}

opt creates an optional rule

This function creates a rule that tries to match the given $input on an inputString, consuming the expectedString when $input does match, but succeeding anyway without consuming any text when $input fails. The rule returns a match object { value, remainder }, where:

  • When $input matches inputString, then the match succeeds: value is $input’s match’s value, and remainder is the $input’s match’s remainder.
  • When $input does not match inputString, then the match still succeeds: value is false, and remainder is the unchanged inputString.

For example, this is a rule that matches single newline characters: const $optionalNewline = opt(literal('\n'));.

$optionalNewline('\nSTART B\n') evaluates to { value: '\n', remainder: 'START B\n' }, which represents a successful match. Note that the rule has removed the first \n from the input string in the remainder.

$optionalNewline('START B\n'); evaluates to { value: false, remainder: 'START B\n' }, which also represents a successful match. Note that there is no \n at the beginning of the initial string, yet the rule succeeded anyway.

export function optional ($input) {
  return either($input, $empty);
}

cat concatenates two rules in a sequence

This function creates a rule that concatenates two different rules in a sequence. Both $input and body(inputRuleMatch.value) must evaluate into rules. The new rule tries to match the given $input on an inputString – consecutively followed by body(inputRuleMatch.value). The rule returns a match object { value, remainder }, where:

  • When $input then body(inputRuleMatch.value) consecutively match inputString, then the match succeeds: value is true, and remainder is the remainder after body(inputRuleMatch.value)’s match.
  • When $input does not match inputString, then the match fails: value is null, and remainder is body(inputRuleMatch.value)’s match’s remainder.
  • When $input matches inputString – but body(inputRuleMatch.value) does not consecutively match after $input – then the match fails: value is null, and remainder is $input’s match’s remainder.
export function cat ($input, body) {
  return function beforeRule (inputString) {
    // First, apply the `$input` to the `inputString`.
    const inputRuleMatch = $input(inputString);
    if (inputRuleMatch.value != null) {
      // In this case, the `$input`’s match was a success.
      // Next, create a body rule from the pipe `body` and the `$input`’s match’s value.
      const bodyRule = body(inputRuleMatch.value);
      // Then apply that body rule to the `$input` match’s `remainder` string,
      // i.e., after whatever part of the `inputString` that the `$input` has consumed.
      const bodyRuleMatch = bodyRule(inputRuleMatch.remainder);
      if (bodyRuleMatch.value != null) {
        // In this case, the `$body`’s match was also a success.
        // Now return a successful match that combines the two rules’ values in an array.
        return {
          value: [ inputRuleMatch.value, outputRuleMatch.value ],
          remainder: bodyRuleMatch.remainder,
        };
      }
      else {
        // In this case, the `$body`’s match was a failure.
        // Return that failed match.
        return bodyRuleMatch;
      }
    } else {
      // In this case, the `$input`’s match was a failure.
      // Return that failed match.
      return inputRuleMatch;
    }
  }
}

meaning changes the values of a rule’s matches

This function uses a callback body to change the values of any successful matches that are returned by a single $input. It creates a rule that tries to match the given $input on an inputString, consuming any text that $input consumes. The rule returns a match object { value, remainder }, where:

  • When $input matches inputString, then the match succeeds: value is body(inputRuleMatch.value), and remainder is the $input’s match’s remainder.
  • When $input does not match inputString, then the match fails: value is null, and remainder is the $input’s match’s remainder.
export function meaning ($input, body) {
  return function meaningRule (inputString) {
    // First, apply the `$input` to the `inputString`.
    const inputRuleMatch = $input(inputString);
    if (inputRuleMatch.value != null) {
      // In this case, the `$input`’s match was a success.
      // Next, create a `bodyValue` from the pipe `body` and the `$input`’s match’s value.
      const bodyValue = body(inputRuleMatch.value);
      // Now return a successful match with that `bodyValue`.
      return { value: bodyValue, remainder: inputRuleMatch.remainder };
    } else {
      // In this case, the `$input`’s match was a failure.
      // Return the failed match.
      return inputRuleMatch;
    }
  }
}

atLeast0 and atLeast1 create repeating rules

atLeast0 creates a rule that tries to match the given $input on an inputString consecutively, as many times as possible. The $input, whenever it succeeds, must always consume at least one code unit, or else an infinite loop may occur. The rule returns a match object { value, remainder }, where:

  • When $input matches inputString at least once, then the match succeeds: value is an array of the $input matches’ values, and remainder is the remainder after the final $input match.
  • When $input does not match inputString a single time, then the match still succeeds: value is [], and remainder is the unchanged inputString.

For example, this is a rule that matches at least one newline character: const $atLeast0Newline = atLeast0(literal('\n'));.

$atLeast0Newline('\n\n\nSTART B\n'); evaluates to { value: [ '\n', '\n', '\n' ], remainder: 'START B\n' }, which represents a successful match. Note that the rule has removed the first \ns from the input string in the remainder.

$atLeast1Newline('START B\n') evaluates to { value: null, remainder: 'START B\n' }, which represents a failed match. Note that there is no \n at the beginning of the initial string, yet the rule succeeded anyway.

export function atLeast0 ($input) {
  return function $atLeast0 (inputString) {
    const valueArray = [];
    let remainder = inputString;
    // The `remainder` string will be gradually consumed by any matches.
    // This loop will run as long as there are any remaining code points
    // from the `inputString`,
    // and as long as `$input` continues to consecutively match those code points.
    while (remainder.length > 0) {
      // Apply the `$input` to the `remainder`.
      const inputRuleMatch = $input(remainder);
      if (inputRuleMatch.value) {
        // In this case, the `$input`’s match was a success,
        // and the loop may continue
        // (unless there are no more code points in `remainder`).
        valueArray.push(inputRuleMatch.value);
        remainder = inputRuleMatch.remainder;
      } else {
        // In this case, the `$input`’s match was a failure,
        // so `$input` has reached the end of its repetition.
        break;
      }
    }
    // The input string has been completely depleted,
    // or `$input` no longer matches.
    return valueArray;
  }
}

atLeast1 creates a rule that tries to match the given $input on an inputString consecutively, as many times as possible. The $input must always consume at least one code unit, or else an infinite loop may occur.

The $input, whenever it succeeds, must always consume at least one code unit, or else an infinite loop may occur. The rule returns a match object { value, remainder }, where:

  • When $input matches inputString at least once, then the match succeeds: value is an array of the $input matches’ values, and remainder is the remainder after the final $input match.
  • When $input does not match inputString a single time, then the match fails: value is null, and remainder is the remainder after the first failed $input match.

For example, this is a rule that matches at least one newline character: const $atLeast1Newline = atLeast1(literal('\n'));.

$atLeast1Newline('\n\n\nSTART B\n'); evaluates to { value: [ '\n', '\n', '\n' ], remainder: 'START B\n' }, which represents a successful match. Note that the rule has removed the first \ns from the input string in the remainder.

$atLeast1Newline('START B\n') evaluates to { value: null, remainder: 'START B\n' }, which represents a failed match. Note that there is no \n at the beginning of the initial string, yet the rule succeeded anyway.

export function atLeast1 ($input) {
  const $atLeast0 = atLeast0($input);
  // Create a rule that expects an `$input`,
  // followed by at least zero repeated `$input`s,
  // then which combines the values into a single array.
  return $input |> cat(%, firstValue =>
    $atLeast0 |> cat(%, restValues =>
      meaning([ firstValue, ...restValues ])));
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment