Skip to content

Instantly share code, notes, and snippets.

@eburlingame
Last active June 7, 2019 23:37
Show Gist options
  • Save eburlingame/7186971307143116dd7cc631bae24756 to your computer and use it in GitHub Desktop.
Save eburlingame/7186971307143116dd7cc631bae24756 to your computer and use it in GitHub Desktop.
Todoist Filter Expressions
const BINARY_EXPRESSION = "BinaryExpression";
const UNARY_EXPRESSION = "UnaryExpression";
const IDENTIFIER = "Identifier";
const LITERAL = "Literal";
const SYMBOL_DECORATORS = ["@", "#"];
const binOps = {
"|": (l, r, env) => (l || r),
"&": (l, r, env) => (l && r)
};
const unOps = {
"@": (v, env) => (env.labels.includes(v)),
"#": (v, env) => (v === env.project),
"!": (v, env) => (!v)
};
function evalSymbol(expr, env) {
switch (expr.type) {
case IDENTIFIER: {
return expr.name;
} break;
case LITERAL: {
return expr.raw;
} break;
default: {
throw `Invalid id: ${JSON.stringify(expr)}`;
}
}
}
function evalExpression(expr, env) {
switch(expr.type) {
case BINARY_EXPRESSION: {
const res = binOps[expr.operator](
evalExpression(expr.left, env),
evalExpression(expr.right, env),
env,
);
return res;
};
case UNARY_EXPRESSION: {
let subexpr;
// Do not recurse on symbol decorators (@ and #)
if (SYMBOL_DECORATORS.includes(expr.operator)) {
subexpr = evalSymbol(expr.argument);
} else {
subexpr = evalExpression(expr.argument, env);
}
return unOps[expr.operator](subexpr, env);
};
default: {
throw `Unexpected expression: ${JSON.stringify(expr)}`
} break;
}
}
function compileFilterExpression(strExpr) {
const jsep = require("jsep");
// Only use a subset of ops
jsep.removeAllUnaryOps();
jsep.removeAllBinaryOps();
jsep.removeAllLiterals();
jsep.addUnaryOp('@', 1);
jsep.addUnaryOp('#', 2);
jsep.addUnaryOp('!', 3);
jsep.addBinaryOp('&', 4);
jsep.addBinaryOp('|', 5);
// Return the compiled expression AST
return jsep(strExpr);
}
function evaluateFilterExpression(compiledExpr, item) {
const e = {
labels: item.labels,
project: item.project
}
// Evaluate the expression
return evalExpression(compiledExpr, e);
}
module.exports = {
evaluateFilterExpression: evaluateFilterExpression,
compileFilterExpression: compileFilterExpression
};
// console.log(
// evalExpression(
// compileFilterExpression("@Label8 & @Label6 & #Project"),
// {
// project: "Project",
// labels: ["Label6", "Label8"]
// })
// )
const expr = require('./expr');
// compileFilterExpression tests
test("Single unary @ AST", () =>
expect(expr.compileFilterExpression("@Computer"))
.toEqual({
"argument": {
"name": "Computer",
"type": "Identifier",
},
"operator": "@",
"prefix": true,
"type": "UnaryExpression"
})
);
test("Single unary ! AST", () =>
expect(expr.compileFilterExpression("!@Computer"))
.toEqual({
"argument": {
"argument": {
"name": "Computer",
"type": "Identifier",
},
"operator": "@",
"prefix": true,
"type": "UnaryExpression",
},
"operator": "!",
"prefix": true,
"type": "UnaryExpression",
})
);
test("Two binops and labels", () =>
expect(expr.compileFilterExpression("@Computer | (#Test & #Test)"))
.toEqual({
"left": {
"argument": {
"name": "Computer",
"type": "Identifier",
},
"operator": "@",
"prefix": true,
"type": "UnaryExpression",
},
"operator": "|",
"right": {
"left": {
"argument": {
"name": "Test",
"type": "Identifier",
},
"operator": "#",
"prefix": true,
"type": "UnaryExpression",
},
"operator": "&",
"right": {
"argument": {
"name": "Test",
"type": "Identifier",
},
"operator": "#",
"prefix": true,
"type": "UnaryExpression",
},
"type": "BinaryExpression",
},
"type": "BinaryExpression",
})
);
// evaluateFilterExpression tests
const evalExpression = (exprStr, item) => (
expr.evaluateFilterExpression(
expr.compileFilterExpression(exprStr),
item)
);
test("Single project", () => expect(
evalExpression(
"#Project",
{
project: "Project",
labels: []
}))
.toBe(true)
);
test("Not single project", () => expect(
evalExpression(
"!#Project",
{
project: "Project",
labels: []
}))
.toBe(false)
);
test("Single label", () => expect(
evalExpression(
"@Label",
{
project: "",
labels: ["Label"]
}))
.toBe(true)
);
test("Not single label", () => expect(
evalExpression(
"!@Label",
{
project: "",
labels: ["Label"]
}))
.toBe(false)
);
test("Label or project", () => expect(
evalExpression(
"!@Label | #Project",
{
project: "Project",
labels: ["Label"]
}))
.toBe(true)
);
test("Label and project", () => expect(
evalExpression(
"!@Label & #Project",
{
project: "Project",
labels: ["Label"]
}))
.toBe(false)
);
test("And subexpression", () => expect(
evalExpression(
"#Project & (@Label1 & @Label2)",
{
project: "Project",
labels: ["Label1", "Label2"]
}))
.toBe(true)
);
test("Or subexpression", () => expect(
evalExpression(
"#Project & (@Label1 | @Label2)",
{
project: "Project",
labels: ["Label1", "Label5"]
}))
.toBe(true)
);
test("Compound ors", () => expect(
evalExpression(
"@Label6 | @Label7 | #Project",
{
project: "Project",
labels: ["Label1", "Label5"]
}))
.toBe(true)
);
test("Compound ands false", () => expect(
evalExpression(
"@Label8 & @Label6 & #Project",
{
project: "Project",
labels: ["Label1", "Label5"]
}))
.toBe(false)
);
test("Compound ands true", () => expect(
evalExpression(
"@Label8 & @Label6 & #Project",
{
project: "Project",
labels: ["Label6", "Label8"]
}))
.toBe(true)
);
test("Compound ands with nested", () => expect(
evalExpression(
"@Label8 & @Label6 & (#Project2 | (((@Label15))))",
{
project: "Project",
labels: ["Label6", "Label8", "Label15"]
}))
.toBe(true)
);
test("Wrong project", () => expect(
evalExpression(
"(((((#Project2)))))",
{
project: "Project",
labels: ["Label1", "Label5"]
}))
.toBe(false)
);
test("Numeric project", () => expect(
evalExpression(
"#3.01",
{
project: "3.01",
labels: []
}))
.toBe(true)
);
test("Invalid label id", () => expect(
() => evalExpression(
"@(#Project|@Label)",
{
project: "Project",
labels: ["Label"]
}))
.toThrow(/Invalid/)
);
test("Invalid project id", () => expect(
() => evalExpression(
"#(#Project|@Label)",
{
project: "Project",
labels: ["Label"]
}))
.toThrow(/Invalid/)
);
test("Random symbols", () => expect(
() => evalExpression(
"these|arent|valid|symbols",
{
project: "Project",
labels: ["Label"]
}))
.toThrow(/Unexpected expression/)
);
test("Math expression", () => expect(
() => evalExpression(
"@1+234",
{
project: "Project",
labels: ["Label"]
}))
.toThrow(/Unexpected/)
);
test("Numeric literal", () => expect(
() => evalExpression(
"@1|234",
{
project: "Project",
labels: ["Label"]
}))
.toThrow(/Unexpected/)
);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment