Skip to content

Instantly share code, notes, and snippets.

@js-choi
Last active October 27, 2022 09:58
Show Gist options
  • Save js-choi/446f79ea2b4a1cfac8a8cf594ca269a9 to your computer and use it in GitHub Desktop.
Save js-choi/446f79ea2b4a1cfac8a8cf594ca269a9 to your computer and use it in GitHub Desktop.
Babel statement flattening

Because IIFEs aren’t adequate for either smart pipelines, we need to create helper functions that allow us to “flatten” statements that contain them, extracting them into preceding constant declarations. This would not only allow us to implement smart pipelines properly, this would also allow us to implement do expressions away from using IIFEs, which do not work with await and yield (see babel/babel#3780).

  • Add a getChildOfInnermostStatement method to paths, so that calling the method on a path to x in…

    function f () { return Promise.all(g(x)); }

    …would return a path pointing to the Promise.all(g(x)) expression. The same thing would happen if this method is called on paths to g(x) or Promise.all(g(x)) itself. (Calling it on a path to statements such as the return statement will return undefined.)

    Similarly, calling this method on a path to x in…

    if (f(x)) g(y); else h(z);

    …would return a path pointing to the f(x) expression. The same thing would happen if this method is called on a path to f(x) itself. Calling the method on paths to y or to g(y) would return a path to g(y). The same would go for paths to z or to h(z), returning a path to h(z).

  • Add a replaceTopicReferences function that, given any main expression and a variable, replaces all instances of PipelinePrimaryTopicReference in the main expression (except references contained within an inner pipeline’s body) with that variable. For instance, using this function on # + (# |> # * 2) + 1 with an $0 variable will return $0 + ($0 |> # * 2) + 1.

  • Create an isPureExpression function that, given any type of expression or statement, returns a boolean for whether the expression is pure or impure (i.e., whether its evaluation may cause any observable side effects, including runtime errors). The order in which a program’s impure expressions are evaluated must be preserved by any code transformation, but the order of pure expressions’ evaluation may be rearranged without affecting the program’s behavior. The function’s behavior is such that:

    • It would return true for any literal boolean/number/string, like true, false, 1, "string".
    • It would return true for any variable reference, like x.
    • It would return true for any array literal whose elements are all pure expressions, like [0, 1], [[0, 1], 2], [f(0, 1), 2].
    • It would return true for any object literal whose keys and values are all pure expressions, like {x: 0, y: 1}, {a: {x: 0, y: 1}, b: 2}, {x: f(0, 1), y: 2}.
    • It would return true for any function-definition expression.
    • It would return true for any comma expression or logical-not/and/or/conditional expression whose child subexpression(s) are themselves pure.
    • It would return false for any other expression, including pipeline expressions, function/constructor calls, assignment expressions, arithmetic/bitwise expressions (which can raise runtime errors), member access, await expressions, yield expressions, and do expressions.
  • Create a flattenExpressionOnce function that, given any type of expression, returns a shallowly flattened version of that expression – along with a series of helper declarations for lexically hygienic new helper constants or helper variables. If the expression cannot be flattened (if it is already flat), then it returns null instead.

    Category Original Flattened Helper declarations
    A 0 Already flat None
    B await x Already flat None
    C await f() await $0 const $0 = f();
    C g(f(), 0) g($0, 0) const $0 = f();
    C [f(), 0] [$0, 0] const $0 = f();
    C {[a]: b, [f()]: g()} {[a]: b, [$0]: $1} const $0 = f(); const $1 = g();
    C {[x |> # + 1]: y} {[$0]: y} const $0 = x |> # + 1;
    D x |> f f(x) None
    E x |> f |> g g($0) const $0 = x |> f;
    E x |> # + 1 |> g g($0) const $0 = x |> # + 1;
    F x |> # + 1 x + 1 None
    F x |> [f(), # + 1, # + 2] [f(), x + 1, x + 2] None
    F x |> [f(), # |> # + 1] [f(), x |> # + 1] None
    G g(x) |> [f(), # + 1, # + 2] [f(), $0 + 1, $0 + 2] const $0 = g(x);
    G g(x) |> [f(), # |> # + 1] [f(), $0 |> # + 1] const $0 = g();
    G x |> [f(), # + 1] |> [g(), h(#), i(#)] [g(), h($0), i($0)] const $0 = x |> [f(), # + 1];
    H do { if (f()) g(); else h(); } $0; var $0; if (f()) $0 = g(); else $0 = h();

    The examples above follow these rules:

    • A. When the expression contains no subexpression, then it is already flat and the function returns null.

    • B. When the expression contains child subexpressions and none of those child subexpressions is impure, then it is already flat and the function returns null.

    • C. When the expression contains child subexpressions and any one of those child subexpressions is impure, then its flattened version is the expression but with all of those impure child subexpressions replaced by lexically hygienic new helper constants.

    • D. When the expression is a bare-style pipeline whose head is a pure expression, then its flattened version is simply a function call on the pipeline’s body with the pipeline’s head. In this case, there are no helper declarations.

    • E. When the expression is a bare-style pipeline whose head is an impure expression, then its flattened version is a function call on a lexically hygienic new helper constant. The helper constant is defined as the the pipeline’s head expression.

    • F. When the expression is a topic-style pipeline whose head is a pure expression, then its flattened version is the result of calling replaceTopicReferences on the pipeline’s body expression with the pipeline’s head. In this case, there are no new helper declarations.

    • G. When the expression is a topic-style pipeline whose head is an impure expression, then its flattened version is the result of first calling replaceTopicReferences on the pipeline’s body expression with a lexically hygienic new helper constant. The new helper constant is defined as the pipeline’s head expression.

    • H. When the expression is a do expression, then its flattened version is a lexically hygienic new helper variable. The new helper variable’s declaration is followed by the child statements of the original do expression’s block, except that the final statement of every branch from the block is assigned to the helper variable. The logic for adding such assignments to a block of statements is already implemented in replaceExpressionWithStatements, but, unlike with replaceExpressionWithStatements, the resulting statements would not be wrapped in an arrow function.

  • Create a flattenExpression function that, given any type of expression, returns a deeply flattened version of that expression – along with a series of helper declarations for lexically hygienic new helper constants or helper variables. If the expression cannot be flattened (if it is already flat), then it returns null instead.

    This occurs by iterating first over the expression until it is flat (that is, until flattenExpressionOnce returns null). Each time this occurs, any new helper declarations are appended to a growing list. Then, flattenExpression is recursively applied to each helper declaration's binding expression. If it does not return null, then a new helper declaration using the newly flattened binding expression replaces the old helper declaration in the list. Any additional helper declarations that this recursive flattenExpression call created are also inserted into the list, immediately before the current helper declaration.

  • Create a flattenAndReplaceChildOfStatement function that requires a path to any expression whose parent is a statement (see getChildOfInnermostStatement above); otherwise it will throw an error. The function then replaces that expression with the result of flattenExpression. It then inserts any new constant declarations in the appropriate location (usually, but not always, immediately before the replaced expression’s parent statement).

    For instance, the array expression in return [f(), x, y |> # + 1 |> g(#, 2)]; is a child of a statement (the return statement). And calling flattenExpression on the expression would result in [$0, $1] and the new declarations const $0 = f(); const $1 = y |> # + 1 |> g(#, 2);. So then calling flattenAndReplaceChildOfStatement on that array expression will effectively replace its return statement with const $0 = f(); const $1 = y |> # + 1 |> g(#, 2); return [$0, x, $1];.

    Now consider the pipeline expression x |> # + 1 |> g(#, 2) in that new code. It is now also a child of a statement (a const statement). Calling flattenStatement on that pipeline expression would result in const $1 = g($2, 1); and the new declaration const $2 = x |> # + 1;. So then calling flattenAndReplaceStatement on the pipeline expression will replace its const statement with const $2 = x |> # + 1; const $1 = g($2, 1);.

    • In general, when calling flattenAndReplaceChildOfStatement on any expression that is a child of a statement, then first flattenExpression is called on that expression. (flattenStatement returns a new version of that statement along with any new declarations that it uses.) The original statement is replaced inline with the new version returned by flattenStatement. Then the new declarations that it also returned will be inserted immediately before the statement. Several exceptions to this rule follow.

    • If the parent expression is directly the antecedent or consequent child of an if statement – or if the expression is directly the body child of an arrow function or of a for, forin, forof, while, dowhile, with, or label statement – then the replacement statement must first be wrapped in a block statement. The new declarations will then be inserted within the block before the statement. For instance, flattening the pipeline expression in the antecedent statement of:

      if (f())
        g() |> h(#, #+1) |> i;
      else
        j();

      …would result in this:

      if (f()) {
        const $0 = g();
        const $1 = h($0, $0+1);
        i($1);
      } else
        j();
    • If the original expression was the test child of a while (<test>) <body> statement or a do <body> while (<test>) statement, then first flattenExpressionis called on thetestexpression. This results in a replacementtestexpression, and a list of new declarations for the replacementtest. A declaration for an additional lexically hygienic new variable (called $testbelow, which is initially bound to the replacementtest` expression) is also created. These parts are then combined such that the final statement looks like this:

      <testHelperDeclarations>
      var $test = <replacementTestExpression>;
      while ($test) {
        <body>
        <testHelperDeclarations>
        $test = <replacementTestExpression>;
      }

      For instance, flattening the test expression in:

      while (i |> f(#, #+1) |> g |> # < n)
        console.log(i);

      …would result in this:

      const $0 = f(i, i+1);
      const $1 = g($0);
      var $test = $1 < n;
      while ($test) {
        console.log(i);
        const $0 = f(i, i+1);
        const $1 = g($0);
        $test = $1 < n;
      }
    • If the original expression was the test child of a classic for (<init>; <test>; <update>) <body> statement, then first flattenExpression is called on the test expression. This results in a replacement test expression, and a list of new declarations for the replacement test. A declaration for an additional lexically hygienic new variable (called $test below, which is initially bound to the replacement test expression) is also created. These parts are then combined such that the final for statement looks like this:

      {
        <initClause>
        <testHelperDeclarations>
        var $test = <replacementTestExpression>;
        for (; $test; <update>) {
          <body>
          <testHelperDeclarations>
          $test = <replacementTestExpression>;
        }
      }

      For instance, flattening the test expression in:

      for (var i = 0; i |> f(#, #+1) |> g |> # < n; i++)
        console.log(i);

      …would result in this:

      {
        var i = 0;
        const $0 = f(i, i+1);
        const $1 = g($0);
        var $test = $1 < n;
        for (; $test; i++) {
          console.log(i);
          const $0 = f(i, i+1);
          const $1 = g($0);
          $test = $1 < n;
        }
      }

Once flattenAndReplaceExpression is implemented, a visitor searching for pipeline expressions will be able to call flattenAndReplaceExpression on the child of the innermost statement containing that pipeline expression (as determined by the getChildOfInnermostStatement path method mentioned way above).

The visitor will then jump back to the path returned by flattenAndReplaceStatement, which is the earliest statement that was just-now modified (and which will either be a new const declaration or the innermost statement containing the pipeline itself). There, the visitor will restart its search for more pipeline expressions.

The visitor should perform this transformation inside-out: that is, upon leaving pipeline expressions, rather than when entering them.

do expressions could similarly be implemented using this same code, rather than using IIFEs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment