Skip to content
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

How to convert AST to an expression string? #123

Open
chetbox opened this issue Jun 23, 2022 · 5 comments
Open

How to convert AST to an expression string? #123

chetbox opened this issue Jun 23, 2022 · 5 comments

Comments

@chetbox
Copy link

chetbox commented Jun 23, 2022

We have a situation where we're changing the context object in our application to expose more useful data.
This means that expressions that our users have written now have to change so we would like to make this change for them.

e.g.

channel.foo

is now

channels.foo.value

To do this I plan to compile the expression, traverse its AST and update the {type: 'Identifier'} objects. How do I then convert this new AST back to an expression string?

@TomFrost
Copy link
Owner

Hi Chetan! That's a great strategy, but unfortunately Jexl doesn't include a way to de-compile the AST.

@chetbox
Copy link
Author

chetbox commented Jun 24, 2022

Thanks @TomFrost. This is something we really need so I wrote my own in Typescript.

export type JexlAst =
  | { type: 'UnaryExpression'; operator: string; right: JexlAst }
  | { type: 'BinaryExpression'; operator: string; left: JexlAst; right: JexlAst }
  | { type: 'ConditionalExpression'; test: JexlAst; consequent: JexlAst; alternate: JexlAst }
  | { type: 'FilterExpression'; relative: boolean; expr: JexlAst; subject: JexlAst }
  | { type: 'Literal'; value: string | number | boolean }
  | { type: 'ArrayLiteral'; value: JexlAst[] }
  | { type: 'ObjectLiteral'; value: { [key: string]: JexlAst } }
  | { type: 'Identifier'; value: string; from?: JexlAst; relative?: boolean }
  | { type: 'FunctionCall'; name: string; pool: 'functions' | 'transforms'; args: JexlAst[] };

export function escapeKeyOfExpressionIdentifier(identifier: string, ...keys: string[]): string {
  if (keys.length === 0) {
    return identifier;
  }
  const key = keys[0];
  return escapeKeyOfExpressionIdentifier(
    key.match(/^[A-Za-z_]\w*$/)
      ? `${identifier}.${key}`
      : `${identifier}["${key.replace(/"/g, '\\"')}"]`,
    ...keys.slice(1)
  );
}

function getIdentifier(ast: Extract<JexlAst, { type: 'Identifier' }>): [string, ...string[]];
function getIdentifier(ast: Extract<JexlAst, { type: 'FilterExpression' }>): string[];
function getIdentifier(ast: Extract<JexlAst, { type: 'Identifier' | 'FilterExpression' }>): string[];
function getIdentifier(ast: Extract<JexlAst, { type: 'Identifier' | 'FilterExpression' }>): string[] {
  switch (ast.type) {
    case 'Identifier':
      return [
        ...(ast.from?.type === 'Identifier' || ast.from?.type === 'FilterExpression'
          ? getIdentifier(ast.from)
          : []),
        ast.value
      ];
    case 'FilterExpression':
      if (
        !ast.relative &&
        ast.expr.type === 'Literal' &&
        typeof ast.expr.value == 'string' &&
        ast.subject.type === 'Identifier'
      ) {
        // We are indexing into an object with a string so let's treat `foo["bar"]` just like `foo.bar`
        return [...getIdentifier(ast.subject), ast.expr.value];
      } else {
        return [];
      }
  }
}

export function expressionStringFromAst(ast: JexlAst | null): string {
  if (!ast) {
    return '';
  }

  switch (ast.type) {
    case 'Literal':
      return JSON.stringify(ast.value);
    case 'Identifier':
      return escapeKeyOfExpressionIdentifier(...getIdentifier(ast));
    case 'UnaryExpression':
      return `${ast.operator}${expressionStringFromAst(ast.right)}`;
    case 'BinaryExpression':
      return `${expressionStringFromAst(ast.left)} ${ast.operator} ${expressionStringFromAst(
        ast.right
      )}`;
    case 'ConditionalExpression':
      return `${expressionStringFromAst(ast.test)} ? ${expressionStringFromAst(
        ast.consequent
      )} : ${expressionStringFromAst(ast.alternate)}`;
    case 'ArrayLiteral':
      return `[${ast.value.map(expressionStringFromAst).join(', ')}]`;
    case 'ObjectLiteral':
      return `{ ${Object.entries(ast.value)
        .map(([key, value]) => `${JSON.stringify(key)}: ${expressionStringFromAst(value)}`)
        .join(', ')} }`;
    case 'FilterExpression':
      return `${expressionStringFromAst(ast.subject)}[${
        ast.relative ? '.' : ''
      }${expressionStringFromAst(ast.expr)}]`;

    case 'FunctionCall':
      switch (ast.pool) {
        case 'functions':
          return `${ast.name}(${ast.args.map(expressionStringFromAst).join(', ')})`;
        case 'transforms':
          // Note that transforms always have at least one argument
          // i.e. `a | b` is `b` with one argument of `a`
          return `${expressionStringFromAst(ast.args[0])} | ${ast.name}${
            ast.args.length > 1
              ? `(${ast.args
                  .slice(1)
                  .map(expressionStringFromAst)
                  .join(', ')})`
              : ''
          }`;
      }
  }
}

Would it be useful to make a PR here?

@chetbox
Copy link
Author

chetbox commented Jun 24, 2022

On further inspection, while this works for simple cases, it doesn't work for more complex expressions because of operator precedence.

For example expressionStringFromAst will give "1 + 2 * 3" for the AST for "(1 + 2) * 3" which evaluate to different expressions. I think the way to do this successfully is using precedence rules in the grammar to surround sub-expressions with brackets where necessary.

Edit: I have a new version that's tested and works nicely if anyone is interested. Comment here and I'll post the latest version or publish it as a library.

@sreuter
Copy link

sreuter commented Jul 14, 2022

@chetbox Definitely interested!

@chetbox
Copy link
Author

chetbox commented Jul 26, 2022

The implementation above is not far off but misses some important corner cases which could change the logic of the expression.

I've created a library with a much more robust implementation here: https://www.npmjs.com/package/jexl-to-string

Example:

import { jexlExpressionStringFromAst } from "jexl-to-string";
import { Jexl } from "jexl";

const jexl = new Jexl();
const compiledExpression = jexl.compile(input);
let ast = compiledExpression._getAst();
// Modify `ast` here
const newExpression = jexlExpressionStringFromAst(jexl._grammar, ast);

@sreuter

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants