@jbrowse/jexl 4.0.0 → 5.0.0

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 +295 -72
  28. package/dist/evaluator/compile.js.map +1 -1
  29. package/dist/grammar.d.ts +19 -9
  30. package/dist/grammar.js +66 -40
  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 +295 -72
  68. package/esm/evaluator/compile.js.map +1 -1
  69. package/esm/grammar.d.ts +19 -9
  70. package/esm/grammar.js +66 -39
  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 +356 -72
  92. package/src/grammar.ts +123 -42
  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
@@ -0,0 +1,148 @@
1
+ /*
2
+ * Jexl
3
+ * Copyright 2020 Tom Shawver
4
+ */
5
+
6
+ import type { GrammarFn } from './grammar.ts'
7
+ import type { JexlFunction, JexlValue } from './types.ts'
8
+
9
+ /**
10
+ * A list as these functions read it. A lone value is a list of one and a
11
+ * missing one is empty, so that a field which is sometimes a scalar and
12
+ * sometimes an array, as VCF INFO fields are, needs no special case. A Set,
13
+ * a Map or a plain object, such as samples keyed by name, gives its values.
14
+ */
15
+ function toList(value: JexlValue): JexlValue[] {
16
+ if (value == null) {
17
+ return []
18
+ }
19
+ if (Array.isArray(value)) {
20
+ return value
21
+ }
22
+ const held = value as unknown
23
+ if (held instanceof Set || held instanceof Map) {
24
+ return [...(held.values() as Iterable<JexlValue>)]
25
+ }
26
+ return isPlainObject(value) ? Object.values(value) : [value]
27
+ }
28
+
29
+ function isPlainObject(value: JexlValue): value is Record<string, JexlValue> {
30
+ if (typeof value !== 'object' || value === null) {
31
+ return false
32
+ }
33
+ const proto = Object.getPrototypeOf(value) as unknown
34
+ return proto === Object.prototype || proto === null
35
+ }
36
+
37
+ const identity = (value: JexlValue) => value
38
+
39
+ function callback(name: string, fn: JexlValue): JexlFunction {
40
+ if (fn === undefined) {
41
+ return identity
42
+ }
43
+ if (typeof fn !== 'function') {
44
+ throw new TypeError(`${name}() expects a lambda, such as x => x > 1`)
45
+ }
46
+ return fn
47
+ }
48
+
49
+ /**
50
+ * The numbers a list holds, each read through `fn` when there is one. A
51
+ * boolean counts as 1 or 0, so `mean(samples, s => s.GQ > 90)` is the
52
+ * fraction that pass; anything else that is not a number is skipped, as
53
+ * bcftools skips a missing value.
54
+ */
55
+ function numbers(name: string, list: JexlValue, fn: JexlValue) {
56
+ const read = callback(name, fn)
57
+ const out: number[] = []
58
+ for (const item of toList(list)) {
59
+ const value = read(item)
60
+ if (typeof value === 'number' && !Number.isNaN(value)) {
61
+ out.push(value)
62
+ } else if (typeof value === 'boolean') {
63
+ out.push(Number(value))
64
+ }
65
+ }
66
+ return out
67
+ }
68
+
69
+ /**
70
+ * The numbers `min` and `max` compare: a list and an optional lambda, as the
71
+ * other aggregates take, or any number of values and lists, as `Math.max`
72
+ * takes.
73
+ */
74
+ function extremes(name: string, args: JexlValue[]) {
75
+ const last = args.at(-1)
76
+ if (typeof last !== 'function') {
77
+ return numbers(name, args.flatMap(toList), undefined)
78
+ }
79
+ if (args.length > 2) {
80
+ throw new TypeError(`${name}() takes a list and a lambda, or values`)
81
+ }
82
+ return numbers(name, args[0], last)
83
+ }
84
+
85
+ function sum(values: number[]) {
86
+ let total = 0
87
+ for (const value of values) {
88
+ total += value
89
+ }
90
+ return total
91
+ }
92
+
93
+ function ascending(a: JexlValue, b: JexlValue) {
94
+ return (a as number) < (b as number)
95
+ ? -1
96
+ : (a as number) > (b as number)
97
+ ? 1
98
+ : 0
99
+ }
100
+
101
+ /** The functions every Jexl instance starts with. A host may replace any. */
102
+ export const collectionFunctions: Record<string, GrammarFn> = {
103
+ any: (list, fn) => toList(list).some(callback('any', fn)),
104
+ all: (list, fn) => toList(list).every(callback('all', fn)),
105
+ count: (list, fn) =>
106
+ fn === undefined
107
+ ? toList(list).length
108
+ : toList(list).filter(callback('count', fn)).length,
109
+ map: (list, fn) => toList(list).map(callback('map', fn)),
110
+ filter: (list, fn) => toList(list).filter(callback('filter', fn)),
111
+ find: (list, fn) => toList(list).find(callback('find', fn)),
112
+ sort: (list, fn) => {
113
+ const compare = fn === undefined ? ascending : callback('sort', fn)
114
+ return [...toList(list)].sort((a, b) => compare(a, b) as number)
115
+ },
116
+ sum: (list, fn) => sum(numbers('sum', list, fn)),
117
+ mean: (list, fn) => {
118
+ const values = numbers('mean', list, fn)
119
+ return values.length > 0 ? sum(values) / values.length : undefined
120
+ },
121
+ median: (list, fn) => {
122
+ const values = numbers('median', list, fn).sort((a, b) => a - b)
123
+ const mid = values.length >> 1
124
+ return values.length === 0
125
+ ? undefined
126
+ : values.length % 2
127
+ ? values[mid]
128
+ : (values[mid - 1]! + values[mid]!) / 2
129
+ },
130
+ min: (...args) => {
131
+ const values = extremes('min', args)
132
+ return values.length > 0 ? Math.min(...values) : undefined
133
+ },
134
+ max: (...args) => {
135
+ const values = extremes('max', args)
136
+ return values.length > 0 ? Math.max(...values) : undefined
137
+ },
138
+ reduce: (list, fn, ...initial) => {
139
+ const items = toList(list)
140
+ const step = callback('reduce', fn)
141
+ const seeded = initial.length > 0
142
+ let acc = seeded ? initial[0] : items[0]
143
+ for (let i = seeded ? 0 : 1; i < items.length; i++) {
144
+ acc = step(acc, items[i])
145
+ }
146
+ return acc
147
+ }
148
+ }
@@ -0,0 +1,262 @@
1
+ /*
2
+ * Jexl
3
+ * Copyright 2020 Tom Shawver
4
+ */
5
+
6
+ import { analyze } from './analyze.ts'
7
+ import { print } from './check.ts'
8
+
9
+ import type { PathKey } from './analyze.ts'
10
+ import type { AstNode, AstNodeUnion } from './types.ts'
11
+
12
+ export type Scalar = string | number | boolean | null
13
+
14
+ /**
15
+ * What a condition tests: a path off the row (`feature.INFO.DP`, or
16
+ * `get(feature, 'score')` through an accessor), or a host function of the row
17
+ * such as `maf(feature)` or `genotypeCount(feature, 'het')`.
18
+ */
19
+ export type Subject =
20
+ | { kind: 'path'; path: PathKey[]; node: AstNode }
21
+ | { kind: 'call'; name: string; args: Scalar[]; node: AstNode }
22
+
23
+ export type Condition =
24
+ | {
25
+ subject: Subject
26
+ op: '==' | '!=' | '<' | '<=' | '>' | '>='
27
+ value: Scalar
28
+ }
29
+ | { subject: Subject; op: '~' | '!~'; value: string }
30
+ | { subject: Subject; op: 'in' | '!in'; value: Scalar[] }
31
+ | { subject: Subject; op: 'has' | '!has'; value: string }
32
+ | { subject: Subject; op: 'set' | '!set' }
33
+
34
+ export interface ConditionOptions {
35
+ /** The variable holding the record, e.g. `feature`. */
36
+ row: string
37
+ /** Functions that read a path off the row, as {@link analyze} takes them. */
38
+ accessors?: Record<string, readonly PathKey[]>
39
+ /** Host functions of the row, e.g. `maf`, that a condition may test. */
40
+ calls?: readonly string[]
41
+ }
42
+
43
+ const COMPARE = new Set(['==', '!=', '<', '<=', '>', '>='])
44
+ const FLIP: Record<string, Condition['op']> = {
45
+ '==': '==',
46
+ '!=': '!=',
47
+ '<': '>',
48
+ '<=': '>=',
49
+ '>': '<',
50
+ '>=': '<='
51
+ }
52
+
53
+ function scalar(ast: AstNode): { value: Scalar } | undefined {
54
+ const node = ast as AstNodeUnion
55
+ if (node.type === 'Literal') {
56
+ return { value: node.value }
57
+ }
58
+ return node.type === 'TemplateLiteral' &&
59
+ node.parts.every((part) => part.type === 'static')
60
+ ? { value: node.parts.map((part) => part.value).join('') }
61
+ : undefined
62
+ }
63
+
64
+ function isRow(ast: AstNode | undefined, row: string) {
65
+ const node = ast as AstNodeUnion | undefined
66
+ return node?.type === 'Identifier' && !node.from && node.value === row
67
+ }
68
+
69
+ function subjectOf(ast: AstNode, opts: ConditionOptions): Subject | undefined {
70
+ const node = ast as AstNodeUnion
71
+ if (
72
+ node.type === 'FunctionCall' &&
73
+ opts.calls?.includes(node.name) &&
74
+ isRow(node.args[0], opts.row)
75
+ ) {
76
+ const rest = node.args.slice(1).map(scalar)
77
+ return rest.every(Boolean)
78
+ ? {
79
+ kind: 'call',
80
+ name: node.name,
81
+ args: rest.map((arg) => arg!.value),
82
+ node
83
+ }
84
+ : undefined
85
+ }
86
+ const accessor =
87
+ node.type === 'FunctionCall' &&
88
+ Object.hasOwn(opts.accessors ?? {}, node.name)
89
+ if (
90
+ node.type !== 'Identifier' &&
91
+ node.type !== 'FilterExpression' &&
92
+ !accessor
93
+ ) {
94
+ return undefined
95
+ }
96
+ const read = analyze(node, { row: opts.row, accessors: opts.accessors })
97
+ const [first] = read.reads
98
+ const literalAccess =
99
+ accessor &&
100
+ read.reads.length === 1 &&
101
+ read.calls.length === 1 &&
102
+ read.calls[0]!.args.slice(1).every((arg) => arg.type === 'literal')
103
+ return (read.bare || literalAccess) &&
104
+ first?.root === opts.row &&
105
+ !first.dynamic &&
106
+ first.path.length > 0
107
+ ? { kind: 'path', path: first.path, node }
108
+ : undefined
109
+ }
110
+
111
+ function condition(
112
+ ast: AstNode,
113
+ opts: ConditionOptions
114
+ ): Condition | undefined {
115
+ const node = ast as AstNodeUnion
116
+ if (node.type === 'UnaryExpression' && node.operator === '!') {
117
+ const inner = condition(node.right!, opts)
118
+ // only forms whose negation reads as a condition of its own; `!(QUAL > 30)`
119
+ // holds where QUAL is missing, which `QUAL <= 30` does not
120
+ return inner?.op === 'in' || inner?.op === 'has' || inner?.op === 'set'
121
+ ? ({ ...inner, op: `!${inner.op}` } as Condition)
122
+ : undefined
123
+ }
124
+ if (node.type !== 'BinaryExpression') {
125
+ const subject = subjectOf(node, opts)
126
+ return subject && { subject, op: 'set' }
127
+ }
128
+ const { operator, left } = node
129
+ const right = node.right!
130
+ if (COMPARE.has(operator)) {
131
+ const subject = subjectOf(left, opts)
132
+ const value = scalar(right)
133
+ if (subject && value) {
134
+ return { subject, op: operator as '==', value: value.value }
135
+ }
136
+ const flipped = subjectOf(right, opts)
137
+ const leftValue = scalar(left)
138
+ return flipped && leftValue
139
+ ? {
140
+ subject: flipped,
141
+ op: FLIP[operator] as '==',
142
+ value: leftValue.value
143
+ }
144
+ : undefined
145
+ }
146
+ if (operator === '~' || operator === '!~') {
147
+ const subject = subjectOf(left, opts)
148
+ const value = scalar(right)
149
+ return subject && typeof value?.value === 'string'
150
+ ? { subject, op: operator, value: value.value }
151
+ : undefined
152
+ }
153
+ if (operator === 'in') {
154
+ const subject = subjectOf(left, opts)
155
+ const list = right as AstNodeUnion
156
+ if (subject && list.type === 'ArrayLiteral') {
157
+ const values = list.value.map(scalar)
158
+ return values.length > 0 && values.every(Boolean)
159
+ ? { subject, op: 'in', value: values.map((value) => value!.value) }
160
+ : undefined
161
+ }
162
+ const key = scalar(left)
163
+ const container = subjectOf(right, opts)
164
+ return container && typeof key?.value === 'string'
165
+ ? { subject: container, op: 'has', value: key.value }
166
+ : undefined
167
+ }
168
+ return undefined
169
+ }
170
+
171
+ /**
172
+ * Reads an expression as conditions all of which must hold — `a && b && …`,
173
+ * each comparing a field of the row with a literal — or answers undefined when
174
+ * any part is something else. A filter editor shows the conditions as rows and
175
+ * keeps anything undefined as text, so a line it cannot read is never
176
+ * misread.
177
+ */
178
+ export function conditions(
179
+ ast: AstNode | null,
180
+ opts: ConditionOptions
181
+ ): Condition[] | undefined {
182
+ const node = ast as AstNodeUnion | null
183
+ if (!node) {
184
+ return undefined
185
+ }
186
+ if (node.type === 'BinaryExpression' && node.operator === '&&') {
187
+ const left = conditions(node.left, opts)
188
+ const right = left && conditions(node.right!, opts)
189
+ return left && right && [...left, ...right]
190
+ }
191
+ const found = condition(node, opts)
192
+ return found && [found]
193
+ }
194
+
195
+ const literal = (value: Scalar) =>
196
+ print({ type: 'Literal', value } as AstNodeUnion)
197
+
198
+ /** A subject reading `path` off the row, for a condition built from a picker. */
199
+ export function pathSubject(row: string, path: PathKey[]): Subject {
200
+ let node: AstNode = { type: 'Identifier', value: row } as AstNodeUnion
201
+ for (const key of path) {
202
+ node =
203
+ typeof key === 'string'
204
+ ? ({ type: 'Identifier', value: key, from: node } as AstNodeUnion)
205
+ : ({
206
+ type: 'FilterExpression',
207
+ subject: node,
208
+ expr: { type: 'Literal', value: key }
209
+ } as AstNodeUnion)
210
+ }
211
+ return { kind: 'path', path, node }
212
+ }
213
+
214
+ /** A subject calling a host function of the row, such as `maf(feature)`. */
215
+ export function callSubject(
216
+ row: string,
217
+ name: string,
218
+ args: Scalar[] = []
219
+ ): Subject {
220
+ const node = {
221
+ type: 'FunctionCall',
222
+ name,
223
+ args: [
224
+ { type: 'Identifier', value: row },
225
+ ...args.map((value) => ({ type: 'Literal', value }))
226
+ ]
227
+ } as AstNodeUnion
228
+ return { kind: 'call', name, args, node }
229
+ }
230
+
231
+ /** Writes one condition as expression text that {@link conditions} reads back. */
232
+ export function printCondition(c: Condition) {
233
+ const subject = print(c.subject.node)
234
+ switch (c.op) {
235
+ case 'set': {
236
+ return subject
237
+ }
238
+ case '!set': {
239
+ return `!${subject}`
240
+ }
241
+ case 'has': {
242
+ return `${literal(c.value)} in ${subject}`
243
+ }
244
+ case '!has': {
245
+ return `!(${literal(c.value)} in ${subject})`
246
+ }
247
+ case 'in': {
248
+ return `${subject} in [${c.value.map(literal).join(', ')}]`
249
+ }
250
+ case '!in': {
251
+ return `!(${subject} in [${c.value.map(literal).join(', ')}])`
252
+ }
253
+ default: {
254
+ return `${subject} ${c.op} ${literal(c.value)}`
255
+ }
256
+ }
257
+ }
258
+
259
+ /** Writes conditions as one expression requiring all of them. */
260
+ export function fromConditions(list: readonly Condition[]) {
261
+ return list.map(printCondition).join(' && ')
262
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,15 @@
1
+ /*
2
+ * Jexl
3
+ * Copyright 2020 Tom Shawver
4
+ */
5
+
6
+ /** A malformed expression. `offset` is where in the source it went wrong. */
7
+ export class JexlSyntaxError extends Error {
8
+ offset: number
9
+
10
+ constructor(message: string, offset: number) {
11
+ super(message)
12
+ this.name = 'JexlSyntaxError'
13
+ this.offset = offset
14
+ }
15
+ }