-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathemParser.ts
More file actions
223 lines (202 loc) · 7.66 KB
/
Copy pathemParser.ts
File metadata and controls
223 lines (202 loc) · 7.66 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
/**
* Parse the (bounded) LimeSurvey Expression Manager dialect the forward
* transpiler emits (`src/converters/xpathTranspiler.ts`) and serialize it back
* to XPath. This is NOT a general EM parser — it covers exactly the operators
* and functions `transpile()` produces, and throws on anything else, so an
* unsupported construct is rejected rather than silently mistranslated (same
* philosophy as `validateLstsvSubset`).
*
* Forward dialect → XPath, inverted here:
* - `==`/`!=`/`<=`/`>=`/`<`/`>` → `=`/`!=`/`<=`/`>=`/`<`/`>`
* - `and`/`or` → `and`/`or`
* - `!(expr)` → `not(expr)`
* - `+ - * /` → `+ - * div`; `%` → `mod`
* - function renames: floor→floor, ceil→ceiling, round→round, sum→sum,
* substr→substring, strlen→string-length, startsWith→starts-with,
* endsWith→ends-with, trim→normalize-space, regexMatch→regex,
* contains/count/if/today/now → identical
* - a bare field name → `${name}`
* - `(name.NAOK=='code')` → `selected(${name}, 'code')` (select_one)
* - `(name_code.NAOK=='Y')` → `selected(${name}, 'code')` (select_multiple)
*
* NOT reversed (forward collapses these onto the same output, ambiguously):
* - `+` used for string `concat()` vs numeric addition — always emitted as
* XPath `+` (arithmetic); `concat()` is not reconstructed.
*/
// ── Tokenizer ─────────────────────────────────────────────────────────────
type TokenType = 'num' | 'str' | 'ident' | 'op' | 'lparen' | 'rparen' | 'comma';
interface Token {
type: TokenType;
value: string;
}
const MULTI_CHAR_OPS = ['==', '!=', '<=', '>='];
const SINGLE_CHAR_OPS = ['<', '>', '+', '-', '*', '/', '%', '!'];
function tokenize(src: string): Token[] {
const tokens: Token[] = [];
let i = 0;
while (i < src.length) {
const { token, next } = scanAt(src, i);
if (token) tokens.push(token);
i = next;
}
return tokens;
}
/** Identify what kind of token starts at `i` and return it (or `null` for trivia). */
function scanAt(src: string, i: number): { token: Token | null; next: number } {
const c = src[i];
if (/\s/.test(c)) return { token: null, next: i + 1 };
if (c === '(') return { token: { type: 'lparen', value: c }, next: i + 1 };
if (c === ')') return { token: { type: 'rparen', value: c }, next: i + 1 };
if (c === ',') return { token: { type: 'comma', value: c }, next: i + 1 };
if (c === "'" || c === '"') return scanString(src, i, c);
if (/[0-9]/.test(c)) return scanNumber(src, i);
if (/[A-Za-z_]/.test(c)) return scanIdent(src, i);
return scanOperator(src, i);
}
/** Quoted string literal starting at `i` with the given `quote` char. */
function scanString(
src: string,
i: number,
quote: string,
): { token: Token; next: number } {
let j = i + 1;
while (j < src.length && src[j] !== quote) j++;
if (j >= src.length) {
throw new Error(`unterminated string literal in: ${src}`);
}
return { token: { type: 'str', value: src.slice(i + 1, j) }, next: j + 1 };
}
/** Numeric literal (digits + at most one dot) starting at `i`. */
function scanNumber(src: string, i: number): { token: Token; next: number } {
let j = i;
while (j < src.length && /[0-9.]/.test(src[j])) j++;
return { token: { type: 'num', value: src.slice(i, j) }, next: j };
}
/** Identifier (`[A-Za-z_][A-Za-z0-9_.]*`) — possibly `.NAOK` suffixed. */
function scanIdent(src: string, i: number): { token: Token; next: number } {
let j = i;
while (j < src.length && /[A-Za-z0-9_.]/.test(src[j])) j++;
return { token: { type: 'ident', value: src.slice(i, j) }, next: j };
}
/** Two-char operator if present, else single-char. Unknowns throw. */
function scanOperator(src: string, i: number): { token: Token; next: number } {
const two = src.slice(i, i + 2);
if (MULTI_CHAR_OPS.includes(two)) {
return { token: { type: 'op', value: two }, next: i + 2 };
}
if (SINGLE_CHAR_OPS.includes(src[i])) {
return { token: { type: 'op', value: src[i] }, next: i + 1 };
}
throw new Error(`unsupported character "${src[i]}" in expression: ${src}`);
}
// ── AST ───────────────────────────────────────────────────────────────────
export type EmNode =
| { t: 'num'; v: string }
| { t: 'str'; v: string }
| { t: 'ident'; name: string; naok: boolean }
| { t: 'call'; name: string; args: EmNode[] }
| { t: 'unary'; op: '!'; arg: EmNode }
| { t: 'bin'; op: string; left: EmNode; right: EmNode };
// Precedence climbing: or(1) < and(2) < comparisons(3) < +-(4) < */%(5).
const BINARY_PRECEDENCE: Record<string, number> = {
or: 1,
and: 2,
'==': 3,
'!=': 3,
'<=': 3,
'>=': 3,
'<': 3,
'>': 3,
'+': 4,
'-': 4,
'*': 5,
'/': 5,
'%': 5,
};
class Parser {
private pos = 0;
constructor(private tokens: Token[]) {}
private peek(): Token | undefined {
return this.tokens[this.pos];
}
private next(): Token {
const tok = this.tokens[this.pos];
if (!tok) throw new Error('unexpected end of expression');
this.pos++;
return tok;
}
private expect(type: TokenType): Token {
const tok = this.next();
if (tok.type !== type) {
throw new Error(`expected ${type}, got "${tok.value}"`);
}
return tok;
}
parse(): EmNode {
const node = this.parseBinary(1);
if (this.pos < this.tokens.length) {
throw new Error(`unexpected trailing token "${this.peek()!.value}"`);
}
return node;
}
private parseBinary(minPrec: number): EmNode {
let left = this.parseUnary();
for (;;) {
const tok = this.peek();
if (!tok) break;
const opName = tok.type === 'ident' ? tok.value : tok.value;
if (tok.type === 'ident' && opName !== 'and' && opName !== 'or') break;
if (tok.type !== 'op' && tok.type !== 'ident') break;
const prec = BINARY_PRECEDENCE[opName];
if (prec === undefined || prec < minPrec) break;
this.next();
const right = this.parseBinary(prec + 1);
left = { t: 'bin', op: opName, left, right };
}
return left;
}
private parseUnary(): EmNode {
const tok = this.peek();
if (tok?.type === 'op' && tok.value === '!') {
this.next();
this.expect('lparen');
const arg = this.parseBinary(1);
this.expect('rparen');
return { t: 'unary', op: '!', arg };
}
return this.parsePrimary();
}
private parsePrimary(): EmNode {
const tok = this.next();
if (tok.type === 'num') return { t: 'num', v: tok.value };
if (tok.type === 'str') return { t: 'str', v: tok.value };
if (tok.type === 'lparen') {
const inner = this.parseBinary(1);
this.expect('rparen');
return inner;
}
if (tok.type === 'ident') {
if (this.peek()?.type === 'lparen') {
this.next();
const args: EmNode[] = [];
if (this.peek()?.type !== 'rparen') {
args.push(this.parseBinary(1));
while (this.peek()?.type === 'comma') {
this.next();
args.push(this.parseBinary(1));
}
}
this.expect('rparen');
return { t: 'call', name: tok.value, args };
}
const naok = tok.value.endsWith('.NAOK');
const name = naok ? tok.value.slice(0, -'.NAOK'.length) : tok.value;
return { t: 'ident', name, naok };
}
throw new Error(`unexpected token "${tok.value}"`);
}
}
/** Parse an Expression Manager string into an {@link EmNode} AST. */
export function parseEm(src: string): EmNode {
return new Parser(tokenize(src)).parse();
}