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

Fix parsing and evaluation of lambdas with args and operator associativity #151

Merged
merged 11 commits into from
Jan 9, 2022
13 changes: 13 additions & 0 deletions packages/binding.core/spec/eventBehaviors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,19 @@ describe('Binding: Event', function () {
triggerEvent(testNode.childNodes[0], 'mouseout') // Shouldn't do anything (specifically, shouldn't throw)
})

it('Should invoke lambda when the event occurs, using model as \'this\' param and first arg, and event as second arg', function () {
testNode.innerHTML = "<button data-bind='event: { click: (data, event) => data.log(event) }'>hey</button>"
let thing = null;
applyBindings({
log (arg) {
thing = arg
}
})
triggerEvent(testNode.childNodes[0], 'click')
expect(thing)
expect(thing.type).toEqual('click')
})

it('Should prevent default action', function () {
testNode.innerHTML = "<a href='http://www.example.com/' data-bind='event: { click: noop }'>hey</a>"
applyBindings({ noop: function () {} }, testNode)
Expand Down
12 changes: 12 additions & 0 deletions packages/binding.core/spec/optionsBehaviors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,18 @@ describe('Binding: Options', function () {
expect(testNode.childNodes[0]).toHaveTexts(['bob (manager)', 'frank (coder & tester)'])
})

it('Should accept lambda in optionsText param to compute text from model values', function () {
var modelValues = observableArray([
{ name: 'bob' },
{ name: 'frank' }
])
testNode.innerHTML = "<select data-bind='options: myValues, optionsText: val => val.name.toUpperCase()'><option>should be deleted</option></select>"
applyBindings({
myValues: modelValues,
}, testNode)
expect(testNode.childNodes[0]).toHaveTexts(['BOB', 'FRANK'])
})

it('Should accept a function in optionsValue param to select subproperties of the model values (and use that for the option text)', function () {
var modelValues = observableArray([
{ name: 'bob', job: 'manager' },
Expand Down
2 changes: 1 addition & 1 deletion packages/binding.core/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export var options = {

function applyToObject (object, predicate, defaultValue) {
var predicateType = typeof predicate
if (predicateType == 'function') // Given a function; run it against the data value
if (predicateType === 'function') // Given a function; run it against the data value
{ return predicate(object) } else if (predicateType == 'string') // Given a string; treat it as a property name on the data value
{ return object[predicate] } else // Given no optionsText arg; use the data value itself
{ return defaultValue }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ describe('Function Rewrite Provider', function () {
'x: function () { return "abc" }': 'x: () => "abc"',
'stringLiteral: "hello", numberLiteral: 123, boolLiteralTrue: true, boolLiteralFalse: false, objectLiteral: {}, functionLiteral: function() { }, nullLiteral: null, undefinedLiteral: undefined': 'stringLiteral: "hello", numberLiteral: 123, boolLiteralTrue: true, boolLiteralFalse: false, objectLiteral: {}, functionLiteral: () => undefined, nullLiteral: null, undefinedLiteral: undefined',
'function (v) { return v.name + " (" + v.job + ")"; }': '(v) => v.name + " (" + v.job + ")"',
'function () { foo(); }': '() => foo() && undefined'
'function () { foo(); }': '() => foo() && undefined',
}
const idempotents = [
'x: nonfunction () {}'
Expand Down
46 changes: 35 additions & 11 deletions packages/utils.parser/spec/nodeBehaviors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
} from '@tko/observable';

import {
Parser, Node, Arguments, Identifier
Parser, Node, Arguments, Identifier, Ternary
} from '../dist';

var op = Node.operators
Expand Down Expand Up @@ -93,35 +93,43 @@ describe('the create_root function', function () {
})

it('converts multiple * to a tree', function () {
// expr: a * b / c
// sexp: (/ (* a b) c)
var nodes = ['a', operators['*'], 'b', operators['/'], 'c'],
tree = nodes_to_tree(nodes.slice(0));
assert.equal(tree.lhs, 'a');
assert.equal(tree.op, operators['*']);
assert.equal(tree.rhs.lhs, 'b');
assert.equal(tree.rhs.op, operators['/']);
assert.equal(tree.rhs.rhs, 'c');
assert.equal(tree.lhs.lhs, 'a');
assert.equal(tree.lhs.op, operators['*']);
assert.equal(tree.lhs.rhs, 'b');
assert.equal(tree.op, operators['/']);
assert.equal(tree.rhs, 'c');
})

it('converts a complex set as expected', function () {
// expr: a * b + c * d * e > f + g % h
// prec: 4 3 4 4 1 3 4
// sexp: (> (+ (* a b) (* (* c d) e))
// (+ f (% g h)))
var nodes = [
'a', operators['*'], 'b',
operators['+'],
'c', operators['*'], 'd', operators['*'], 'e',
operators['>'],
'f', operators['+'], 'g', operators['%'], 'h'
],
root = nodes_to_tree(nodes.slice(0));
root = nodes_to_tree(nodes.slice(0), true);
// console.log(JSON.stringify(root, null, 2))
assert.equal(root.op, operators['>'], '>')

assert.equal(root.lhs.op, operators['+'], '+')
assert.equal(root.lhs.lhs.op, operators['*'], '*')
assert.equal(root.lhs.lhs.lhs, 'a')
assert.equal(root.lhs.lhs.rhs, 'b')

assert.equal(root.lhs.rhs.op, operators['*'], '*')
assert.equal(root.lhs.rhs.lhs, 'c')
assert.equal(root.lhs.rhs.rhs.lhs, 'd')
assert.equal(root.lhs.rhs.rhs.rhs, 'e')
assert.equal(root.lhs.rhs.op, operators['*'], '* 2')
assert.equal(root.lhs.rhs.lhs.op, operators['*'], '* 3')
assert.equal(root.lhs.rhs.lhs.lhs, 'c')
assert.equal(root.lhs.rhs.lhs.rhs, 'd')
assert.equal(root.lhs.rhs.rhs, 'e')

assert.equal(root.rhs.op, operators['+'], 'rhs +')
assert.equal(root.rhs.lhs, 'f')
Expand All @@ -130,6 +138,22 @@ describe('the create_root function', function () {
assert.equal(root.rhs.rhs.lhs, 'g')
assert.equal(root.rhs.rhs.rhs, 'h')
})

it('converts a lambda with ternary operator', function () {
const root = nodes_to_tree([
undefined, op['=>'],
-1, op['>'], 0,
op['?'],
new Ternary('positive', 'negative')
])
// console.log(JSON.stringify(root, null, 2))
assert.equal(root.op, op['=>'])
assert.equal(root.rhs.op, op['?'])
assert.equal(root.rhs.lhs.op, op['>'])
assert.equal(root.rhs.rhs.yes, 'positive')
assert.equal(root.rhs.rhs.no, 'negative')
assert.equal(root.get_value()(), 'negative')
})
})

describe('Node', function () {
Expand Down
64 changes: 56 additions & 8 deletions packages/utils.parser/spec/parserBehaviors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,15 @@ import {
} from '../dist';

function ctxStub (ctx) {
return { lookup (v) { return ctx ? ctx[v] : null } }
return {
lookup (v) { return ctx ? ctx[v] : null },
extend (vals) {
return ctxStub(Object.assign({}, ctx, vals))
},
toString () {
return JSON.stringify(ctx, null, 2)
}
}
}

function makeBindings (binding, context) {
Expand Down Expand Up @@ -508,19 +516,49 @@ describe('unary operations', function () {
assert.equal(obs(), 146)
})

// Noting #65.
it.skip('exposes arguments', () => {
const binding = 'x: (z) => 1 + z'
it('exposes single argument', () => {
const binding = 'x: z => 1 + z'
const context = {}
const bindings = makeBindings(binding, context)
assert.equal(typeof bindings.x, 'function', 'binding should be a function')
assert.equal(typeof bindings.x(), 'function', `binding value ${bindings.x()} should be a function`)
assert.equal(bindings.x()(941), 942)
})

it.skip('exposes multiple arguments', () => {
danieldickison marked this conversation as resolved.
Show resolved Hide resolved
const binding = 'x: (a,b,c) => a * b * c'
it('exposes multiple arguments', () => {
const binding = 'x: (a,b, c) => a * b / c'
const context = {}
const bindings = makeBindings(binding, context)
assert.equal(bindings.x()(4, 3, 2), 6)
})

it('fails on malformed arguments', () => {
const binding = 'x: (a, b + c) => a * b / c'
const context = {}
assert.throws(() => makeBindings(binding, context), "only simple identifiers allowed")
})

it('can call methods on args', () => {
const binding = 'x: s => s.toUpperCase()'
const context = {}
const bindings = makeBindings(binding, context)
assert.equal(bindings.x()('foo'), 'FOO')
})

it('can have a ternary operator as body', () => {
const binding = 'x: n => n > 0 ? "positive" : "negative"'
const context = {}
const bindings = makeBindings(binding, context)
assert.equal(bindings.x()(1, 2, 3), 6)
assert.equal(bindings.x()(1), 'positive')
assert.equal(bindings.x()(-1), 'negative')
})

it('can have ternary followed by other bindings', () => {
const binding = 'x: n => n > 0 ? "positive" : "negative", y: "foo"'
const context = {}
const bindings = makeBindings(binding, context)
assert.equal(bindings.x()(1), 'positive')
assert.equal(bindings.y(), 'foo')
})
})

Expand Down Expand Up @@ -624,6 +662,16 @@ describe('unary operations', function () {
obs(false);
assert.equal(bindings.x(), 'string!a');
})

it('can be followed by other bindings', () => {
const binding = 'x: n > 0 ? "positive" : "negative", y: n + 1'
const context = {
n: observable(-3)
}
const bindings = makeBindings(binding, context)
assert.equal(bindings.x(), 'negative')
assert.equal(bindings.y(), -2)
})
})
})

Expand Down Expand Up @@ -938,7 +986,7 @@ describe('compound expressions', function () {
function fn () {
expect_equal('u.r', undefined) // undefined
}
assert.throws(fn, 'defined')
assert.throws(fn, 'dereference')
})

it('calls function F1', function () {
Expand Down
2 changes: 2 additions & 0 deletions packages/utils.parser/src/Identifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export default class Identifier {
// fn(args)
value = value.apply(lastValue || $data, member)
lastValue = value
} else if (value === null || value === undefined) {
throw new Error(`dereference of null value in ${JSON.stringify(this, null, 2)} context: ${JSON.stringify($context, null, 2)}`)
} else {
// obj[x] or obj.x dereference. Note that obj may be a function.
lastValue = value
Expand Down
48 changes: 26 additions & 22 deletions packages/utils.parser/src/Node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,13 @@ export default class Node {
var node = this

if (node.op === LAMBDA) {
return () => node.get_leaf_value(node.rhs, context, globals, node)
return (...args) => {
let lambdaContext = context
if (node.lhs) {
lambdaContext = node.lhs.extendContext(context, args)
}
return node.get_leaf_value(node.rhs, lambdaContext, globals, node)
}
}

const lhv = node.get_leaf_value(node.lhs, context, globals, node)
Expand Down Expand Up @@ -84,29 +90,27 @@ export default class Node {
* to the left hand side, right hand side, and
* operation function.
*/
static create_root (nodes) {
var root, leaf, op, value

// Prime the leaf = root node.
leaf = root = new Node(nodes.shift(), nodes.shift(), nodes.shift())

while (true) {
op = nodes.shift()
value = nodes.shift()
if (!op) {
break
}
if (op.precedence < root.op.precedence) {
// rebase
root = new Node(root, op, value)
leaf = root
} else {
leaf.rhs = new Node(leaf.rhs, op, value)
leaf = leaf.rhs
static create_root (nodes, debug=false) {
// shunting yard algorithm with output to an abstact syntax tree of Nodes
const out = []
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very minor note: out and ops are very similarly named, which might make them harder to read.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like they present fairly distinctively in context, since out has an ascender while ops has a descender, and I can't think of good ones that aren't annoyingly long, but I'm open to other naming ideas!

const ops = []
for (let i = 0; i < nodes.length; i += 2) {
out.push(nodes[i]) // next value
const op = nodes[i+1]

// only left-associative operators are currently defined and handled here
const prec = op?.precedence || 0 // no op for last value
while (ops.length && prec <= ops[ops.length-1].precedence) {
danieldickison marked this conversation as resolved.
Show resolved Hide resolved
const rhs = out.pop()
const lhs = out.pop()
out.push(new Node(lhs, ops.pop(), rhs))
}
ops.push(op)
}
if (out.length !== 1) {
throw new Error(`unexpected nodes remain in shunting yard output stack: ${out}`)
}
// console.log('tree', root)
return root
return out[0]
}
}

Expand Down
58 changes: 58 additions & 0 deletions packages/utils.parser/src/Parameters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import operators from './operators'
import Node from './Node'
import Expression from './Expression'
import Identifier from './Identifier'

export default class Parameters {
constructor (parser, node) {
// convert a node of comma-separated Identifiers to Parameters
if (node instanceof Expression) {
node = node.root
}
try {
this.names = Parameters.nodeTreeToNames(node)
} catch (e) {
parser.error(e)
}
}

extendContext (context, args) {
if (!this.names) {
return context
} else {
const newValues = {}
this.names.forEach((name, index) => {
newValues[name] = args[index]
})
return context.extend(newValues)
}
}

get [Node.isExpressionOrIdentifierSymbol] () { return true }

static nodeTreeToNames (node) {
// left-associative series of commas produces a tree with children only on the lhs, so we can extract the leaves with a simplified depth-first traversal
const names = []
while (node) {
danieldickison marked this conversation as resolved.
Show resolved Hide resolved
if (node instanceof Identifier) {
danieldickison marked this conversation as resolved.
Show resolved Hide resolved
names.push(node.token)
node = null
} else if (this.isCommaNode(node)) {
names.push(node.rhs.token)
node = node.lhs
} else {
throw new Error(`only simple identifiers allowed in lambda parameter list but found ${JSON.stringify(node, null, 2)}`)
}
}
names.reverse()
return names
}

static isCommaNode (node) {
return (
(node instanceof Node) &&
node.op === operators[','] &&
(node.rhs instanceof Identifier)
)
}
}
Loading