webpipe-js 0.1.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.
- package/comprehensive_test.wp +1139 -0
- package/dist/parser.d.ts +129 -0
- package/dist/parser.js +594 -0
- package/dist/tests/parser.test.d.ts +1 -0
- package/dist/tests/parser.test.js +106 -0
- package/package.json +25 -0
- package/parser.ts +731 -0
- package/tests/parser.test.ts +119 -0
- package/tsconfig.json +18 -0
package/parser.ts
ADDED
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
export interface Program {
|
|
2
|
+
configs: Config[];
|
|
3
|
+
pipelines: NamedPipeline[];
|
|
4
|
+
variables: Variable[];
|
|
5
|
+
routes: Route[];
|
|
6
|
+
describes: Describe[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface Config {
|
|
10
|
+
name: string;
|
|
11
|
+
properties: ConfigProperty[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ConfigProperty {
|
|
15
|
+
key: string;
|
|
16
|
+
value: ConfigValue;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type ConfigValue =
|
|
20
|
+
| { kind: 'String'; value: string }
|
|
21
|
+
| { kind: 'EnvVar'; var: string; default?: string }
|
|
22
|
+
| { kind: 'Boolean'; value: boolean }
|
|
23
|
+
| { kind: 'Number'; value: number };
|
|
24
|
+
|
|
25
|
+
export interface NamedPipeline {
|
|
26
|
+
name: string;
|
|
27
|
+
pipeline: Pipeline;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface Variable {
|
|
31
|
+
varType: string;
|
|
32
|
+
name: string;
|
|
33
|
+
value: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Route {
|
|
37
|
+
method: string;
|
|
38
|
+
path: string;
|
|
39
|
+
pipeline: PipelineRef;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type PipelineRef =
|
|
43
|
+
| { kind: 'Inline'; pipeline: Pipeline }
|
|
44
|
+
| { kind: 'Named'; name: string };
|
|
45
|
+
|
|
46
|
+
export interface Pipeline {
|
|
47
|
+
steps: PipelineStep[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type PipelineStep =
|
|
51
|
+
| { kind: 'Regular'; name: string; config: string }
|
|
52
|
+
| { kind: 'Result'; branches: ResultBranch[] };
|
|
53
|
+
|
|
54
|
+
export interface ResultBranch {
|
|
55
|
+
branchType: ResultBranchType;
|
|
56
|
+
statusCode: number;
|
|
57
|
+
pipeline: Pipeline;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type ResultBranchType =
|
|
61
|
+
| { kind: 'Ok' }
|
|
62
|
+
| { kind: 'Custom'; name: string }
|
|
63
|
+
| { kind: 'Default' };
|
|
64
|
+
|
|
65
|
+
export interface Describe {
|
|
66
|
+
name: string;
|
|
67
|
+
mocks: Mock[];
|
|
68
|
+
tests: It[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface Mock {
|
|
72
|
+
target: string;
|
|
73
|
+
returnValue: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface It {
|
|
77
|
+
name: string;
|
|
78
|
+
mocks: Mock[];
|
|
79
|
+
when: When;
|
|
80
|
+
input?: string;
|
|
81
|
+
conditions: Condition[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type When =
|
|
85
|
+
| { kind: 'CallingRoute'; method: string; path: string }
|
|
86
|
+
| { kind: 'ExecutingPipeline'; name: string }
|
|
87
|
+
| { kind: 'ExecutingVariable'; varType: string; name: string };
|
|
88
|
+
|
|
89
|
+
export interface Condition {
|
|
90
|
+
conditionType: 'Then' | 'And';
|
|
91
|
+
field: string;
|
|
92
|
+
jqExpr?: string;
|
|
93
|
+
comparison: string;
|
|
94
|
+
value: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type DiagnosticSeverity = 'error' | 'warning' | 'info';
|
|
98
|
+
export interface ParseDiagnostic {
|
|
99
|
+
message: string;
|
|
100
|
+
start: number;
|
|
101
|
+
end: number;
|
|
102
|
+
severity: DiagnosticSeverity;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
class Parser {
|
|
106
|
+
private readonly text: string;
|
|
107
|
+
private readonly len: number;
|
|
108
|
+
private pos: number = 0;
|
|
109
|
+
private diagnostics: ParseDiagnostic[] = [];
|
|
110
|
+
private readonly pipelineRanges: Map<string, { start: number; end: number }> = new Map();
|
|
111
|
+
private readonly variableRanges: Map<string, { start: number; end: number }> = new Map();
|
|
112
|
+
|
|
113
|
+
constructor(text: string) {
|
|
114
|
+
this.text = text;
|
|
115
|
+
this.len = text.length;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
getDiagnostics(): ParseDiagnostic[] {
|
|
119
|
+
return this.diagnostics.slice();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
getPipelineRanges(): Map<string, { start: number; end: number }> {
|
|
123
|
+
return new Map(this.pipelineRanges);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
getVariableRanges(): Map<string, { start: number; end: number }> {
|
|
127
|
+
return new Map(this.variableRanges);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
report(message: string, start: number, end: number, severity: DiagnosticSeverity): void {
|
|
131
|
+
this.diagnostics.push({ message, start, end, severity });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
findLineStart(pos: number): number {
|
|
135
|
+
let i = Math.max(0, Math.min(pos, this.len));
|
|
136
|
+
while (i > 0 && this.text[i - 1] !== '\n') i--;
|
|
137
|
+
return i;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
findLineEnd(pos: number): number {
|
|
141
|
+
let i = Math.max(0, Math.min(pos, this.len));
|
|
142
|
+
while (i < this.text.length && this.text[i] !== '\n') i++;
|
|
143
|
+
return i;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
parseProgram(): Program {
|
|
147
|
+
this.skipSpaces();
|
|
148
|
+
|
|
149
|
+
const configs: Config[] = [];
|
|
150
|
+
const pipelines: NamedPipeline[] = [];
|
|
151
|
+
const variables: Variable[] = [];
|
|
152
|
+
const routes: Route[] = [];
|
|
153
|
+
const describes: Describe[] = [];
|
|
154
|
+
|
|
155
|
+
while (!this.eof()) {
|
|
156
|
+
this.skipSpaces();
|
|
157
|
+
if (this.eof()) break;
|
|
158
|
+
|
|
159
|
+
const start = this.pos;
|
|
160
|
+
|
|
161
|
+
const cfg = this.tryParse(() => this.parseConfig());
|
|
162
|
+
if (cfg) {
|
|
163
|
+
configs.push(cfg);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const namedPipe = this.tryParse(() => this.parseNamedPipeline());
|
|
168
|
+
if (namedPipe) {
|
|
169
|
+
pipelines.push(namedPipe);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const variable = this.tryParse(() => this.parseVariable());
|
|
174
|
+
if (variable) {
|
|
175
|
+
variables.push(variable);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const route = this.tryParse(() => this.parseRoute());
|
|
180
|
+
if (route) {
|
|
181
|
+
routes.push(route);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const describe = this.tryParse(() => this.parseDescribe());
|
|
186
|
+
if (describe) {
|
|
187
|
+
describes.push(describe);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (this.pos === start) {
|
|
192
|
+
const lineStart = this.findLineStart(this.pos);
|
|
193
|
+
const lineEnd = this.findLineEnd(this.pos);
|
|
194
|
+
this.report('Unrecognized or unsupported syntax', lineStart, lineEnd, 'warning');
|
|
195
|
+
this.skipToEol();
|
|
196
|
+
if (this.cur() === '\n') this.pos++;
|
|
197
|
+
this.consumeWhile((c) => c === '\n');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const backtickCount = (this.text.match(/`/g) || []).length;
|
|
202
|
+
if (backtickCount % 2 === 1) {
|
|
203
|
+
const idx = this.text.lastIndexOf('`');
|
|
204
|
+
const start = Math.max(0, idx);
|
|
205
|
+
this.report('Unclosed backtick-delimited string', start, start + 1, 'warning');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return { configs, pipelines, variables, routes, describes };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private eof(): boolean { return this.pos >= this.len; }
|
|
212
|
+
private peek(): string { return this.text[this.pos] ?? '\0'; }
|
|
213
|
+
private cur(): string { return this.text[this.pos] ?? '\0'; }
|
|
214
|
+
private ahead(n: number): string { return this.text[this.pos + n] ?? '\0'; }
|
|
215
|
+
|
|
216
|
+
private tryParse<T>(fn: () => T): T | null {
|
|
217
|
+
const save = this.pos;
|
|
218
|
+
try {
|
|
219
|
+
const value = fn();
|
|
220
|
+
return value;
|
|
221
|
+
} catch (_e) {
|
|
222
|
+
this.pos = save;
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private skipSpaces(): void {
|
|
228
|
+
while (true) {
|
|
229
|
+
this.consumeWhile((ch) => ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n');
|
|
230
|
+
if (this.text.startsWith('#', this.pos)) {
|
|
231
|
+
this.skipToEol();
|
|
232
|
+
if (this.cur() === '\n') this.pos++;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (this.text.startsWith('//', this.pos)) {
|
|
236
|
+
this.skipToEol();
|
|
237
|
+
if (this.cur() === '\n') this.pos++;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private skipInlineSpaces(): void {
|
|
245
|
+
this.consumeWhile((ch) => ch === ' ' || ch === '\t' || ch === '\r');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private consumeWhile(pred: (ch: string) => boolean): string {
|
|
249
|
+
const start = this.pos;
|
|
250
|
+
while (!this.eof() && pred(this.text[this.pos])) this.pos++;
|
|
251
|
+
return this.text.slice(start, this.pos);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private match(str: string): boolean {
|
|
255
|
+
if (this.text.startsWith(str, this.pos)) {
|
|
256
|
+
this.pos += str.length;
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private expect(str: string): void {
|
|
263
|
+
if (!this.match(str)) throw new ParseFailure(`expected '${str}'`, this.pos);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private skipToEol(): void {
|
|
267
|
+
while (!this.eof() && this.cur() !== '\n') this.pos++;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private isIdentStart(ch: string): boolean {
|
|
271
|
+
return /[A-Za-z_]/.test(ch);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private isIdentCont(ch: string): boolean {
|
|
275
|
+
return /[A-Za-z0-9_\-]/.test(ch);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private parseIdentifier(): string {
|
|
279
|
+
if (!this.isIdentStart(this.cur())) throw new ParseFailure('identifier', this.pos);
|
|
280
|
+
const start = this.pos;
|
|
281
|
+
this.pos++;
|
|
282
|
+
while (!this.eof() && this.isIdentCont(this.cur())) this.pos++;
|
|
283
|
+
return this.text.slice(start, this.pos);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private parseNumber(): number {
|
|
287
|
+
const start = this.pos;
|
|
288
|
+
const digits = this.consumeWhile((c) => /[0-9]/.test(c));
|
|
289
|
+
if (digits.length === 0) throw new ParseFailure('number', this.pos);
|
|
290
|
+
return parseInt(this.text.slice(start, this.pos), 10);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private parseQuotedString(): string {
|
|
294
|
+
this.expect('"');
|
|
295
|
+
const start = this.pos;
|
|
296
|
+
while (!this.eof()) {
|
|
297
|
+
const ch = this.cur();
|
|
298
|
+
if (ch === '"') break;
|
|
299
|
+
this.pos++;
|
|
300
|
+
}
|
|
301
|
+
const content = this.text.slice(start, this.pos);
|
|
302
|
+
this.expect('"');
|
|
303
|
+
return content;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private parseBacktickString(): string {
|
|
307
|
+
this.expect('`');
|
|
308
|
+
const start = this.pos;
|
|
309
|
+
while (!this.eof()) {
|
|
310
|
+
const ch = this.cur();
|
|
311
|
+
if (ch === '`') break;
|
|
312
|
+
this.pos++;
|
|
313
|
+
}
|
|
314
|
+
const content = this.text.slice(start, this.pos);
|
|
315
|
+
this.expect('`');
|
|
316
|
+
return content;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private parseMethod(): string {
|
|
320
|
+
const methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
321
|
+
for (const m of methods) {
|
|
322
|
+
if (this.text.startsWith(m, this.pos)) {
|
|
323
|
+
this.pos += m.length;
|
|
324
|
+
return m;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
throw new ParseFailure('method', this.pos);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private parseStepConfig(): string {
|
|
331
|
+
const bt = this.tryParse(() => this.parseBacktickString());
|
|
332
|
+
if (bt !== null) return bt;
|
|
333
|
+
const dq = this.tryParse(() => this.parseQuotedString());
|
|
334
|
+
if (dq !== null) return dq;
|
|
335
|
+
const id = this.tryParse(() => this.parseIdentifier());
|
|
336
|
+
if (id !== null) return id;
|
|
337
|
+
throw new ParseFailure('step-config', this.pos);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private parseConfigValue(): ConfigValue {
|
|
341
|
+
const envWithDefault = this.tryParse(() => {
|
|
342
|
+
this.expect('$');
|
|
343
|
+
const variable = this.parseIdentifier();
|
|
344
|
+
this.skipInlineSpaces();
|
|
345
|
+
this.expect('||');
|
|
346
|
+
this.skipInlineSpaces();
|
|
347
|
+
const def = this.parseQuotedString();
|
|
348
|
+
return { kind: 'EnvVar', var: variable, default: def } as ConfigValue;
|
|
349
|
+
});
|
|
350
|
+
if (envWithDefault) return envWithDefault;
|
|
351
|
+
|
|
352
|
+
const envNoDefault = this.tryParse(() => {
|
|
353
|
+
this.expect('$');
|
|
354
|
+
const variable = this.parseIdentifier();
|
|
355
|
+
return { kind: 'EnvVar', var: variable } as ConfigValue;
|
|
356
|
+
});
|
|
357
|
+
if (envNoDefault) return envNoDefault;
|
|
358
|
+
|
|
359
|
+
const str = this.tryParse(() => this.parseQuotedString());
|
|
360
|
+
if (str !== null) return { kind: 'String', value: str };
|
|
361
|
+
|
|
362
|
+
const bool = this.tryParse(() => {
|
|
363
|
+
if (this.match('true')) return true;
|
|
364
|
+
if (this.match('false')) return false;
|
|
365
|
+
throw new ParseFailure('bool', this.pos);
|
|
366
|
+
});
|
|
367
|
+
if (bool !== null) return { kind: 'Boolean', value: bool };
|
|
368
|
+
|
|
369
|
+
const num = this.tryParse(() => this.parseNumber());
|
|
370
|
+
if (num !== null) return { kind: 'Number', value: num };
|
|
371
|
+
|
|
372
|
+
throw new ParseFailure('config-value', this.pos);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private parseConfigProperty(): ConfigProperty {
|
|
376
|
+
this.skipSpaces();
|
|
377
|
+
const key = this.parseIdentifier();
|
|
378
|
+
this.skipInlineSpaces();
|
|
379
|
+
this.expect(':');
|
|
380
|
+
this.skipInlineSpaces();
|
|
381
|
+
const value = this.parseConfigValue();
|
|
382
|
+
return { key, value };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private parseConfig(): Config {
|
|
386
|
+
this.expect('config');
|
|
387
|
+
this.skipInlineSpaces();
|
|
388
|
+
const name = this.parseIdentifier();
|
|
389
|
+
this.skipInlineSpaces();
|
|
390
|
+
this.expect('{');
|
|
391
|
+
this.skipSpaces();
|
|
392
|
+
const properties: ConfigProperty[] = [];
|
|
393
|
+
while (true) {
|
|
394
|
+
const prop = this.tryParse(() => this.parseConfigProperty());
|
|
395
|
+
if (!prop) break;
|
|
396
|
+
properties.push(prop);
|
|
397
|
+
this.skipSpaces();
|
|
398
|
+
}
|
|
399
|
+
this.skipSpaces();
|
|
400
|
+
this.expect('}');
|
|
401
|
+
this.skipSpaces();
|
|
402
|
+
return { name, properties };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
private parsePipelineStep(): PipelineStep {
|
|
406
|
+
const result = this.tryParse(() => this.parseResultStep());
|
|
407
|
+
if (result) return result;
|
|
408
|
+
return this.parseRegularStep();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private parseRegularStep(): PipelineStep {
|
|
412
|
+
this.skipSpaces();
|
|
413
|
+
this.expect('|>');
|
|
414
|
+
this.skipInlineSpaces();
|
|
415
|
+
const name = this.parseIdentifier();
|
|
416
|
+
this.expect(':');
|
|
417
|
+
this.skipInlineSpaces();
|
|
418
|
+
const config = this.parseStepConfig();
|
|
419
|
+
this.skipSpaces();
|
|
420
|
+
return { kind: 'Regular', name, config };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private parseResultStep(): PipelineStep {
|
|
424
|
+
this.skipSpaces();
|
|
425
|
+
this.expect('|>');
|
|
426
|
+
this.skipInlineSpaces();
|
|
427
|
+
this.expect('result');
|
|
428
|
+
this.skipSpaces();
|
|
429
|
+
const branches: ResultBranch[] = [];
|
|
430
|
+
while (true) {
|
|
431
|
+
const br = this.tryParse(() => this.parseResultBranch());
|
|
432
|
+
if (!br) break;
|
|
433
|
+
branches.push(br);
|
|
434
|
+
}
|
|
435
|
+
return { kind: 'Result', branches };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private parseResultBranch(): ResultBranch {
|
|
439
|
+
this.skipSpaces();
|
|
440
|
+
const branchIdent = this.parseIdentifier();
|
|
441
|
+
let branchType: ResultBranchType;
|
|
442
|
+
if (branchIdent === 'ok') branchType = { kind: 'Ok' };
|
|
443
|
+
else if (branchIdent === 'default') branchType = { kind: 'Default' };
|
|
444
|
+
else branchType = { kind: 'Custom', name: branchIdent };
|
|
445
|
+
this.expect('(');
|
|
446
|
+
const statusCode = this.parseNumber();
|
|
447
|
+
if (statusCode < 100 || statusCode > 599) {
|
|
448
|
+
this.report(`Invalid HTTP status code: ${statusCode}`,
|
|
449
|
+
this.pos - String(statusCode).length,
|
|
450
|
+
this.pos,
|
|
451
|
+
'error');
|
|
452
|
+
}
|
|
453
|
+
this.expect(')');
|
|
454
|
+
this.expect(':');
|
|
455
|
+
this.skipSpaces();
|
|
456
|
+
const pipeline = this.parsePipeline();
|
|
457
|
+
return { branchType, statusCode, pipeline };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private parsePipeline(): Pipeline {
|
|
461
|
+
const steps: PipelineStep[] = [];
|
|
462
|
+
while (true) {
|
|
463
|
+
const save = this.pos;
|
|
464
|
+
this.skipSpaces();
|
|
465
|
+
if (!this.text.startsWith('|>', this.pos)) {
|
|
466
|
+
this.pos = save;
|
|
467
|
+
break;
|
|
468
|
+
}
|
|
469
|
+
const step = this.parsePipelineStep();
|
|
470
|
+
steps.push(step);
|
|
471
|
+
}
|
|
472
|
+
return { steps };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
private parseNamedPipeline(): NamedPipeline {
|
|
476
|
+
const start = this.pos;
|
|
477
|
+
this.expect('pipeline');
|
|
478
|
+
this.skipInlineSpaces();
|
|
479
|
+
const name = this.parseIdentifier();
|
|
480
|
+
this.skipInlineSpaces();
|
|
481
|
+
this.expect('=');
|
|
482
|
+
this.skipInlineSpaces();
|
|
483
|
+
const beforePipeline = this.pos;
|
|
484
|
+
const pipeline = this.parsePipeline();
|
|
485
|
+
const end = this.pos;
|
|
486
|
+
this.pipelineRanges.set(name, { start, end });
|
|
487
|
+
this.skipSpaces();
|
|
488
|
+
return { name, pipeline };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
private parsePipelineRef(): PipelineRef {
|
|
492
|
+
const inline = this.tryParse(() => this.parsePipeline());
|
|
493
|
+
if (inline && inline.steps.length > 0) return { kind: 'Inline', pipeline: inline };
|
|
494
|
+
|
|
495
|
+
const named = this.tryParse(() => {
|
|
496
|
+
this.skipSpaces();
|
|
497
|
+
this.expect('|>');
|
|
498
|
+
this.skipInlineSpaces();
|
|
499
|
+
this.expect('pipeline:');
|
|
500
|
+
this.skipInlineSpaces();
|
|
501
|
+
const name = this.parseIdentifier();
|
|
502
|
+
return { kind: 'Named', name } as PipelineRef;
|
|
503
|
+
});
|
|
504
|
+
if (named) return named;
|
|
505
|
+
throw new Error('pipeline-ref');
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
private parseVariable(): Variable {
|
|
509
|
+
const start = this.pos;
|
|
510
|
+
const varType = this.parseIdentifier();
|
|
511
|
+
this.skipInlineSpaces();
|
|
512
|
+
const name = this.parseIdentifier();
|
|
513
|
+
this.skipInlineSpaces();
|
|
514
|
+
this.expect('=');
|
|
515
|
+
this.skipInlineSpaces();
|
|
516
|
+
const value = this.parseBacktickString();
|
|
517
|
+
const end = this.pos;
|
|
518
|
+
this.variableRanges.set(`${varType}::${name}`, { start, end });
|
|
519
|
+
this.skipSpaces();
|
|
520
|
+
return { varType, name, value };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private parseRoute(): Route {
|
|
524
|
+
const method = this.parseMethod();
|
|
525
|
+
this.skipInlineSpaces();
|
|
526
|
+
const path = this.consumeWhile((c) => c !== ' ' && c !== '\n');
|
|
527
|
+
this.skipSpaces();
|
|
528
|
+
const pipeline = this.parsePipelineRef();
|
|
529
|
+
this.skipSpaces();
|
|
530
|
+
return { method, path, pipeline };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
private parseWhen(): When {
|
|
534
|
+
const calling = this.tryParse(() => {
|
|
535
|
+
this.expect('calling');
|
|
536
|
+
this.skipInlineSpaces();
|
|
537
|
+
const method = this.parseMethod();
|
|
538
|
+
this.skipInlineSpaces();
|
|
539
|
+
const path = this.consumeWhile((c) => c !== '\n');
|
|
540
|
+
return { kind: 'CallingRoute', method, path } as When;
|
|
541
|
+
});
|
|
542
|
+
if (calling) return calling;
|
|
543
|
+
|
|
544
|
+
const executingPipeline = this.tryParse(() => {
|
|
545
|
+
this.expect('executing');
|
|
546
|
+
this.skipInlineSpaces();
|
|
547
|
+
this.expect('pipeline');
|
|
548
|
+
this.skipInlineSpaces();
|
|
549
|
+
const name = this.parseIdentifier();
|
|
550
|
+
return { kind: 'ExecutingPipeline', name } as When;
|
|
551
|
+
});
|
|
552
|
+
if (executingPipeline) return executingPipeline;
|
|
553
|
+
|
|
554
|
+
const executingVariable = this.tryParse(() => {
|
|
555
|
+
this.expect('executing');
|
|
556
|
+
this.skipInlineSpaces();
|
|
557
|
+
this.expect('variable');
|
|
558
|
+
this.skipInlineSpaces();
|
|
559
|
+
const varType = this.parseIdentifier();
|
|
560
|
+
this.skipInlineSpaces();
|
|
561
|
+
const name = this.parseIdentifier();
|
|
562
|
+
return { kind: 'ExecutingVariable', varType, name } as When;
|
|
563
|
+
});
|
|
564
|
+
if (executingVariable) return executingVariable;
|
|
565
|
+
|
|
566
|
+
throw new ParseFailure('when', this.pos);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
private parseCondition(): Condition {
|
|
570
|
+
this.skipSpaces();
|
|
571
|
+
const ct = (() => {
|
|
572
|
+
if (this.match('then')) return 'Then' as const;
|
|
573
|
+
if (this.match('and')) return 'And' as const;
|
|
574
|
+
throw new Error('condition-type');
|
|
575
|
+
})();
|
|
576
|
+
this.skipInlineSpaces();
|
|
577
|
+
const field = this.consumeWhile((c) => c !== ' ' && c !== '\n' && c !== '`');
|
|
578
|
+
this.skipInlineSpaces();
|
|
579
|
+
const jqExpr = this.tryParse(() => this.parseBacktickString());
|
|
580
|
+
this.skipInlineSpaces();
|
|
581
|
+
const comparison = this.consumeWhile((c) => c !== ' ' && c !== '\n');
|
|
582
|
+
this.skipInlineSpaces();
|
|
583
|
+
const value = (() => {
|
|
584
|
+
const v1 = this.tryParse(() => this.parseBacktickString());
|
|
585
|
+
if (v1 !== null) return v1;
|
|
586
|
+
const v2 = this.tryParse(() => this.parseQuotedString());
|
|
587
|
+
if (v2 !== null) return v2;
|
|
588
|
+
return this.consumeWhile((c) => c !== '\n');
|
|
589
|
+
})();
|
|
590
|
+
return { conditionType: ct, field, jqExpr: jqExpr ?? undefined, comparison, value };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
private parseMockHead(prefixWord: 'with' | 'and'): Mock {
|
|
594
|
+
this.skipSpaces();
|
|
595
|
+
this.expect(prefixWord);
|
|
596
|
+
this.skipInlineSpaces();
|
|
597
|
+
this.expect('mock');
|
|
598
|
+
this.skipInlineSpaces();
|
|
599
|
+
const target = this.consumeWhile((c) => c !== ' ' && c !== '\n');
|
|
600
|
+
this.skipInlineSpaces();
|
|
601
|
+
this.expect('returning');
|
|
602
|
+
this.skipInlineSpaces();
|
|
603
|
+
const returnValue = this.parseBacktickString();
|
|
604
|
+
this.skipSpaces();
|
|
605
|
+
return { target, returnValue };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
private parseMock(): Mock {
|
|
609
|
+
return this.parseMockHead('with');
|
|
610
|
+
}
|
|
611
|
+
private parseAndMock(): Mock {
|
|
612
|
+
return this.parseMockHead('and');
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
private parseIt(): It {
|
|
616
|
+
this.skipSpaces();
|
|
617
|
+
this.expect('it');
|
|
618
|
+
this.skipInlineSpaces();
|
|
619
|
+
this.expect('"');
|
|
620
|
+
const name = this.consumeWhile((c) => c !== '"');
|
|
621
|
+
this.expect('"');
|
|
622
|
+
this.skipSpaces();
|
|
623
|
+
|
|
624
|
+
const mocks: Mock[] = [];
|
|
625
|
+
while (true) {
|
|
626
|
+
const m = this.tryParse(() => this.parseMock());
|
|
627
|
+
if (!m) break;
|
|
628
|
+
mocks.push(m);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
this.expect('when');
|
|
632
|
+
this.skipInlineSpaces();
|
|
633
|
+
const when = this.parseWhen();
|
|
634
|
+
this.skipSpaces();
|
|
635
|
+
|
|
636
|
+
const input = this.tryParse(() => {
|
|
637
|
+
this.expect('with');
|
|
638
|
+
this.skipInlineSpaces();
|
|
639
|
+
this.expect('input');
|
|
640
|
+
this.skipInlineSpaces();
|
|
641
|
+
const v = this.parseBacktickString();
|
|
642
|
+
this.skipSpaces();
|
|
643
|
+
return v;
|
|
644
|
+
}) ?? undefined;
|
|
645
|
+
|
|
646
|
+
const extraMocks: Mock[] = [];
|
|
647
|
+
while (true) {
|
|
648
|
+
const m = this.tryParse(() => this.parseAndMock());
|
|
649
|
+
if (!m) break;
|
|
650
|
+
extraMocks.push(m);
|
|
651
|
+
this.skipSpaces();
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const conditions: Condition[] = [];
|
|
655
|
+
while (true) {
|
|
656
|
+
const c = this.tryParse(() => this.parseCondition());
|
|
657
|
+
if (!c) break;
|
|
658
|
+
conditions.push(c);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
return { name, mocks: [...mocks, ...extraMocks], when, input, conditions };
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
private parseDescribe(): Describe {
|
|
665
|
+
this.skipSpaces();
|
|
666
|
+
this.expect('describe');
|
|
667
|
+
this.skipInlineSpaces();
|
|
668
|
+
this.expect('"');
|
|
669
|
+
const name = this.consumeWhile((c) => c !== '"');
|
|
670
|
+
this.expect('"');
|
|
671
|
+
this.skipSpaces();
|
|
672
|
+
|
|
673
|
+
const mocks: Mock[] = [];
|
|
674
|
+
while (true) {
|
|
675
|
+
const m = this.tryParse(() => this.parseMock());
|
|
676
|
+
if (!m) break;
|
|
677
|
+
mocks.push(m);
|
|
678
|
+
this.skipSpaces();
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const tests: It[] = [];
|
|
682
|
+
while (true) {
|
|
683
|
+
const it = this.tryParse(() => this.parseIt());
|
|
684
|
+
if (!it) break;
|
|
685
|
+
tests.push(it);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
return { name, mocks, tests };
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
export function parseProgram(text: string): Program {
|
|
693
|
+
const parser = new Parser(text);
|
|
694
|
+
return parser.parseProgram();
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
export function parseProgramWithDiagnostics(text: string): { program: Program; diagnostics: ParseDiagnostic[] } {
|
|
698
|
+
const parser = new Parser(text);
|
|
699
|
+
const program = parser.parseProgram();
|
|
700
|
+
return { program, diagnostics: parser.getDiagnostics() };
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
export function getPipelineRanges(text: string): Map<string, { start: number; end: number }> {
|
|
704
|
+
const parser = new Parser(text);
|
|
705
|
+
parser.parseProgram();
|
|
706
|
+
return parser.getPipelineRanges();
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
export function getVariableRanges(text: string): Map<string, { start: number; end: number }> {
|
|
710
|
+
const parser = new Parser(text);
|
|
711
|
+
parser.parseProgram();
|
|
712
|
+
return parser.getVariableRanges();
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
class ParseFailure extends Error {
|
|
716
|
+
constructor(message: string, public at: number) {
|
|
717
|
+
super(message);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
722
|
+
const fs = await import('node:fs/promises');
|
|
723
|
+
const path = process.argv[2];
|
|
724
|
+
if (!path) {
|
|
725
|
+
console.error('Usage: node dist/parser.js <file.wp>');
|
|
726
|
+
process.exit(1);
|
|
727
|
+
}
|
|
728
|
+
const src = await fs.readFile(path, 'utf8');
|
|
729
|
+
const program = parseProgram(src);
|
|
730
|
+
console.log(JSON.stringify(program, null, 2));
|
|
731
|
+
}
|