Skip to content

Commit 0c7f469

Browse files
committed
Multi-expression
1 parent fb541a0 commit 0c7f469

6 files changed

Lines changed: 440 additions & 424 deletions

File tree

package.json

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,16 +51,17 @@
5151
},
5252
"homepage": "https://github.com/TomFrost/jexl",
5353
"devDependencies": {
54-
"@types/node": "^25.0.3",
55-
"@vitest/coverage-v8": "^4.0.16",
56-
"eslint": "^9.39.2",
54+
"@eslint/js": "^10.0.1",
55+
"@types/node": "^25.3.3",
56+
"@vitest/coverage-v8": "^4.0.18",
57+
"eslint": "^10.0.2",
5758
"eslint-plugin-import": "^2.32.0",
58-
"eslint-plugin-unicorn": "^62.0.0",
59-
"prettier": "^3.7.4",
60-
"rimraf": "^6.1.2",
59+
"eslint-plugin-unicorn": "^63.0.0",
60+
"prettier": "^3.8.1",
61+
"rimraf": "^6.1.3",
6162
"typescript": "^5.9.3",
62-
"typescript-eslint": "^8.50.1",
63-
"vitest": "^4.0.16"
63+
"typescript-eslint": "^8.56.1",
64+
"vitest": "^4.0.18"
6465
},
6566
"publishConfig": {
6667
"access": "public"

src/Lexer.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ const minusNegatesAfter = new Set([
3434
'openParen',
3535
'openBracket',
3636
'question',
37-
'colon'
37+
'colon',
38+
'comma',
39+
'semicolon'
3840
])
3941

4042
interface Grammar {

src/parser/Parser.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,16 @@ class Parser {
8585
const stopState = this._subParser!.addToken(token)
8686
if (stopState) {
8787
this._endSubExpression()
88-
if (this._parentStop) {
88+
if (stopState === '_semicolon') {
89+
handlers.semicolon.call(this)
90+
} else if (this._parentStop) {
8991
return stopState
92+
} else {
93+
this._state = stopState
9094
}
91-
this._state = stopState
9295
}
96+
} else if (token.type === 'semicolon' && this._stopMap[token.type]) {
97+
return this._stopMap[token.type]
9398
} else if (state.tokenTypes?.[token.type]) {
9499
const typeOpts = state.tokenTypes[token.type]
95100
let handleFunc = (handlers as any)[token.type]
@@ -139,7 +144,9 @@ class Parser {
139144
}
140145

141146
if (this._sequenceExpressions) {
142-
this._sequenceExpressions.push(this._tree!)
147+
if (this._tree) {
148+
this._sequenceExpressions.push(this._tree)
149+
}
143150
const sequence: any = {
144151
type: 'SequenceExpression',
145152
expressions: this._sequenceExpressions
@@ -228,6 +235,9 @@ class Parser {
228235
this._parentStop = true
229236
endStates = this._stopMap
230237
}
238+
if (states[this._state].completable && !endStates.semicolon) {
239+
endStates = { ...endStates, semicolon: '_semicolon' }
240+
}
231241
this._subParser = new Parser(this._grammar, this._lexer, exprStr, endStates)
232242
}
233243
}

src/parser/states.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export const states: States = {
7272
binaryOp: { toState: 'expectOperand' },
7373
pipe: { toState: 'expectTransform' },
7474
dot: { toState: 'traverse' },
75+
openBracket: { toState: 'filter' },
7576
question: { toState: 'ternaryMid', handler: h.ternaryStart },
7677
semicolon: { handler: h.semicolon }
7778
},
@@ -101,7 +102,8 @@ export const states: States = {
101102
dot: { toState: 'traverse' },
102103
openBracket: { toState: 'filter' },
103104
pipe: { toState: 'expectTransform' },
104-
question: { toState: 'ternaryMid', handler: h.ternaryStart }
105+
question: { toState: 'ternaryMid', handler: h.ternaryStart },
106+
semicolon: { handler: h.semicolon }
105107
},
106108
completable: true
107109
},
@@ -111,7 +113,8 @@ export const states: States = {
111113
dot: { toState: 'traverse' },
112114
openBracket: { toState: 'filter' },
113115
pipe: { toState: 'expectTransform' },
114-
question: { toState: 'ternaryMid', handler: h.ternaryStart }
116+
question: { toState: 'ternaryMid', handler: h.ternaryStart },
117+
semicolon: { handler: h.semicolon }
115118
},
116119
completable: true
117120
},
@@ -122,7 +125,8 @@ export const states: States = {
122125
openBracket: { toState: 'filter' },
123126
openParen: { toState: 'argVal', handler: h.functionCall },
124127
pipe: { toState: 'expectTransform' },
125-
question: { toState: 'ternaryMid', handler: h.ternaryStart }
128+
question: { toState: 'ternaryMid', handler: h.ternaryStart },
129+
semicolon: { handler: h.semicolon }
126130
},
127131
completable: true
128132
},

test/lib/multi-expression.test.js

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,110 @@ describe('Multi-Expression Support', () => {
5858
})
5959
})
6060

61+
describe('Semicolon after property access', () => {
62+
it('handles semicolon after dot-accessed identifier', () => {
63+
const ctx = { obj: { val: 'hello' } }
64+
expect(jexl.eval('s = obj.val; s', ctx)).toBe('hello')
65+
})
66+
67+
it('handles semicolon after function call result property', () => {
68+
const j = new Jexl()
69+
j.addFunction('getObj', () => ({ CLNSIG: 'Benign' }))
70+
const lookup = { Benign: 'blue', Pathogenic: 'red' }
71+
expect(j.eval('s = getObj().CLNSIG; lookup[s]', { lookup })).toBe('blue')
72+
})
73+
74+
it('handles semicolon after bracket access on identifier', () => {
75+
const ctx = { arr: [10, 20, 30] }
76+
expect(jexl.eval('v = arr[1]; v + 5', ctx)).toBe(25)
77+
})
78+
})
79+
80+
describe('Real-world expressions', () => {
81+
it('handles full CLNSIG-style lookup with semicolon', () => {
82+
const j = new Jexl()
83+
j.addFunction('get', (obj, key) => obj[key])
84+
const expr =
85+
"s = get(feature,'INFO').CLNSIG; ({'Benign':'blue','Likely_benign':'deepskyblue','Uncertain_significance':'gray','Pathogenic':'red','Likely_pathogenic':'orange','Conflicting_interpretations_of_pathogenicity':'brown'})[s] || 'purple'"
86+
expect(
87+
j.eval(expr, { feature: { INFO: { CLNSIG: 'Benign' } } })
88+
).toBe('blue')
89+
expect(
90+
j.eval(expr, { feature: { INFO: { CLNSIG: 'Pathogenic' } } })
91+
).toBe('red')
92+
expect(
93+
j.eval(expr, { feature: { INFO: { CLNSIG: 'Likely_benign' } } })
94+
).toBe('deepskyblue')
95+
expect(
96+
j.eval(expr, {
97+
feature: {
98+
INFO: {
99+
CLNSIG: 'Conflicting_interpretations_of_pathogenicity'
100+
}
101+
}
102+
})
103+
).toBe('brown')
104+
expect(
105+
j.eval(expr, { feature: { INFO: { CLNSIG: 'SomethingElse' } } })
106+
).toBe('purple')
107+
})
108+
109+
it('handles CLNSIG-style lookup returning default', () => {
110+
const j = new Jexl()
111+
j.addFunction('get', (obj, key) => obj[key])
112+
const feature = {
113+
INFO: { CLNSIG: 'Unknown' }
114+
}
115+
const result = j.eval(
116+
"s = get(feature,'INFO').CLNSIG; ({'Benign':'blue','Pathogenic':'red'})[s] || 'purple'",
117+
{ feature }
118+
)
119+
expect(result).toBe('purple')
120+
})
121+
122+
it('semicolon after function call with no property access', () => {
123+
const j = new Jexl()
124+
j.addFunction('add', (a, b) => a + b)
125+
expect(j.eval('x = add(3, 4); x * 2')).toBe(14)
126+
})
127+
128+
it('semicolon after bracket access on result of parens', () => {
129+
expect(jexl.eval("s = 'a'; ({'a': 1, 'b': 2})[s]")).toBe(1)
130+
})
131+
})
132+
133+
describe('Edge cases', () => {
134+
it('handles negative numbers after semicolons', () => {
135+
expect(jexl.eval('5; -3')).toBe(-3)
136+
})
137+
138+
it('handles trailing semicolons', () => {
139+
expect(jexl.eval('5;')).toBe(5)
140+
})
141+
142+
it('handles negative numbers after commas in function args', () => {
143+
const j = new Jexl()
144+
j.addFunction('add', (a, b) => a + b)
145+
expect(j.eval('add(1, -2)')).toBe(-1)
146+
})
147+
148+
it('handles semicolon after ternary expression', () => {
149+
expect(jexl.eval('true ? 1 : 2; 99')).toBe(99)
150+
})
151+
152+
it('handles ternary with object literal still works', () => {
153+
expect(jexl.eval('true ? {a:1} : {b:2}')).toEqual({ a: 1 })
154+
})
155+
156+
it('handles ternary inside array', () => {
157+
expect(jexl.eval('[true ? 1 : 2]')).toEqual([1])
158+
})
159+
160+
it('handles ternary in parens with continuation', () => {
161+
expect(jexl.eval('(true ? 1 : 2) + 3')).toBe(4)
162+
})
163+
})
164+
61165
describe('Error Handling', () => {
62166
it('assignment to non-identifier throws error', () => {
63167
expect(() => jexl.eval('5 = 10')).toThrow(

0 commit comments

Comments
 (0)