@jbrowse/jexl 4.0.1 → 5.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +163 -20
  2. package/dist/Expression.d.ts +16 -0
  3. package/dist/Expression.js +28 -4
  4. package/dist/Expression.js.map +1 -1
  5. package/dist/Jexl.d.ts +20 -8
  6. package/dist/Jexl.js +34 -15
  7. package/dist/Jexl.js.map +1 -1
  8. package/dist/Lexer.d.ts +4 -3
  9. package/dist/Lexer.js +29 -21
  10. package/dist/Lexer.js.map +1 -1
  11. package/dist/analyze.d.ts +84 -0
  12. package/dist/analyze.js +259 -0
  13. package/dist/analyze.js.map +1 -0
  14. package/dist/check.d.ts +109 -0
  15. package/dist/check.js +1267 -0
  16. package/dist/check.js.map +1 -0
  17. package/dist/collections.d.ts +3 -0
  18. package/dist/collections.js +140 -0
  19. package/dist/collections.js.map +1 -0
  20. package/dist/conditions.d.ts +62 -0
  21. package/dist/conditions.js +207 -0
  22. package/dist/conditions.js.map +1 -0
  23. package/dist/errors.d.ts +5 -0
  24. package/dist/errors.js +18 -0
  25. package/dist/errors.js.map +1 -0
  26. package/dist/evaluator/compile.d.ts +10 -7
  27. package/dist/evaluator/compile.js +292 -99
  28. package/dist/evaluator/compile.js.map +1 -1
  29. package/dist/grammar.d.ts +22 -9
  30. package/dist/grammar.js +83 -55
  31. package/dist/grammar.js.map +1 -1
  32. package/dist/index.d.ts +9 -0
  33. package/dist/index.js +16 -1
  34. package/dist/index.js.map +1 -1
  35. package/dist/operators.d.ts +34 -0
  36. package/dist/operators.js +131 -0
  37. package/dist/operators.js.map +1 -0
  38. package/dist/parser/Parser.d.ts +48 -91
  39. package/dist/parser/Parser.js +347 -221
  40. package/dist/parser/Parser.js.map +1 -1
  41. package/dist/types.d.ts +11 -5
  42. package/esm/Expression.d.ts +16 -0
  43. package/esm/Expression.js +28 -4
  44. package/esm/Expression.js.map +1 -1
  45. package/esm/Jexl.d.ts +20 -8
  46. package/esm/Jexl.js +34 -15
  47. package/esm/Jexl.js.map +1 -1
  48. package/esm/Lexer.d.ts +4 -3
  49. package/esm/Lexer.js +29 -21
  50. package/esm/Lexer.js.map +1 -1
  51. package/esm/analyze.d.ts +84 -0
  52. package/esm/analyze.js +256 -0
  53. package/esm/analyze.js.map +1 -0
  54. package/esm/check.d.ts +109 -0
  55. package/esm/check.js +1260 -0
  56. package/esm/check.js.map +1 -0
  57. package/esm/collections.d.ts +3 -0
  58. package/esm/collections.js +137 -0
  59. package/esm/collections.js.map +1 -0
  60. package/esm/conditions.d.ts +62 -0
  61. package/esm/conditions.js +200 -0
  62. package/esm/conditions.js.map +1 -0
  63. package/esm/errors.d.ts +5 -0
  64. package/esm/errors.js +14 -0
  65. package/esm/errors.js.map +1 -0
  66. package/esm/evaluator/compile.d.ts +10 -7
  67. package/esm/evaluator/compile.js +292 -99
  68. package/esm/evaluator/compile.js.map +1 -1
  69. package/esm/grammar.d.ts +22 -9
  70. package/esm/grammar.js +79 -53
  71. package/esm/grammar.js.map +1 -1
  72. package/esm/index.d.ts +9 -0
  73. package/esm/index.js +4 -0
  74. package/esm/index.js.map +1 -1
  75. package/esm/operators.d.ts +34 -0
  76. package/esm/operators.js +123 -0
  77. package/esm/operators.js.map +1 -0
  78. package/esm/parser/Parser.d.ts +48 -91
  79. package/esm/parser/Parser.js +349 -192
  80. package/esm/parser/Parser.js.map +1 -1
  81. package/esm/types.d.ts +11 -5
  82. package/package.json +1 -1
  83. package/src/Expression.ts +34 -4
  84. package/src/Jexl.ts +52 -16
  85. package/src/Lexer.ts +32 -21
  86. package/src/analyze.ts +378 -0
  87. package/src/check.ts +1682 -0
  88. package/src/collections.ts +148 -0
  89. package/src/conditions.ts +262 -0
  90. package/src/errors.ts +15 -0
  91. package/src/evaluator/compile.ts +351 -102
  92. package/src/grammar.ts +137 -55
  93. package/src/index.ts +15 -0
  94. package/src/operators.ts +151 -0
  95. package/src/parser/Parser.ts +389 -199
  96. package/src/types.ts +13 -3
  97. package/dist/parser/handlers.d.ts +0 -115
  98. package/dist/parser/handlers.js +0 -352
  99. package/dist/parser/handlers.js.map +0 -1
  100. package/dist/parser/states.d.ts +0 -47
  101. package/dist/parser/states.js +0 -176
  102. package/dist/parser/states.js.map +0 -1
  103. package/esm/parser/handlers.d.ts +0 -115
  104. package/esm/parser/handlers.js +0 -328
  105. package/esm/parser/handlers.js.map +0 -1
  106. package/esm/parser/states.d.ts +0 -47
  107. package/esm/parser/states.js +0 -140
  108. package/esm/parser/states.js.map +0 -1
  109. package/src/parser/handlers.ts +0 -372
  110. package/src/parser/states.ts +0 -159
package/esm/types.d.ts CHANGED
@@ -4,20 +4,21 @@ export interface TemplatePart {
4
4
  }
5
5
  export type JexlValue = string | number | boolean | null | undefined | JexlValue[] | {
6
6
  [key: string]: JexlValue;
7
- };
7
+ } | JexlFunction;
8
+ /** What a lambda evaluates to, for a registered function to call. */
9
+ export type JexlFunction = (...args: JexlValue[]) => JexlValue;
8
10
  export interface Token {
9
11
  type: string;
10
- value: string | number | boolean | TemplatePart[];
12
+ value: string | number | boolean | null | TemplatePart[];
11
13
  raw: string;
12
14
  }
13
15
  export interface AstNode {
14
16
  type: string;
15
- _parent?: AstNode;
16
17
  right?: AstNode;
17
18
  }
18
19
  export interface Literal extends AstNode {
19
20
  type: 'Literal';
20
- value: string | number | boolean;
21
+ value: string | number | boolean | null;
21
22
  }
22
23
  export interface Identifier extends AstNode {
23
24
  type: 'Identifier';
@@ -79,7 +80,12 @@ export interface AssignmentExpression extends AstNode {
79
80
  operator: '=';
80
81
  left: Identifier;
81
82
  }
82
- export type AstNodeUnion = Literal | Identifier | BinaryExpression | UnaryExpression | ArrayLiteral | ObjectLiteral | TemplateLiteral | FunctionCall | FilterExpression | ConditionalExpression | SequenceExpression | AssignmentExpression;
83
+ export interface Lambda extends AstNode {
84
+ type: 'Lambda';
85
+ params: string[];
86
+ body: AstNode;
87
+ }
88
+ export type AstNodeUnion = Literal | Identifier | BinaryExpression | UnaryExpression | ArrayLiteral | ObjectLiteral | TemplateLiteral | FunctionCall | FilterExpression | ConditionalExpression | SequenceExpression | AssignmentExpression | Lambda;
83
89
  export type NodeByType<T extends AstNodeUnion['type']> = Extract<AstNodeUnion, {
84
90
  type: T;
85
91
  }>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jbrowse/jexl",
3
- "version": "4.0.1",
3
+ "version": "5.0.1",
4
4
  "description": "A fork of the jexl lang for jbrowse",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/src/Expression.ts CHANGED
@@ -4,9 +4,13 @@
4
4
  */
5
5
 
6
6
  import Lexer from './Lexer.ts'
7
+ import { analyze } from './analyze.ts'
8
+ import { check } from './check.ts'
7
9
  import { compileAst } from './evaluator/compile.ts'
8
10
  import Parser from './parser/Parser.ts'
9
11
 
12
+ import type { AnalyzeOptions } from './analyze.ts'
13
+ import type { CheckOptions } from './check.ts'
10
14
  import type { CompiledNode } from './evaluator/compile.ts'
11
15
  import type { Grammar } from './grammar.ts'
12
16
  import type { AstNode } from './types.ts'
@@ -45,10 +49,7 @@ class Expression {
45
49
  * @returns {Expression} this Expression instance, for convenience
46
50
  */
47
51
  compile() {
48
- const parser = new Parser(this._grammar, this._lexer)
49
- const tokens = this._lexer.tokenize(this._exprStr)
50
- parser.addTokens(tokens)
51
- this._ast = parser.complete()
52
+ this._ast = new Parser(this._grammar, this._lexer).parse(this._exprStr)
52
53
  // lower the tree to closures once, here, so that eval() is just a call
53
54
  this._fn = this._ast ? compileAst(this._ast, this._grammar) : null
54
55
  this._compiled = true
@@ -72,6 +73,35 @@ class Expression {
72
73
  }
73
74
  return this._fn(context)
74
75
  }
76
+
77
+ /** The expression text this Expression was made from. */
78
+ get source() {
79
+ return this._exprStr
80
+ }
81
+
82
+ /** The parsed tree, or null for an expression with no tokens. */
83
+ get ast() {
84
+ if (!this._compiled) {
85
+ this.compile()
86
+ }
87
+ return this._ast
88
+ }
89
+
90
+ /**
91
+ * Lists what the expression reads from its context, without evaluating it.
92
+ * See {@link analyze}.
93
+ */
94
+ analyze(options?: AnalyzeOptions) {
95
+ return analyze(this.ast, options)
96
+ }
97
+
98
+ /**
99
+ * Checks the expression against a host's fields and functions, without
100
+ * evaluating it. See {@link check}.
101
+ */
102
+ check(options?: CheckOptions) {
103
+ return check(this.ast, options)
104
+ }
75
105
  }
76
106
 
77
107
  export default Expression
package/src/Jexl.ts CHANGED
@@ -10,14 +10,29 @@ import { getGrammar } from './grammar.ts'
10
10
  import type {
11
11
  BinaryOpEval,
12
12
  BinaryOpEvalOnDemand,
13
+ GetMember,
13
14
  Grammar,
14
15
  GrammarElement,
15
16
  GrammarFn,
16
17
  UnaryOpEval,
17
- UncheckedFn
18
+ UncheckedFn,
19
+ VariableReader
18
20
  } from './grammar.ts'
19
21
  import type { JexlValue } from './types.ts'
20
22
 
23
+ /**
24
+ * Hooks for a host whose values keep their fields behind an accessor. Both are
25
+ * fixed at construction, since a compiled expression binds them.
26
+ */
27
+ export interface JexlOptions {
28
+ /** Resolves `a.b` and `a[k]`. */
29
+ getMember?: GetMember
30
+ /** Resolves a bare name, such as a field of the current row. */
31
+ variableReader?: VariableReader
32
+ }
33
+
34
+ const MAX_COMPILED = 10_000
35
+
21
36
  /**
22
37
  * Jexl is the Javascript Expression Language, capable of parsing and
23
38
  * evaluating basic to complex expression strings, combined with advanced
@@ -29,9 +44,10 @@ class Jexl {
29
44
  // shared by every Expression this instance creates, so that the expensive
30
45
  // element-splitting regex is built once rather than per compile
31
46
  _lexer: Lexer
47
+ _compiled = new Map<string, Expression>()
32
48
 
33
- constructor() {
34
- this._grammar = getGrammar()
49
+ constructor({ getMember, variableReader }: JexlOptions = {}) {
50
+ this._grammar = { ...getGrammar(), getMember, variableReader }
35
51
  this._lexer = new Lexer(this._grammar)
36
52
  this.expr = this.expr.bind(this)
37
53
  }
@@ -73,6 +89,10 @@ class Jexl {
73
89
  fn: UncheckedFn | BinaryOpEvalOnDemand,
74
90
  manualEval?: boolean
75
91
  ) {
92
+ // replacing `-` keeps its prefix form, which is a separate operation
93
+ const previous = this._grammar.elements[operator]
94
+ const unaryEval =
95
+ previous?.type === 'binaryOp' ? previous.unaryEval : undefined
76
96
  // the overloads above pair `fn` with `manualEval`; the implementation
77
97
  // signature can't express that correlation, hence the assertions
78
98
  this._addGrammarElement(
@@ -81,12 +101,14 @@ class Jexl {
81
101
  ? {
82
102
  type: 'binaryOp',
83
103
  precedence,
84
- evalOnDemand: fn as BinaryOpEvalOnDemand
104
+ evalOnDemand: fn as BinaryOpEvalOnDemand,
105
+ unaryEval
85
106
  }
86
107
  : {
87
108
  type: 'binaryOp',
88
109
  precedence,
89
- eval: fn as unknown as BinaryOpEval
110
+ eval: fn as unknown as BinaryOpEval,
111
+ unaryEval
90
112
  }
91
113
  )
92
114
  }
@@ -132,16 +154,21 @@ class Jexl {
132
154
  }
133
155
 
134
156
  /**
135
- * Creates an Expression object from the given Jexl expression string, and
136
- * immediately compiles it. The returned Expression object can then be
137
- * evaluated multiple times with new contexts, without generating any
138
- * additional string processing overhead.
139
- * @param {string} expression The Jexl expression to be compiled
140
- * @returns {Expression} The compiled Expression object
157
+ * Compiles an expression string, or returns the Expression this instance
158
+ * already compiled from the same string. Adding or removing an operator
159
+ * empties the cache, since a compiled expression binds its operators;
160
+ * functions are looked up per call and leave it alone.
141
161
  */
142
162
  compile(expression: string) {
143
- const exprObj = this.createExpression(expression)
144
- return exprObj.compile()
163
+ let compiled = this._compiled.get(expression)
164
+ if (!compiled) {
165
+ compiled = this.createExpression(expression).compile()
166
+ if (this._compiled.size >= MAX_COMPILED) {
167
+ this._compiled.delete(this._compiled.keys().next().value!)
168
+ }
169
+ this._compiled.set(expression, compiled)
170
+ }
171
+ return compiled
145
172
  }
146
173
 
147
174
  /**
@@ -154,6 +181,11 @@ class Jexl {
154
181
  return new Expression(this._grammar, expression, this._lexer)
155
182
  }
156
183
 
184
+ /** Parses an expression string, or throws a JexlSyntaxError. */
185
+ parse(expression: string) {
186
+ return this.compile(expression).ast
187
+ }
188
+
157
189
  /**
158
190
  * Retrieves a previously set expression function.
159
191
  * @param {string} name The name of the expression function
@@ -172,8 +204,7 @@ class Jexl {
172
204
  * @throws {*} on error
173
205
  */
174
206
  eval(expression: string, context = {}) {
175
- const exprObj = this.createExpression(expression)
176
- return exprObj.eval(context)
207
+ return this.compile(expression).eval(context)
177
208
  }
178
209
 
179
210
  /**
@@ -210,7 +241,7 @@ class Jexl {
210
241
  const elem = this._grammar.elements[operator]
211
242
  if (elem?.type === 'binaryOp' || elem?.type === 'unaryOp') {
212
243
  Reflect.deleteProperty(this._grammar.elements, operator)
213
- this._lexer._clearCache()
244
+ this._grammarChanged()
214
245
  }
215
246
  }
216
247
 
@@ -223,7 +254,12 @@ class Jexl {
223
254
  */
224
255
  _addGrammarElement(str: string, obj: GrammarElement) {
225
256
  this._grammar.elements[str] = obj
257
+ this._grammarChanged()
258
+ }
259
+
260
+ _grammarChanged() {
226
261
  this._lexer._clearCache()
262
+ this._compiled.clear()
227
263
  }
228
264
  }
229
265
 
package/src/Lexer.ts CHANGED
@@ -3,6 +3,8 @@
3
3
  * Copyright 2020 Tom Shawver
4
4
  */
5
5
 
6
+ import { JexlSyntaxError } from './errors.ts'
7
+
6
8
  import type { Grammar } from './grammar.ts'
7
9
  import type { TemplatePart, Token } from './types.ts'
8
10
 
@@ -12,9 +14,13 @@ import type { TemplatePart, Token } from './types.ts'
12
14
  // disagree on some input, and each disagreement is an "Invalid expression
13
15
  // token" for something the splitter was happy to produce.
14
16
  const identChars = String.raw`a-zA-Zа-яА-Я_\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u00FF$`
15
- const identPattern = `[${identChars}][${identChars}0-9]*`
17
+ const identPart = `[${identChars}0-9]`
18
+ const identPattern = `[${identChars}]${identPart}*`
19
+ // a word operator such as `in`, but not part of a longer name. \b would do only
20
+ // for ASCII names, and split `in$` or `inà` into the operator and a remainder
21
+ const wholeWord = (word: string) => `(?<!${identPart})${word}(?!${identPart})`
16
22
  // unsigned: whether a leading '-' negates is decided separately, in getTokens
17
- const numberPattern = String.raw`(?:(?:[0-9]*\.[0-9]+)|[0-9]+)`
23
+ const numberPattern = String.raw`(?:(?:[0-9]*\.[0-9]+)|[0-9]+)(?:[eE][+-]?[0-9]+)?`
18
24
 
19
25
  const numericRegex = new RegExp(`^-?${numberPattern}$`)
20
26
  const identRegex = new RegExp(`^${identPattern}$`)
@@ -35,11 +41,10 @@ const preOpRegexElems = [
35
41
  String.raw`"(?:(?:\\")|[^"])*"`,
36
42
  // Whitespace
37
43
  String.raw`\s+`,
38
- // Booleans
39
- String.raw`\btrue\b`,
40
- String.raw`\bfalse\b`
44
+ // ahead of the grammar's '.', so that '.5' is a number rather than a dot
45
+ numberPattern
41
46
  ]
42
- const postOpRegexElems = [identPattern, numberPattern]
47
+ const postOpRegexElems = [identPattern]
43
48
  const unaryMinusToken = (): Token => ({
44
49
  type: 'unaryOp',
45
50
  value: '-',
@@ -53,7 +58,8 @@ const minusNegatesAfter = new Set([
53
58
  'question',
54
59
  'colon',
55
60
  'comma',
56
- 'semicolon'
61
+ 'semicolon',
62
+ 'arrow'
57
63
  ])
58
64
 
59
65
  /**
@@ -113,6 +119,7 @@ class Lexer {
113
119
  // 1") accumulates on the minus rather than on the token before it, which
114
120
  // is what lets the parser's error messages quote the expression verbatim.
115
121
  let pendingMinus: Token | undefined
122
+ let offset = 0
116
123
  for (const element of elements) {
117
124
  if (this._isWhitespace(element)) {
118
125
  const last = pendingMinus ?? tokens.at(-1)
@@ -129,18 +136,19 @@ class Lexer {
129
136
  } else if (pendingMinus) {
130
137
  if (numericRegex.exec(element)) {
131
138
  // fold the sign into the number, so "-1" stays a single literal
132
- const token = this._createToken('-' + element)
139
+ const token = this._createToken('-' + element, offset)
133
140
  token.raw = pendingMinus.raw + element
134
141
  tokens.push(token)
135
142
  } else {
136
143
  // anything else gets a standalone prefix operator, letting "-x",
137
144
  // "-(a + b)" and "-foo.bar" negate a computed value
138
- tokens.push(pendingMinus, this._createToken(element))
145
+ tokens.push(pendingMinus, this._createToken(element, offset))
139
146
  }
140
147
  pendingMinus = undefined
141
148
  } else {
142
- tokens.push(this._createToken(element))
149
+ tokens.push(this._createToken(element, offset))
143
150
  }
151
+ offset += element.length
144
152
  }
145
153
  // Catch a - at the end of the string. Let the parser handle that issue.
146
154
  if (pendingMinus) {
@@ -191,7 +199,7 @@ class Lexer {
191
199
  * @throws {Error} if the provided string is not a valid expression element.
192
200
  * @private
193
201
  */
194
- _createToken(element: string): Token {
202
+ _createToken(element: string, offset = 0): Token {
195
203
  const token: Token = {
196
204
  type: 'literal',
197
205
  value: element,
@@ -199,7 +207,7 @@ class Lexer {
199
207
  }
200
208
  if (element.startsWith('`')) {
201
209
  token.type = 'templateString'
202
- token.value = this._parseTemplateString(element)
210
+ token.value = this._parseTemplateString(element, offset)
203
211
  return token
204
212
  } else if (element.startsWith('"') || element.startsWith("'")) {
205
213
  token.value = this._unquote(element)
@@ -207,30 +215,30 @@ class Lexer {
207
215
  token.value = parseFloat(element)
208
216
  } else if (element === 'true' || element === 'false') {
209
217
  token.value = element === 'true'
218
+ } else if (element === 'null') {
219
+ token.value = null
210
220
  } else if (Object.hasOwn(this._grammar.elements, element)) {
211
221
  token.type = this._grammar.elements[element]!.type
212
222
  } else if (identRegex.exec(element)) {
213
223
  token.type = 'identifier'
214
224
  } else {
215
- throw new Error(`Invalid expression token: ${element}`)
225
+ throw new JexlSyntaxError(`Invalid expression token: ${element}`, offset)
216
226
  }
217
227
  return token
218
228
  }
219
229
 
220
230
  /**
221
231
  * Escapes a string so that it can be treated as a string literal within a
222
- * regular expression.
232
+ * regular expression. A word such as `in` also stops matching inside a
233
+ * longer name.
223
234
  * @param {string} str The string to be escaped
224
235
  * @returns {string} the RegExp-escaped string.
225
236
  * @see https://developer.mozilla.org/en/docs/Web/JavaScript/Guide/Regular_Expressions
226
237
  * @private
227
238
  */
228
239
  _escapeRegExp(str: string) {
229
- str = str.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)
230
- if (identRegex.exec(str)) {
231
- str = String.raw`\b` + str + String.raw`\b`
232
- }
233
- return str
240
+ const escaped = str.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)
241
+ return identRegex.exec(str) ? wholeWord(escaped) : escaped
234
242
  }
235
243
 
236
244
  /**
@@ -307,7 +315,7 @@ class Lexer {
307
315
  .replaceAll(escEscRegex, '\\')
308
316
  }
309
317
 
310
- _parseTemplateString(str: string) {
318
+ _parseTemplateString(str: string, offset = 0) {
311
319
  const parts: TemplatePart[] = []
312
320
  let current = 1
313
321
  let staticStart = 1
@@ -355,7 +363,10 @@ class Lexer {
355
363
  }
356
364
 
357
365
  if (braceDepth !== 0) {
358
- throw new Error(`Unclosed interpolation in template string: ${str}`)
366
+ throw new JexlSyntaxError(
367
+ `Unclosed interpolation in template string: ${str}`,
368
+ offset + interpStart - '${'.length
369
+ )
359
370
  }
360
371
 
361
372
  parts.push({