ampscript-parser 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.
Files changed (3) hide show
  1. package/README.md +117 -0
  2. package/package.json +33 -0
  3. package/src/index.js +1164 -0
package/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # ampscript-parser
2
+
3
+ AMPscript lexer and parser that produces an AST for Salesforce Marketing Cloud (SFMC) tooling.
4
+
5
+ Handles all AMPscript embedding syntaxes:
6
+
7
+ - Block syntax: `%%[ ... ]%%`
8
+ - Script-tag syntax: `<script runat="server" language="ampscript"> ... </script>`
9
+ - Inline expressions: `%%=...=%%`
10
+ - Plain HTML/text content between AMPscript segments
11
+
12
+ This package is used internally by:
13
+
14
+ - [prettier-plugin-sfmc](https://www.npmjs.com/package/prettier-plugin-sfmc) — AMPscript formatting
15
+ - [eslint-plugin-sfmc](https://www.npmjs.com/package/eslint-plugin-sfmc) — AMPscript linting
16
+
17
+ ## Installation
18
+
19
+ ```sh
20
+ npm install ampscript-parser
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```js
26
+ import { parse, tokenizeBlock, parseStatements, TokenType } from 'ampscript-parser';
27
+ ```
28
+
29
+ ### `parse(text)`
30
+
31
+ Parses a full document string (HTML with embedded AMPscript) into a `Document` AST. This is the main entry point for most use cases.
32
+
33
+ ```js
34
+ import { parse } from 'ampscript-parser';
35
+
36
+ const doc = parse(`
37
+ <p>Hello %%=AttributeValue('firstname')=%%</p>
38
+ %%[
39
+ SET @greeting = "Welcome"
40
+ IF @greeting == "Welcome" THEN
41
+ Output(@greeting)
42
+ ENDIF
43
+ ]%%
44
+ `);
45
+
46
+ // doc.type === 'Document'
47
+ // doc.children — array of Content, Block, and InlineExpression nodes
48
+ for (const node of doc.children) {
49
+ console.log(node.type); // 'Content' | 'Block' | 'InlineExpression'
50
+ }
51
+ ```
52
+
53
+ #### AST node types
54
+
55
+ | Node type | Description |
56
+ |---|---|
57
+ | `Document` | Root node; has a `children` array |
58
+ | `Content` | Plain HTML/text segment between AMPscript regions |
59
+ | `Block` | A `%%[ ]%%` or script-tag block; has a `statements` array |
60
+ | `InlineExpression` | A `%%=...=%%` expression; has an `expression` property |
61
+
62
+ ### `tokenizeBlock(code, offset?)`
63
+
64
+ Tokenizes a raw AMPscript code string (the content inside a block or inline expression, without the surrounding delimiters). Returns an array of token objects.
65
+
66
+ ```js
67
+ import { tokenizeBlock } from 'ampscript-parser';
68
+
69
+ const tokens = tokenizeBlock("SET @name = 'World'");
70
+
71
+ for (const token of tokens) {
72
+ console.log(token.type); // e.g. 'SET', 'VARIABLE', 'EQUALS', 'STRING'
73
+ console.log(token.value); // raw text of the token
74
+ console.log(token.start); // character offset in the source
75
+ console.log(token.end); // end offset
76
+ }
77
+ ```
78
+
79
+ The optional `offset` parameter shifts all token positions by a base character offset, useful when the code snippet originates from a larger document.
80
+
81
+ ### `parseStatements(tokens)`
82
+
83
+ Parses an array of tokens (as returned by `tokenizeBlock`) into an array of statement AST nodes. Useful when you already have tokens and want to build the AST incrementally.
84
+
85
+ ```js
86
+ import { tokenizeBlock, parseStatements } from 'ampscript-parser';
87
+
88
+ const tokens = tokenizeBlock("VAR @x\nSET @x = Add(1, 2)");
89
+ const statements = parseStatements(tokens);
90
+
91
+ for (const stmt of statements) {
92
+ console.log(stmt.type); // e.g. 'VarStatement', 'SetStatement'
93
+ }
94
+ ```
95
+
96
+ ### `TokenType`
97
+
98
+ An object of token type constants used to identify tokens returned by `tokenizeBlock`.
99
+
100
+ ```js
101
+ import { TokenType } from 'ampscript-parser';
102
+
103
+ console.log(TokenType.SET); // 'SET'
104
+ console.log(TokenType.IF); // 'IF'
105
+ console.log(TokenType.VARIABLE); // 'VARIABLE'
106
+ console.log(TokenType.STRING); // 'STRING'
107
+ console.log(TokenType.NUMBER); // 'NUMBER'
108
+ console.log(TokenType.BOOLEAN); // 'BOOLEAN'
109
+ console.log(TokenType.IDENTIFIER); // 'IDENTIFIER'
110
+ console.log(TokenType.COMMENT); // 'COMMENT'
111
+ ```
112
+
113
+ Full list of token types: `BLOCK_OPEN`, `BLOCK_CLOSE`, `INLINE_OPEN`, `INLINE_CLOSE`, `VAR`, `SET`, `IF`, `THEN`, `ELSEIF`, `ELSE`, `ENDIF`, `FOR`, `TO`, `DOWNTO`, `DO`, `NEXT`, `AND`, `OR`, `NOT`, `COMMA`, `LPAREN`, `RPAREN`, `EQUALS`, `EQ`, `NEQ`, `GT`, `LT`, `GTE`, `LTE`, `STRING`, `NUMBER`, `BOOLEAN`, `IDENTIFIER`, `VARIABLE`, `COMMENT`, `NEWLINE`, `WHITESPACE`.
114
+
115
+ ## License
116
+
117
+ MIT
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "ampscript-parser",
3
+ "version": "0.1.0",
4
+ "description": "AMPscript lexer and parser producing an AST for SFMC tooling",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "files": ["src"],
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/JoernBerkefeld/ampscript-parser.git"
14
+ },
15
+ "bugs": {
16
+ "url": "https://github.com/JoernBerkefeld/ampscript-parser/issues"
17
+ },
18
+ "homepage": "https://github.com/JoernBerkefeld/ampscript-parser#readme",
19
+ "author": "Joern Berkefeld",
20
+ "keywords": [
21
+ "ampscript",
22
+ "sfmc",
23
+ "salesforce",
24
+ "marketing-cloud",
25
+ "parser",
26
+ "ast",
27
+ "lexer"
28
+ ],
29
+ "license": "MIT",
30
+ "engines": {
31
+ "node": ">=18.0.0"
32
+ }
33
+ }
package/src/index.js ADDED
@@ -0,0 +1,1164 @@
1
+ /**
2
+ * AMPscript Parser
3
+ *
4
+ * Tokenizes and builds an AST from AMPscript code, which can be:
5
+ * - AMPscript blocks: %%[ ... ]%%
6
+ * - Script-tag blocks: <script runat="server" language="ampscript"> ... </script>
7
+ * - Inline expressions: %%=...=%%
8
+ * - HTML/text content between AMPscript segments
9
+ *
10
+ * The AST is a flat list of top-level nodes (Content, Block, InlineExpression).
11
+ * Inside blocks, statements are parsed into their own node types.
12
+ */
13
+
14
+ // ── Prettier Ignore Marking ───────────────────────────────────────────────
15
+
16
+ /**
17
+ * Walks an array of statement nodes and marks nodes to be ignored based on
18
+ * prettier-ignore and prettier-ignore-start / prettier-ignore-end comments.
19
+ */
20
+ function markPrettierIgnore(nodes) {
21
+ if (!Array.isArray(nodes)) return;
22
+ let index = 0;
23
+ while (index < nodes.length) {
24
+ const node = nodes[index];
25
+ if (
26
+ node &&
27
+ node.type === 'Comment' &&
28
+ /^\s*\/\*\s*prettier-ignore\s*\*\/\s*$/i.test(node.value)
29
+ ) {
30
+ let index_ = index + 1;
31
+ while (index_ < nodes.length && nodes[index_].type === 'Comment') index_++;
32
+ if (index_ < nodes.length) nodes[index_].prettierIgnore = true;
33
+ index = index_;
34
+ continue;
35
+ }
36
+ if (node && node.type === 'Comment' && /prettier-ignore-start/i.test(node.value)) {
37
+ let index_ = index + 1;
38
+ while (
39
+ index_ < nodes.length &&
40
+ !(
41
+ nodes[index_].type === 'Comment' &&
42
+ /prettier-ignore-end/i.test(nodes[index_].value)
43
+ )
44
+ ) {
45
+ if (nodes[index_].type !== 'Comment') nodes[index_].prettierIgnore = true;
46
+ index_++;
47
+ }
48
+ index = index_ + 1;
49
+ continue;
50
+ }
51
+ if (node && typeof node === 'object') {
52
+ if (Array.isArray(node.statements)) markPrettierIgnore(node.statements);
53
+ if (Array.isArray(node.consequent)) markPrettierIgnore(node.consequent);
54
+ if (Array.isArray(node.alternates)) {
55
+ for (const alt of node.alternates) {
56
+ if (Array.isArray(alt.body)) markPrettierIgnore(alt.body);
57
+ }
58
+ }
59
+ if (Array.isArray(node.body)) markPrettierIgnore(node.body);
60
+ }
61
+ index++;
62
+ }
63
+ }
64
+
65
+ // ── Token types ──────────────────────────────────────────────────────────────
66
+
67
+ const TokenType = {
68
+ BLOCK_OPEN: 'BLOCK_OPEN',
69
+ BLOCK_CLOSE: 'BLOCK_CLOSE',
70
+ INLINE_OPEN: 'INLINE_OPEN',
71
+ INLINE_CLOSE: 'INLINE_CLOSE',
72
+ VAR: 'VAR',
73
+ SET: 'SET',
74
+ IF: 'IF',
75
+ THEN: 'THEN',
76
+ ELSEIF: 'ELSEIF',
77
+ ELSE: 'ELSE',
78
+ ENDIF: 'ENDIF',
79
+ FOR: 'FOR',
80
+ TO: 'TO',
81
+ DOWNTO: 'DOWNTO',
82
+ DO: 'DO',
83
+ NEXT: 'NEXT',
84
+ AND: 'AND',
85
+ OR: 'OR',
86
+ NOT: 'NOT',
87
+ COMMA: 'COMMA',
88
+ LPAREN: 'LPAREN',
89
+ RPAREN: 'RPAREN',
90
+ EQUALS: 'EQUALS',
91
+ EQ: 'EQ',
92
+ NEQ: 'NEQ',
93
+ GT: 'GT',
94
+ LT: 'LT',
95
+ GTE: 'GTE',
96
+ LTE: 'LTE',
97
+ STRING: 'STRING',
98
+ NUMBER: 'NUMBER',
99
+ BOOLEAN: 'BOOLEAN',
100
+ IDENTIFIER: 'IDENTIFIER',
101
+ VARIABLE: 'VARIABLE',
102
+ COMMENT: 'COMMENT',
103
+ NEWLINE: 'NEWLINE',
104
+ WHITESPACE: 'WHITESPACE',
105
+ };
106
+
107
+ const KEYWORDS = {
108
+ var: TokenType.VAR,
109
+ set: TokenType.SET,
110
+ if: TokenType.IF,
111
+ then: TokenType.THEN,
112
+ elseif: TokenType.ELSEIF,
113
+ else: TokenType.ELSE,
114
+ endif: TokenType.ENDIF,
115
+ for: TokenType.FOR,
116
+ to: TokenType.TO,
117
+ downto: TokenType.DOWNTO,
118
+ do: TokenType.DO,
119
+ next: TokenType.NEXT,
120
+ and: TokenType.AND,
121
+ or: TokenType.OR,
122
+ not: TokenType.NOT,
123
+ true: TokenType.BOOLEAN,
124
+ false: TokenType.BOOLEAN,
125
+ };
126
+
127
+ // ── Tokenizer ────────────────────────────────────────────────────────────────
128
+
129
+ function tokenizeBlock(code, offset = 0) {
130
+ const tokens = [];
131
+ let index = 0;
132
+
133
+ while (index < code.length) {
134
+ if (code[index] === ' ' || code[index] === '\t') {
135
+ const start = index;
136
+ while (index < code.length && (code[index] === ' ' || code[index] === '\t')) index++;
137
+ tokens.push({
138
+ type: TokenType.WHITESPACE,
139
+ value: code.slice(start, index),
140
+ start: offset + start,
141
+ end: offset + index,
142
+ });
143
+ continue;
144
+ }
145
+
146
+ if (code[index] === '\n' || code[index] === '\r') {
147
+ const start = index;
148
+ if (code[index] === '\r' && code[index + 1] === '\n') index++;
149
+ index++;
150
+ tokens.push({
151
+ type: TokenType.NEWLINE,
152
+ value: code.slice(start, index),
153
+ start: offset + start,
154
+ end: offset + index,
155
+ });
156
+ continue;
157
+ }
158
+
159
+ if (code[index] === '/' && code[index + 1] === '*') {
160
+ const start = index;
161
+ index += 2;
162
+ while (index < code.length && !(code[index] === '*' && code[index + 1] === '/'))
163
+ index++;
164
+ if (index < code.length) index += 2;
165
+ tokens.push({
166
+ type: TokenType.COMMENT,
167
+ value: code.slice(start, index),
168
+ start: offset + start,
169
+ end: offset + index,
170
+ });
171
+ continue;
172
+ }
173
+
174
+ if (code[index] === '"' || code[index] === "'") {
175
+ const quote = code[index];
176
+ const start = index;
177
+ index++;
178
+ while (index < code.length && code[index] !== quote) {
179
+ index++;
180
+ }
181
+ if (index < code.length) index++;
182
+ tokens.push({
183
+ type: TokenType.STRING,
184
+ value: code.slice(start, index),
185
+ start: offset + start,
186
+ end: offset + index,
187
+ });
188
+ continue;
189
+ }
190
+
191
+ if (code[index] === '=' && code[index + 1] === '=') {
192
+ tokens.push({
193
+ type: TokenType.EQ,
194
+ value: '==',
195
+ start: offset + index,
196
+ end: offset + index + 2,
197
+ });
198
+ index += 2;
199
+ continue;
200
+ }
201
+ if (code[index] === '!' && code[index + 1] === '=') {
202
+ tokens.push({
203
+ type: TokenType.NEQ,
204
+ value: '!=',
205
+ start: offset + index,
206
+ end: offset + index + 2,
207
+ });
208
+ index += 2;
209
+ continue;
210
+ }
211
+ if (code[index] === '>' && code[index + 1] === '=') {
212
+ tokens.push({
213
+ type: TokenType.GTE,
214
+ value: '>=',
215
+ start: offset + index,
216
+ end: offset + index + 2,
217
+ });
218
+ index += 2;
219
+ continue;
220
+ }
221
+ if (code[index] === '<' && code[index + 1] === '=') {
222
+ tokens.push({
223
+ type: TokenType.LTE,
224
+ value: '<=',
225
+ start: offset + index,
226
+ end: offset + index + 2,
227
+ });
228
+ index += 2;
229
+ continue;
230
+ }
231
+
232
+ if (code[index] === '=') {
233
+ tokens.push({
234
+ type: TokenType.EQUALS,
235
+ value: '=',
236
+ start: offset + index,
237
+ end: offset + index + 1,
238
+ });
239
+ index++;
240
+ continue;
241
+ }
242
+ if (code[index] === '>') {
243
+ tokens.push({
244
+ type: TokenType.GT,
245
+ value: '>',
246
+ start: offset + index,
247
+ end: offset + index + 1,
248
+ });
249
+ index++;
250
+ continue;
251
+ }
252
+ if (code[index] === '<') {
253
+ tokens.push({
254
+ type: TokenType.LT,
255
+ value: '<',
256
+ start: offset + index,
257
+ end: offset + index + 1,
258
+ });
259
+ index++;
260
+ continue;
261
+ }
262
+ if (code[index] === '(') {
263
+ tokens.push({
264
+ type: TokenType.LPAREN,
265
+ value: '(',
266
+ start: offset + index,
267
+ end: offset + index + 1,
268
+ });
269
+ index++;
270
+ continue;
271
+ }
272
+ if (code[index] === ')') {
273
+ tokens.push({
274
+ type: TokenType.RPAREN,
275
+ value: ')',
276
+ start: offset + index,
277
+ end: offset + index + 1,
278
+ });
279
+ index++;
280
+ continue;
281
+ }
282
+ if (code[index] === ',') {
283
+ tokens.push({
284
+ type: TokenType.COMMA,
285
+ value: ',',
286
+ start: offset + index,
287
+ end: offset + index + 1,
288
+ });
289
+ index++;
290
+ continue;
291
+ }
292
+
293
+ if (code[index] === '@') {
294
+ const start = index;
295
+ index++;
296
+ if (index < code.length && code[index] === '@') index++;
297
+ while (index < code.length && /[a-zA-Z0-9_]/.test(code[index])) index++;
298
+ tokens.push({
299
+ type: TokenType.VARIABLE,
300
+ value: code.slice(start, index),
301
+ start: offset + start,
302
+ end: offset + index,
303
+ });
304
+ continue;
305
+ }
306
+
307
+ if (
308
+ /[0-9]/.test(code[index]) ||
309
+ (code[index] === '-' && index + 1 < code.length && /[0-9]/.test(code[index + 1]))
310
+ ) {
311
+ const start = index;
312
+ if (code[index] === '-') index++;
313
+ while (index < code.length && /[0-9]/.test(code[index])) index++;
314
+ if (index < code.length && code[index] === '.') {
315
+ index++;
316
+ while (index < code.length && /[0-9]/.test(code[index])) index++;
317
+ }
318
+ tokens.push({
319
+ type: TokenType.NUMBER,
320
+ value: code.slice(start, index),
321
+ start: offset + start,
322
+ end: offset + index,
323
+ });
324
+ continue;
325
+ }
326
+
327
+ if (/[a-zA-Z_]/.test(code[index])) {
328
+ const start = index;
329
+ while (index < code.length && /[a-zA-Z0-9_]/.test(code[index])) index++;
330
+ const word = code.slice(start, index);
331
+ const lower = word.toLowerCase();
332
+ const kwType = KEYWORDS[lower];
333
+ if (kwType) {
334
+ tokens.push({
335
+ type: kwType,
336
+ value: word,
337
+ start: offset + start,
338
+ end: offset + index,
339
+ });
340
+ } else {
341
+ tokens.push({
342
+ type: TokenType.IDENTIFIER,
343
+ value: word,
344
+ start: offset + start,
345
+ end: offset + index,
346
+ });
347
+ }
348
+ continue;
349
+ }
350
+
351
+ const start = index;
352
+ index++;
353
+ tokens.push({
354
+ type: 'RAW',
355
+ value: code[start],
356
+ start: offset + start,
357
+ end: offset + index,
358
+ });
359
+ }
360
+
361
+ return tokens;
362
+ }
363
+
364
+ // ── Statement Parser ─────────────────────────────────────────────────────────
365
+
366
+ function parseStatements(tokens) {
367
+ const statements = [];
368
+ let pos = 0;
369
+
370
+ function _peek() {
371
+ while (
372
+ pos < tokens.length &&
373
+ (tokens[pos].type === TokenType.WHITESPACE || tokens[pos].type === TokenType.NEWLINE)
374
+ ) {
375
+ pos++;
376
+ }
377
+ return pos < tokens.length ? tokens[pos] : null;
378
+ }
379
+
380
+ function current() {
381
+ return pos < tokens.length ? tokens[pos] : null;
382
+ }
383
+
384
+ function advance() {
385
+ return tokens[pos++];
386
+ }
387
+
388
+ function skipTrivia() {
389
+ let newlineCount = 0;
390
+ while (
391
+ pos < tokens.length &&
392
+ (tokens[pos].type === TokenType.WHITESPACE || tokens[pos].type === TokenType.NEWLINE)
393
+ ) {
394
+ if (tokens[pos].type === TokenType.NEWLINE) newlineCount++;
395
+ pos++;
396
+ }
397
+ return newlineCount;
398
+ }
399
+
400
+ function parseExpression() {
401
+ return parseOrExpr();
402
+ }
403
+
404
+ function parseOrExpr() {
405
+ let left = parseAndExpr();
406
+ while (pos < tokens.length) {
407
+ skipTrivia();
408
+ const t = current();
409
+ if (t && t.type === TokenType.OR) {
410
+ const opToken = advance();
411
+ skipTrivia();
412
+ const right = parseAndExpr();
413
+ left = {
414
+ type: 'BinaryExpression',
415
+ operator: 'or',
416
+ originalOperator: opToken.value,
417
+ left,
418
+ right,
419
+ start: left.start,
420
+ end: right.end,
421
+ };
422
+ } else {
423
+ break;
424
+ }
425
+ }
426
+ return left;
427
+ }
428
+
429
+ function parseAndExpr() {
430
+ let left = parseNotExpr();
431
+ while (pos < tokens.length) {
432
+ skipTrivia();
433
+ const t = current();
434
+ if (t && t.type === TokenType.AND) {
435
+ const opToken = advance();
436
+ skipTrivia();
437
+ const right = parseNotExpr();
438
+ left = {
439
+ type: 'BinaryExpression',
440
+ operator: 'and',
441
+ originalOperator: opToken.value,
442
+ left,
443
+ right,
444
+ start: left.start,
445
+ end: right.end,
446
+ };
447
+ } else {
448
+ break;
449
+ }
450
+ }
451
+ return left;
452
+ }
453
+
454
+ function parseNotExpr() {
455
+ skipTrivia();
456
+ const t = current();
457
+ if (t && t.type === TokenType.NOT) {
458
+ const start = t.start;
459
+ const opToken = advance();
460
+ skipTrivia();
461
+ const expr = parseComparison();
462
+ return {
463
+ type: 'UnaryExpression',
464
+ operator: 'not',
465
+ originalOperator: opToken.value,
466
+ argument: expr,
467
+ start,
468
+ end: expr.end,
469
+ };
470
+ }
471
+ return parseComparison();
472
+ }
473
+
474
+ function parseComparison() {
475
+ let left = parsePrimary();
476
+ skipTrivia();
477
+ const t = current();
478
+ if (
479
+ t &&
480
+ (t.type === TokenType.EQ ||
481
+ t.type === TokenType.NEQ ||
482
+ t.type === TokenType.GT ||
483
+ t.type === TokenType.LT ||
484
+ t.type === TokenType.GTE ||
485
+ t.type === TokenType.LTE)
486
+ ) {
487
+ const op = advance();
488
+ skipTrivia();
489
+ const right = parsePrimary();
490
+ return {
491
+ type: 'BinaryExpression',
492
+ operator: op.value,
493
+ left,
494
+ right,
495
+ start: left.start,
496
+ end: right.end,
497
+ };
498
+ }
499
+ return left;
500
+ }
501
+
502
+ function parsePrimary() {
503
+ skipTrivia();
504
+ const t = current();
505
+ if (!t) {
506
+ return { type: 'Empty', value: '', start: 0, end: 0 };
507
+ }
508
+
509
+ if (t.type === TokenType.LPAREN) {
510
+ const start = t.start;
511
+ advance();
512
+ skipTrivia();
513
+ const expr = parseExpression();
514
+ skipTrivia();
515
+ const closing = current();
516
+ let end = expr.end;
517
+ if (closing && closing.type === TokenType.RPAREN) {
518
+ end = closing.end;
519
+ advance();
520
+ }
521
+ return { type: 'ParenExpression', expression: expr, start, end };
522
+ }
523
+
524
+ if (t.type === TokenType.IDENTIFIER) {
525
+ const savedPos = pos;
526
+ const name = advance();
527
+ skipTrivia();
528
+ const next = current();
529
+ if (next && next.type === TokenType.LPAREN) {
530
+ advance();
531
+ const arguments_ = [];
532
+ skipTrivia();
533
+ if (current() && current().type !== TokenType.RPAREN) {
534
+ arguments_.push(parseExpression());
535
+ while (current() && current().type === TokenType.COMMA) {
536
+ advance();
537
+ skipTrivia();
538
+ arguments_.push(parseExpression());
539
+ }
540
+ }
541
+ skipTrivia();
542
+ let end = name.end;
543
+ if (current() && current().type === TokenType.RPAREN) {
544
+ end = current().end;
545
+ advance();
546
+ }
547
+ return {
548
+ type: 'FunctionCall',
549
+ name: name.value,
550
+ arguments: arguments_,
551
+ start: name.start,
552
+ end,
553
+ };
554
+ }
555
+ pos = savedPos;
556
+ advance();
557
+ return { type: 'Identifier', value: name.value, start: name.start, end: name.end };
558
+ }
559
+
560
+ if (t.type === TokenType.VARIABLE) {
561
+ advance();
562
+ return { type: 'Variable', value: t.value, start: t.start, end: t.end };
563
+ }
564
+
565
+ if (t.type === TokenType.STRING) {
566
+ advance();
567
+ const quote = t.value[0];
568
+ const content = t.value.slice(1, -1);
569
+ return { type: 'StringLiteral', value: content, quote, start: t.start, end: t.end };
570
+ }
571
+
572
+ if (t.type === TokenType.NUMBER) {
573
+ advance();
574
+ return { type: 'NumberLiteral', value: t.value, start: t.start, end: t.end };
575
+ }
576
+
577
+ if (t.type === TokenType.BOOLEAN) {
578
+ advance();
579
+ return {
580
+ type: 'BooleanLiteral',
581
+ value: t.value.toLowerCase(),
582
+ originalValue: t.value,
583
+ start: t.start,
584
+ end: t.end,
585
+ };
586
+ }
587
+
588
+ advance();
589
+ return { type: 'Raw', value: t.value, start: t.start, end: t.end };
590
+ }
591
+
592
+ // ── Main statement parsing loop ──
593
+
594
+ function pushStmt(stmt, blankLine) {
595
+ if (blankLine) stmt.blankLineBefore = true;
596
+ statements.push(stmt);
597
+ }
598
+
599
+ while (pos < tokens.length) {
600
+ const newlines = skipTrivia();
601
+ if (pos >= tokens.length) break;
602
+ const hasBlankLine = newlines >= 2 && statements.length > 0;
603
+
604
+ const t = current();
605
+
606
+ if (t.type === TokenType.COMMENT) {
607
+ advance();
608
+ pushStmt({ type: 'Comment', value: t.value, start: t.start, end: t.end }, hasBlankLine);
609
+ continue;
610
+ }
611
+
612
+ if (t.type === TokenType.VAR) {
613
+ const start = t.start;
614
+ const variableKeyword = t.value;
615
+ advance();
616
+ skipTrivia();
617
+ const variables = [];
618
+ while (current() && current().type === TokenType.VARIABLE) {
619
+ variables.push({
620
+ type: 'Variable',
621
+ value: current().value,
622
+ start: current().start,
623
+ end: current().end,
624
+ });
625
+ advance();
626
+ skipTrivia();
627
+ if (current() && current().type === TokenType.COMMA) {
628
+ advance();
629
+ skipTrivia();
630
+ }
631
+ }
632
+ pushStmt(
633
+ {
634
+ type: 'VarDeclaration',
635
+ originalKeyword: variableKeyword,
636
+ variables,
637
+ start,
638
+ end: variables.length > 0 ? variables.at(-1).end : start + 3,
639
+ },
640
+ hasBlankLine,
641
+ );
642
+ continue;
643
+ }
644
+
645
+ if (t.type === TokenType.SET) {
646
+ const start = t.start;
647
+ const setKeyword = t.value;
648
+ advance();
649
+ skipTrivia();
650
+ let target = null;
651
+ if (current() && current().type === TokenType.VARIABLE) {
652
+ target = {
653
+ type: 'Variable',
654
+ value: current().value,
655
+ start: current().start,
656
+ end: current().end,
657
+ };
658
+ advance();
659
+ }
660
+ skipTrivia();
661
+ if (current() && current().type === TokenType.EQUALS) {
662
+ advance();
663
+ }
664
+ skipTrivia();
665
+ const value = parseExpression();
666
+ pushStmt(
667
+ {
668
+ type: 'SetStatement',
669
+ originalKeyword: setKeyword,
670
+ target,
671
+ value,
672
+ start,
673
+ end: value.end,
674
+ },
675
+ hasBlankLine,
676
+ );
677
+ continue;
678
+ }
679
+
680
+ if (t.type === TokenType.IF) {
681
+ const stmt = parseIfStatement();
682
+ pushStmt(stmt, hasBlankLine);
683
+ continue;
684
+ }
685
+
686
+ if (t.type === TokenType.FOR) {
687
+ const stmt = parseForStatement();
688
+ pushStmt(stmt, hasBlankLine);
689
+ continue;
690
+ }
691
+
692
+ if (t.type === TokenType.IDENTIFIER || t.type === TokenType.VARIABLE) {
693
+ const expr = parseExpression();
694
+ pushStmt(
695
+ { type: 'ExpressionStatement', expression: expr, start: expr.start, end: expr.end },
696
+ hasBlankLine,
697
+ );
698
+ continue;
699
+ }
700
+
701
+ if (
702
+ t.type === TokenType.ENDIF ||
703
+ t.type === TokenType.ELSE ||
704
+ t.type === TokenType.ELSEIF ||
705
+ t.type === TokenType.NEXT ||
706
+ t.type === TokenType.THEN ||
707
+ t.type === TokenType.DO
708
+ ) {
709
+ const kw = advance();
710
+ if (t.type === TokenType.ELSEIF) {
711
+ skipTrivia();
712
+ if (current() && current().type !== TokenType.BLOCK_CLOSE) {
713
+ parseExpression();
714
+ skipTrivia();
715
+ if (current() && current().type === TokenType.THEN) advance();
716
+ }
717
+ }
718
+ if (t.type === TokenType.NEXT) {
719
+ skipTrivia();
720
+ if (current() && current().type === TokenType.VARIABLE) advance();
721
+ }
722
+ pushStmt(
723
+ {
724
+ type: 'RawStatement',
725
+ value: kw.value,
726
+ keyword: kw.value,
727
+ start: kw.start,
728
+ end: kw.end,
729
+ },
730
+ hasBlankLine,
731
+ );
732
+ continue;
733
+ }
734
+
735
+ advance();
736
+ }
737
+
738
+ function parseIfStatement() {
739
+ const ifToken = current();
740
+ const start = ifToken.start;
741
+ advance();
742
+ skipTrivia();
743
+ const condition = parseExpression();
744
+ skipTrivia();
745
+ let thenKeyword = 'then';
746
+ if (current() && current().type === TokenType.THEN) {
747
+ thenKeyword = current().value;
748
+ advance();
749
+ }
750
+
751
+ const originalKeywords = { if: ifToken.value, then: thenKeyword };
752
+ const consequent = [];
753
+ const alternates = [];
754
+ let currentBlock = consequent;
755
+
756
+ while (pos < tokens.length) {
757
+ skipTrivia();
758
+ if (pos >= tokens.length) break;
759
+
760
+ const t = current();
761
+
762
+ if (t.type === TokenType.ENDIF) {
763
+ originalKeywords.endif = t.value;
764
+ const endToken = advance();
765
+ return {
766
+ type: 'IfStatement',
767
+ originalKeywords,
768
+ condition,
769
+ consequent,
770
+ alternates,
771
+ start,
772
+ end: endToken.end,
773
+ };
774
+ }
775
+
776
+ if (t.type === TokenType.ELSEIF) {
777
+ const elseifStart = t.start;
778
+ const elseifKeyword = t.value;
779
+ advance();
780
+ skipTrivia();
781
+ const elseifCondition = parseExpression();
782
+ skipTrivia();
783
+ let elseifThenKeyword = 'then';
784
+ if (current() && current().type === TokenType.THEN) {
785
+ elseifThenKeyword = current().value;
786
+ advance();
787
+ }
788
+ currentBlock = [];
789
+ alternates.push({
790
+ type: 'ElseIfClause',
791
+ originalKeywords: { elseif: elseifKeyword, then: elseifThenKeyword },
792
+ condition: elseifCondition,
793
+ body: currentBlock,
794
+ start: elseifStart,
795
+ end: elseifCondition.end,
796
+ });
797
+ continue;
798
+ }
799
+
800
+ if (t.type === TokenType.ELSE) {
801
+ const elseStart = t.start;
802
+ const elseKeyword = t.value;
803
+ advance();
804
+ currentBlock = [];
805
+ alternates.push({
806
+ type: 'ElseClause',
807
+ originalKeywords: { else: elseKeyword },
808
+ body: currentBlock,
809
+ start: elseStart,
810
+ end: elseStart + 4,
811
+ });
812
+ continue;
813
+ }
814
+
815
+ const innerStatements = parseInnerStatement();
816
+ if (innerStatements) {
817
+ currentBlock.push(innerStatements);
818
+ }
819
+ }
820
+
821
+ return {
822
+ type: 'IfStatement',
823
+ originalKeywords,
824
+ condition,
825
+ consequent,
826
+ alternates,
827
+ start,
828
+ end: pos < tokens.length ? tokens[pos - 1].end : start,
829
+ };
830
+ }
831
+
832
+ function parseForStatement() {
833
+ const forToken = current();
834
+ const start = forToken.start;
835
+ const originalKeywords = { for: forToken.value };
836
+ advance();
837
+ skipTrivia();
838
+
839
+ let counter = null;
840
+ if (current() && current().type === TokenType.VARIABLE) {
841
+ counter = {
842
+ type: 'Variable',
843
+ value: current().value,
844
+ start: current().start,
845
+ end: current().end,
846
+ };
847
+ advance();
848
+ }
849
+ skipTrivia();
850
+ if (current() && current().type === TokenType.EQUALS) {
851
+ advance();
852
+ }
853
+ skipTrivia();
854
+ const startExpr = parseExpression();
855
+ skipTrivia();
856
+
857
+ let direction = 'to';
858
+ if (current() && current().type === TokenType.DOWNTO) {
859
+ direction = 'downto';
860
+ originalKeywords.direction = current().value;
861
+ advance();
862
+ } else if (current() && current().type === TokenType.TO) {
863
+ originalKeywords.direction = current().value;
864
+ advance();
865
+ }
866
+ skipTrivia();
867
+ const endExpr = parseExpression();
868
+ skipTrivia();
869
+ if (current() && current().type === TokenType.DO) {
870
+ originalKeywords.do = current().value;
871
+ advance();
872
+ }
873
+
874
+ const body = [];
875
+ while (pos < tokens.length) {
876
+ skipTrivia();
877
+ if (pos >= tokens.length) break;
878
+
879
+ const t = current();
880
+ if (t.type === TokenType.NEXT) {
881
+ originalKeywords.next = t.value;
882
+ const nextToken = advance();
883
+ skipTrivia();
884
+ if (current() && current().type === TokenType.VARIABLE) {
885
+ advance();
886
+ }
887
+ return {
888
+ type: 'ForStatement',
889
+ originalKeywords,
890
+ counter,
891
+ startExpr,
892
+ endExpr,
893
+ direction,
894
+ body,
895
+ start,
896
+ end: nextToken.end,
897
+ };
898
+ }
899
+
900
+ const stmt = parseInnerStatement();
901
+ if (stmt) body.push(stmt);
902
+ }
903
+
904
+ return {
905
+ type: 'ForStatement',
906
+ originalKeywords,
907
+ counter,
908
+ startExpr,
909
+ endExpr,
910
+ direction,
911
+ body,
912
+ start,
913
+ end: pos < tokens.length ? tokens[pos - 1].end : start,
914
+ };
915
+ }
916
+
917
+ function parseInnerStatement() {
918
+ skipTrivia();
919
+ if (pos >= tokens.length) return null;
920
+
921
+ const t = current();
922
+
923
+ if (t.type === TokenType.COMMENT) {
924
+ advance();
925
+ return { type: 'Comment', value: t.value, start: t.start, end: t.end };
926
+ }
927
+
928
+ if (t.type === TokenType.VAR) {
929
+ const start = t.start;
930
+ const variableKeyword = t.value;
931
+ advance();
932
+ skipTrivia();
933
+ const variables = [];
934
+ while (current() && current().type === TokenType.VARIABLE) {
935
+ variables.push({
936
+ type: 'Variable',
937
+ value: current().value,
938
+ start: current().start,
939
+ end: current().end,
940
+ });
941
+ advance();
942
+ skipTrivia();
943
+ if (current() && current().type === TokenType.COMMA) {
944
+ advance();
945
+ skipTrivia();
946
+ }
947
+ }
948
+ return {
949
+ type: 'VarDeclaration',
950
+ originalKeyword: variableKeyword,
951
+ variables,
952
+ start,
953
+ end: variables.length > 0 ? variables.at(-1).end : start + 3,
954
+ };
955
+ }
956
+
957
+ if (t.type === TokenType.SET) {
958
+ const start = t.start;
959
+ const setKeyword = t.value;
960
+ advance();
961
+ skipTrivia();
962
+ let target = null;
963
+ if (current() && current().type === TokenType.VARIABLE) {
964
+ target = {
965
+ type: 'Variable',
966
+ value: current().value,
967
+ start: current().start,
968
+ end: current().end,
969
+ };
970
+ advance();
971
+ }
972
+ skipTrivia();
973
+ if (current() && current().type === TokenType.EQUALS) {
974
+ advance();
975
+ }
976
+ skipTrivia();
977
+ const value = parseExpression();
978
+ return {
979
+ type: 'SetStatement',
980
+ originalKeyword: setKeyword,
981
+ target,
982
+ value,
983
+ start,
984
+ end: value.end,
985
+ };
986
+ }
987
+
988
+ if (t.type === TokenType.IF) {
989
+ return parseIfStatement();
990
+ }
991
+
992
+ if (t.type === TokenType.FOR) {
993
+ return parseForStatement();
994
+ }
995
+
996
+ if (
997
+ t.type === TokenType.IDENTIFIER ||
998
+ t.type === TokenType.VARIABLE ||
999
+ t.type === TokenType.LPAREN
1000
+ ) {
1001
+ const expr = parseExpression();
1002
+ return {
1003
+ type: 'ExpressionStatement',
1004
+ expression: expr,
1005
+ start: expr.start,
1006
+ end: expr.end,
1007
+ };
1008
+ }
1009
+
1010
+ advance();
1011
+ return null;
1012
+ }
1013
+
1014
+ return statements;
1015
+ }
1016
+
1017
+ // ── Top-level parser ─────────────────────────────────────────────────────────
1018
+
1019
+ function parse(text) {
1020
+ const children = [];
1021
+ let index = 0;
1022
+ let contentStart = 0;
1023
+
1024
+ function pushContent(end) {
1025
+ if (end > contentStart) {
1026
+ children.push({
1027
+ type: 'Content',
1028
+ value: text.slice(contentStart, end),
1029
+ start: contentStart,
1030
+ end,
1031
+ });
1032
+ }
1033
+ }
1034
+
1035
+ const scriptOpenRe =
1036
+ /^<script\b(?=[^>]*\brunat\s*=\s*['"]server['"])(?=[^>]*\blanguage\s*=\s*['"]ampscript['"])[^>]*>/i;
1037
+ const scriptCloseRe = /^<\/script\s*>/i;
1038
+
1039
+ while (index < text.length) {
1040
+ if (text[index] === '<') {
1041
+ const slice = text.slice(index);
1042
+ const openMatch = scriptOpenRe.exec(slice);
1043
+ if (openMatch) {
1044
+ pushContent(index);
1045
+ const blockStart = index;
1046
+ const openTag = openMatch[0];
1047
+ index += openTag.length;
1048
+ const codeStart = index;
1049
+
1050
+ while (index < text.length) {
1051
+ if (text[index] === '<') {
1052
+ const closeMatch = scriptCloseRe.exec(text.slice(index));
1053
+ if (closeMatch) break;
1054
+ }
1055
+ index++;
1056
+ }
1057
+
1058
+ const codeEnd = index;
1059
+ const code = text.slice(codeStart, codeEnd);
1060
+ const closeMatch = scriptCloseRe.exec(text.slice(index));
1061
+ if (closeMatch) {
1062
+ index += closeMatch[0].length;
1063
+ }
1064
+
1065
+ const tokens = tokenizeBlock(code, codeStart);
1066
+ const stmts = parseStatements(tokens);
1067
+ markPrettierIgnore(stmts);
1068
+
1069
+ children.push({
1070
+ type: 'Block',
1071
+ syntax: 'script-tag',
1072
+ statements: stmts,
1073
+ start: blockStart,
1074
+ end: index,
1075
+ });
1076
+ contentStart = index;
1077
+ continue;
1078
+ }
1079
+ }
1080
+
1081
+ if (text[index] === '%' && text[index + 1] === '%' && text[index + 2] === '[') {
1082
+ pushContent(index);
1083
+ const blockStart = index;
1084
+ index += 3;
1085
+ const codeStart = index;
1086
+
1087
+ let depth = 1;
1088
+ while (index < text.length) {
1089
+ if (text[index] === '%' && text[index + 1] === '%' && text[index + 2] === '[') {
1090
+ depth++;
1091
+ index += 3;
1092
+ } else if (
1093
+ text[index] === ']' &&
1094
+ text[index + 1] === '%' &&
1095
+ text[index + 2] === '%'
1096
+ ) {
1097
+ depth--;
1098
+ if (depth === 0) break;
1099
+ index += 3;
1100
+ } else {
1101
+ index++;
1102
+ }
1103
+ }
1104
+
1105
+ const codeEnd = index;
1106
+ const code = text.slice(codeStart, codeEnd);
1107
+ index += 3;
1108
+
1109
+ const tokens = tokenizeBlock(code, codeStart);
1110
+ const stmts = parseStatements(tokens);
1111
+ markPrettierIgnore(stmts);
1112
+
1113
+ children.push({ type: 'Block', statements: stmts, start: blockStart, end: index });
1114
+ contentStart = index;
1115
+ continue;
1116
+ }
1117
+
1118
+ if (text[index] === '%' && text[index + 1] === '%' && text[index + 2] === '=') {
1119
+ pushContent(index);
1120
+ const inlineStart = index;
1121
+ index += 3;
1122
+ const codeStart = index;
1123
+
1124
+ while (index < text.length) {
1125
+ if (text[index] === '=' && text[index + 1] === '%' && text[index + 2] === '%') {
1126
+ break;
1127
+ }
1128
+ index++;
1129
+ }
1130
+
1131
+ const codeEnd = index;
1132
+ const code = text.slice(codeStart, codeEnd);
1133
+ index += 3;
1134
+
1135
+ const tokens = tokenizeBlock(code, codeStart);
1136
+ const exprStatements = parseStatements(tokens);
1137
+ markPrettierIgnore(exprStatements);
1138
+ const expression = exprStatements.length > 0 ? exprStatements[0] : null;
1139
+
1140
+ const expr =
1141
+ expression && expression.type === 'ExpressionStatement'
1142
+ ? expression.expression
1143
+ : expression;
1144
+
1145
+ children.push({
1146
+ type: 'InlineExpression',
1147
+ expression: expr,
1148
+ start: inlineStart,
1149
+ end: index,
1150
+ });
1151
+ contentStart = index;
1152
+ continue;
1153
+ }
1154
+
1155
+ index++;
1156
+ }
1157
+
1158
+ pushContent(text.length);
1159
+ markPrettierIgnore(children);
1160
+
1161
+ return { type: 'Document', children, start: 0, end: text.length };
1162
+ }
1163
+
1164
+ export { parse, tokenizeBlock, parseStatements, TokenType };