norn-cli 1.3.17 → 1.3.19

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/out/parser.js ADDED
@@ -0,0 +1,725 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.extractRetryOptions = extractRetryOptions;
4
+ exports.extractNamedRequests = extractNamedRequests;
5
+ exports.getNamedRequest = getNamedRequest;
6
+ exports.extractVariables = extractVariables;
7
+ exports.extractFileLevelVariables = extractFileLevelVariables;
8
+ exports.substituteVariables = substituteVariables;
9
+ exports.parserHttpRequest = parserHttpRequest;
10
+ exports.getRequestBlockAtLine = getRequestBlockAtLine;
11
+ exports.extractImports = extractImports;
12
+ exports.resolveImports = resolveImports;
13
+ const nornapiParser_1 = require("./nornapiParser");
14
+ /**
15
+ * Strip inline comment from a line.
16
+ * Finds # that is not inside quotes and removes everything after it.
17
+ */
18
+ function stripInlineComment(line) {
19
+ let inSingleQuote = false;
20
+ let inDoubleQuote = false;
21
+ for (let i = 0; i < line.length; i++) {
22
+ const char = line[i];
23
+ const prevChar = i > 0 ? line[i - 1] : '';
24
+ // Skip escaped quotes
25
+ if (prevChar === '\\') {
26
+ continue;
27
+ }
28
+ if (char === '"' && !inSingleQuote) {
29
+ inDoubleQuote = !inDoubleQuote;
30
+ }
31
+ else if (char === "'" && !inDoubleQuote) {
32
+ inSingleQuote = !inSingleQuote;
33
+ }
34
+ else if (char === '#' && !inSingleQuote && !inDoubleQuote) {
35
+ // Found an unquoted #, strip from here
36
+ return line.substring(0, i).trimEnd();
37
+ }
38
+ }
39
+ return line;
40
+ }
41
+ /**
42
+ * Extract retry and backoff options from a request line.
43
+ * Syntax: GET "URL" retry 3 backoff 500 ms
44
+ * Returns the cleaned line (without retry/backoff) and the parsed options.
45
+ */
46
+ function extractRetryOptions(line) {
47
+ let cleanedLine = line;
48
+ let retryCount;
49
+ let backoffMs;
50
+ // Extract retry count: "retry 3" or "retry 3 "
51
+ const retryMatch = line.match(/\bretry\s+(\d+)\b/i);
52
+ if (retryMatch) {
53
+ retryCount = parseInt(retryMatch[1], 10);
54
+ cleanedLine = cleanedLine.replace(retryMatch[0], '').trim();
55
+ }
56
+ // Extract backoff: "backoff 500 ms" or "backoff 2s" or "backoff 1000"
57
+ const backoffMatch = line.match(/\bbackoff\s+(\d+(?:\.\d+)?)\s*(s|ms|seconds?|milliseconds?)?\b/i);
58
+ if (backoffMatch) {
59
+ const value = parseFloat(backoffMatch[1]);
60
+ const unit = (backoffMatch[2] || 'ms').toLowerCase();
61
+ if (unit === 's' || unit.startsWith('second')) {
62
+ backoffMs = value * 1000;
63
+ }
64
+ else {
65
+ backoffMs = value;
66
+ }
67
+ cleanedLine = cleanedLine.replace(backoffMatch[0], '').trim();
68
+ }
69
+ // Default backoff if retry specified but not backoff
70
+ if (retryCount !== undefined && backoffMs === undefined) {
71
+ backoffMs = 1000; // Default 1 second
72
+ }
73
+ return { cleanedLine, retryCount, backoffMs };
74
+ }
75
+ /**
76
+ * Extracts all named requests from the document.
77
+ * Named requests are declared as:
78
+ * [RequestName] (no spaces allowed)
79
+ * or legacy: [Name: RequestName]
80
+ * HTTP_METHOD URL
81
+ * ...
82
+ */
83
+ function extractNamedRequests(text) {
84
+ const lines = text.split('\n');
85
+ const namedRequests = [];
86
+ // Match [SomeName] or [Name: SomeName] - name must not contain spaces
87
+ const nameRegex = /^\[(?:Name:\s*)?([a-zA-Z_][a-zA-Z0-9_-]*)\]$/;
88
+ const methodRegex = /^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+/i;
89
+ for (let i = 0; i < lines.length; i++) {
90
+ const line = lines[i].trim();
91
+ const nameMatch = line.match(nameRegex);
92
+ if (nameMatch) {
93
+ const name = nameMatch[1].trim();
94
+ const contentStartLine = i + 1;
95
+ // Find the end of this request (next [Name], sequence, ###, or end of file)
96
+ let endLine = lines.length - 1;
97
+ for (let j = contentStartLine; j < lines.length; j++) {
98
+ const scanLine = lines[j].trim();
99
+ if (scanLine.match(/^\[/) ||
100
+ scanLine.match(/^sequence\s+/) ||
101
+ scanLine.startsWith('###')) {
102
+ endLine = j - 1;
103
+ break;
104
+ }
105
+ }
106
+ // Trim trailing empty lines
107
+ while (endLine > contentStartLine && lines[endLine].trim() === '') {
108
+ endLine--;
109
+ }
110
+ const content = lines.slice(contentStartLine, endLine + 1).join('\n');
111
+ // Only add if there's actual content with an HTTP method
112
+ if (content.split('\n').some(l => methodRegex.test(l.trim()))) {
113
+ namedRequests.push({
114
+ name,
115
+ content,
116
+ startLine: i,
117
+ endLine
118
+ });
119
+ }
120
+ // Skip to end of this request
121
+ i = endLine;
122
+ }
123
+ }
124
+ return namedRequests;
125
+ }
126
+ /**
127
+ * Gets a named request by its name
128
+ */
129
+ function getNamedRequest(text, name) {
130
+ const requests = extractNamedRequests(text);
131
+ return requests.find(r => r.name === name ||
132
+ r.name.toLowerCase() === name.toLowerCase());
133
+ }
134
+ /**
135
+ * Patterns for runtime-computed variable values that should not be treated as static vars.
136
+ */
137
+ const RUNTIME_VARIABLE_VALUE_PATTERNS = [
138
+ /^run\s+/i, // var x = run ... (scripts/sequences)
139
+ /^\$\d+/, // var x = $1... (response captures)
140
+ /^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+/i // var x = METHOD url (request captures)
141
+ ];
142
+ function isRuntimeComputedVariableValue(value) {
143
+ return RUNTIME_VARIABLE_VALUE_PATTERNS.some(pattern => pattern.test(value));
144
+ }
145
+ function isSequenceStartDeclaration(line) {
146
+ return /^(?:test\s+)?sequence\s+[a-zA-Z_][a-zA-Z0-9_-]*(?:\s*\([^)]*\))?\s*$/i.test(line);
147
+ }
148
+ function isSequenceEndDeclaration(line) {
149
+ return /^end\s+sequence\s*$/i.test(line);
150
+ }
151
+ /**
152
+ * Extracts all STATIC variables from the document.
153
+ * Variables are declared as: var variableName = value
154
+ *
155
+ * Does NOT extract runtime-computed values:
156
+ * - var x = run ... (script commands)
157
+ * - var x = $1... (response captures)
158
+ * - var x = GET/POST/... url (request captures)
159
+ */
160
+ function extractVariables(text) {
161
+ const variables = {};
162
+ // Allow optional leading whitespace for indented variables (inside sequences)
163
+ const variableRegex = /^\s*var\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.+)$/gm;
164
+ let match;
165
+ while ((match = variableRegex.exec(text)) !== null) {
166
+ let value = match[2].trim();
167
+ // Skip runtime-computed values
168
+ const isRuntimeValue = isRuntimeComputedVariableValue(value);
169
+ if (isRuntimeValue) {
170
+ continue;
171
+ }
172
+ // Strip quotes from string literals
173
+ if ((value.startsWith('"') && value.endsWith('"')) ||
174
+ (value.startsWith("'") && value.endsWith("'"))) {
175
+ value = value.slice(1, -1);
176
+ }
177
+ variables[match[1]] = value;
178
+ }
179
+ return variables;
180
+ }
181
+ /**
182
+ * Extracts only file-level variables (outside sequences).
183
+ * Used for diagnostics to understand variable scope.
184
+ */
185
+ function extractFileLevelVariables(text) {
186
+ const variables = {};
187
+ const lines = text.split('\n');
188
+ const variableRegex = /^\s*var\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.+)$/;
189
+ // Use depth so malformed files don't accidentally leak inner vars to file scope.
190
+ let sequenceDepth = 0;
191
+ for (const line of lines) {
192
+ const trimmed = line.trim();
193
+ // Track sequence boundaries
194
+ if (isSequenceStartDeclaration(trimmed)) {
195
+ sequenceDepth++;
196
+ continue;
197
+ }
198
+ if (isSequenceEndDeclaration(trimmed)) {
199
+ sequenceDepth = Math.max(0, sequenceDepth - 1);
200
+ continue;
201
+ }
202
+ // Only extract variables outside sequences
203
+ if (sequenceDepth === 0) {
204
+ const match = line.match(variableRegex);
205
+ if (match) {
206
+ let value = match[2].trim();
207
+ // Skip runtime-computed values
208
+ if (isRuntimeComputedVariableValue(value)) {
209
+ continue;
210
+ }
211
+ // Strip quotes from string literals
212
+ if ((value.startsWith('"') && value.endsWith('"')) ||
213
+ (value.startsWith("'") && value.endsWith("'"))) {
214
+ value = value.slice(1, -1);
215
+ }
216
+ variables[match[1]] = value;
217
+ }
218
+ }
219
+ }
220
+ return variables;
221
+ }
222
+ /**
223
+ * Gets a value from an object using a dot-notation path.
224
+ * Supports array indexing with brackets: obj.array[0].property
225
+ */
226
+ function getNestedValue(obj, path) {
227
+ if (!path || obj === null || obj === undefined) {
228
+ return obj;
229
+ }
230
+ // Convert [0] to .0 and split by dots
231
+ const parts = path.replace(/\[(\d+)\]/g, '.$1').split('.').filter(p => p !== '');
232
+ let current = obj;
233
+ for (const part of parts) {
234
+ if (current === null || current === undefined) {
235
+ return undefined;
236
+ }
237
+ current = current[part];
238
+ }
239
+ return current;
240
+ }
241
+ /**
242
+ * Converts a value to a string for use in variable substitution.
243
+ */
244
+ function valueToString(value) {
245
+ if (value === null) {
246
+ return 'null';
247
+ }
248
+ if (value === undefined) {
249
+ return '';
250
+ }
251
+ if (typeof value === 'object') {
252
+ return JSON.stringify(value);
253
+ }
254
+ return String(value);
255
+ }
256
+ /**
257
+ * Substitutes variables in text.
258
+ * Variables are referenced as: {{variableName}} or {{variableName.property.path}}
259
+ * Supports nested property access for JSON objects stored as strings.
260
+ * Also supports object values directly (e.g., from var user = GET responses).
261
+ * Also supports $N response references (e.g., {{$1.status}}, {{$2.body.id}})
262
+ */
263
+ function substituteVariables(text, variables) {
264
+ // Match {{varName}} or {{varName.path.to.property}} or {{varName[0].property}}
265
+ // Also match {{$N}} or {{$N.path}} for response references
266
+ return text.replace(/\{\{(\$\d+|[a-zA-Z_][a-zA-Z0-9_]*)((?:\.[a-zA-Z_][a-zA-Z0-9_-]*|\[\d+\])*)\}\}/g, (match, varName, pathPart) => {
267
+ if (!(varName in variables)) {
268
+ return match; // Variable not found, return original
269
+ }
270
+ const value = variables[varName];
271
+ // If value is already an object (e.g., HttpResponse), navigate directly
272
+ if (typeof value === 'object' && value !== null) {
273
+ if (pathPart) {
274
+ const path = pathPart.replace(/^\./, ''); // Remove leading dot
275
+ const nestedValue = getNestedValue(value, path);
276
+ return valueToString(nestedValue);
277
+ }
278
+ // No path, stringify the whole object
279
+ return valueToString(value);
280
+ }
281
+ // If there's a path and value is a string, try to parse as JSON and navigate
282
+ if (pathPart && typeof value === 'string') {
283
+ try {
284
+ const parsed = JSON.parse(value);
285
+ const path = pathPart.replace(/^\./, ''); // Remove leading dot
286
+ const nestedValue = getNestedValue(parsed, path);
287
+ return valueToString(nestedValue);
288
+ }
289
+ catch {
290
+ // Not valid JSON, return original value
291
+ return value;
292
+ }
293
+ }
294
+ // Simple substitution
295
+ return String(value);
296
+ });
297
+ }
298
+ /**
299
+ * Extracts a single request block from the text.
300
+ * Stops parsing at ### delimiter (REST Client style separator).
301
+ * A blank line separates headers from body (standard HTTP convention).
302
+ */
303
+ function parserHttpRequest(text, variables = {}) {
304
+ // Substitute variables first
305
+ const substitutedText = substituteVariables(text, variables);
306
+ // Split by ### to get only the first request block
307
+ const requestBlock = substitutedText.split(/^###/m)[0];
308
+ const allLines = requestBlock.split('\n');
309
+ // Find the first line that looks like a request (METHOD URL)
310
+ const methodRegex = /^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+/i;
311
+ let requestLineIndex = -1;
312
+ for (let i = 0; i < allLines.length; i++) {
313
+ const trimmed = allLines[i].trim();
314
+ if (methodRegex.test(trimmed)) {
315
+ requestLineIndex = i;
316
+ break;
317
+ }
318
+ }
319
+ if (requestLineIndex === -1) {
320
+ throw new Error('No valid HTTP method found');
321
+ }
322
+ // Strip inline comments from the request line before parsing
323
+ let requestLine = stripInlineComment(allLines[requestLineIndex].trim());
324
+ // Extract retry/backoff options before parsing method/URL
325
+ const { cleanedLine, retryCount, backoffMs } = extractRetryOptions(requestLine);
326
+ requestLine = cleanedLine;
327
+ const [method, ...urlParts] = requestLine.split(' ');
328
+ let url = urlParts.join(' ');
329
+ // Handle quoted URLs - remove the quotes
330
+ if ((url.startsWith('"') && url.endsWith('"')) || (url.startsWith("'") && url.endsWith("'"))) {
331
+ url = url.slice(1, -1);
332
+ }
333
+ const headers = {};
334
+ let bodyStartIndex = -1;
335
+ let foundBlankLine = false;
336
+ // Parse headers (lines after request line until blank line or non-header)
337
+ // Headers must have format "Name: Value" where Name is a valid token
338
+ const headerRegex = /^([A-Za-z0-9\-_]+)\s*:\s*(.+)$/;
339
+ for (let i = requestLineIndex + 1; i < allLines.length; i++) {
340
+ const line = allLines[i].trim();
341
+ // Skip comment lines and variable declarations in header section
342
+ if (line.startsWith('#') || line.startsWith('var ')) {
343
+ continue;
344
+ }
345
+ // Blank line marks end of headers
346
+ if (line === '') {
347
+ foundBlankLine = true;
348
+ continue;
349
+ }
350
+ // If we've seen a blank line, everything after is body
351
+ if (foundBlankLine) {
352
+ bodyStartIndex = i;
353
+ break;
354
+ }
355
+ const headerMatch = line.match(headerRegex);
356
+ if (headerMatch) {
357
+ headers[headerMatch[1]] = headerMatch[2].trim();
358
+ }
359
+ else {
360
+ // Not a header format, must be start of body
361
+ bodyStartIndex = i;
362
+ break;
363
+ }
364
+ }
365
+ // Extract body - join remaining non-empty, non-comment lines
366
+ // Stop at next request boundary: [SomeName], sequence, or HTTP method
367
+ let body;
368
+ if (bodyStartIndex > 0) {
369
+ const bodyLines = [];
370
+ const boundaryRegex = /^\[.*\]$|^sequence\s+|^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+/i;
371
+ for (let i = bodyStartIndex; i < allLines.length; i++) {
372
+ const line = allLines[i].trim();
373
+ // Stop at request boundaries
374
+ if (boundaryRegex.test(line)) {
375
+ break;
376
+ }
377
+ // Include non-empty, non-comment, non-var lines
378
+ if (line && !line.startsWith('#') && !line.startsWith('var ')) {
379
+ bodyLines.push(line);
380
+ }
381
+ }
382
+ body = bodyLines.join('\n').trim();
383
+ if (body === '') {
384
+ body = undefined;
385
+ }
386
+ }
387
+ return { method: method.toUpperCase(), url, headers, body, retryCount, backoffMs };
388
+ }
389
+ const METHOD_REGEX = /^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|TRACE|CONNECT)\s+/i;
390
+ /**
391
+ * Checks if a line is the start of a new request (HTTP method line).
392
+ */
393
+ function isRequestLine(line) {
394
+ return METHOD_REGEX.test(line.trim());
395
+ }
396
+ /**
397
+ * Checks if a line is a variable declaration.
398
+ */
399
+ function isVariableLine(line) {
400
+ return /^var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=/.test(line.trim());
401
+ }
402
+ /**
403
+ * Checks if a line is the start of a sequence block.
404
+ */
405
+ function isSequenceLine(line) {
406
+ return /^sequence\s+[a-zA-Z_][a-zA-Z0-9_-]*/.test(line.trim());
407
+ }
408
+ /**
409
+ * Finds the request block at a given line number.
410
+ * Request blocks are detected by HTTP method keywords (GET, POST, etc.).
411
+ * No need for ### separators - we detect boundaries automatically.
412
+ */
413
+ function getRequestBlockAtLine(text, lineNumber) {
414
+ const lines = text.split('\n');
415
+ // First, find the request line at or before the given line number
416
+ let requestStartLine = -1;
417
+ for (let i = lineNumber; i >= 0; i--) {
418
+ if (isRequestLine(lines[i])) {
419
+ requestStartLine = i;
420
+ break;
421
+ }
422
+ }
423
+ // If no request line found above, search below (user might be on a comment above the request)
424
+ if (requestStartLine === -1) {
425
+ for (let i = lineNumber; i < lines.length; i++) {
426
+ if (isRequestLine(lines[i])) {
427
+ requestStartLine = i;
428
+ break;
429
+ }
430
+ }
431
+ }
432
+ if (requestStartLine === -1) {
433
+ return ''; // No request found
434
+ }
435
+ // Find where this request block ends (next request line or end of file)
436
+ let endLine = lines.length;
437
+ for (let i = requestStartLine + 1; i < lines.length; i++) {
438
+ const line = lines[i].trim();
439
+ // End at next request line, ### separator (still support it), sequence block, or variable declaration at file level
440
+ if (isRequestLine(lines[i]) || line.startsWith('###') || isSequenceLine(lines[i])) {
441
+ endLine = i;
442
+ break;
443
+ }
444
+ }
445
+ // Include any comments/blank lines immediately before the request
446
+ let startLine = requestStartLine;
447
+ for (let i = requestStartLine - 1; i >= 0; i--) {
448
+ const line = lines[i].trim();
449
+ if (line === '' || line.startsWith('#')) {
450
+ // Don't include if it's a ### separator
451
+ if (line.startsWith('###')) {
452
+ break;
453
+ }
454
+ startLine = i;
455
+ }
456
+ else if (isVariableLine(lines[i])) {
457
+ // Stop at variable declarations
458
+ break;
459
+ }
460
+ else if (isRequestLine(lines[i])) {
461
+ // Stop at previous request
462
+ break;
463
+ }
464
+ else {
465
+ break;
466
+ }
467
+ }
468
+ return lines.slice(startLine, endLine).join('\n');
469
+ }
470
+ /**
471
+ * Extracts all import statements from the document.
472
+ * Import syntax:
473
+ * import "path/to/file.norn" (quoted)
474
+ * import './path/to/file.norn' (quoted)
475
+ * import ./path/to/file.norn (unquoted)
476
+ */
477
+ function extractImports(text) {
478
+ const lines = text.split('\n');
479
+ const imports = [];
480
+ // Match import "path" or import 'path' (quoted)
481
+ const quotedImportRegex = /^\s*import\s+["'](.+?)["']\s*$/;
482
+ // Match import path (unquoted - path without spaces)
483
+ const unquotedImportRegex = /^\s*import\s+(\S+)\s*$/;
484
+ for (let i = 0; i < lines.length; i++) {
485
+ const line = lines[i];
486
+ // Try quoted first
487
+ let match = line.match(quotedImportRegex);
488
+ if (match) {
489
+ imports.push({
490
+ path: match[1],
491
+ lineNumber: i
492
+ });
493
+ continue;
494
+ }
495
+ // Try unquoted
496
+ match = line.match(unquotedImportRegex);
497
+ if (match) {
498
+ imports.push({
499
+ path: match[1],
500
+ lineNumber: i
501
+ });
502
+ }
503
+ }
504
+ return imports;
505
+ }
506
+ /**
507
+ * Resolves all imports in a .norn file and returns the combined imported content.
508
+ * Only extracts named requests and sequences from imported files (not variables).
509
+ * Also handles .nornapi imports for header groups and endpoints.
510
+ * Handles circular imports by tracking the active import stack.
511
+ * Re-imports of already loaded files are treated as no-ops.
512
+ *
513
+ * @param text - The source text to extract imports from
514
+ * @param baseDir - The directory of the current file (for resolving relative paths)
515
+ * @param readFile - Function to read file contents (allows async filesystem access)
516
+ * @param alreadyImported - Set of already loaded import paths (for de-duplication)
517
+ * @param importStack - Set of import paths currently being resolved (for true cycle detection)
518
+ */
519
+ async function resolveImports(text, baseDir, readFile, alreadyImported = new Set(), importStack = new Set()) {
520
+ const imports = extractImports(text);
521
+ const errors = [];
522
+ const importedContents = [];
523
+ const resolvedPaths = [];
524
+ const headerGroups = [];
525
+ const endpoints = [];
526
+ // Track sources for duplicate detection
527
+ const headerGroupSources = new Map();
528
+ const endpointSources = new Map();
529
+ const namedRequestSources = new Map();
530
+ const sequenceSources = new Map();
531
+ for (const imp of imports) {
532
+ // Resolve the path relative to baseDir
533
+ const path = await import('path');
534
+ const absolutePath = path.resolve(baseDir, imp.path);
535
+ // Circular import only when the file is in the active import stack.
536
+ if (importStack.has(absolutePath)) {
537
+ errors.push({
538
+ path: imp.path,
539
+ error: `Circular import detected`,
540
+ lineNumber: imp.lineNumber
541
+ });
542
+ continue;
543
+ }
544
+ // Duplicate import of an already loaded file: skip silently.
545
+ if (alreadyImported.has(absolutePath)) {
546
+ continue;
547
+ }
548
+ try {
549
+ const content = await readFile(absolutePath);
550
+ alreadyImported.add(absolutePath);
551
+ importStack.add(absolutePath);
552
+ resolvedPaths.push(absolutePath);
553
+ // Check if this is a .nornapi file
554
+ if (imp.path.endsWith('.nornapi')) {
555
+ // Parse the .nornapi file
556
+ const apiDef = (0, nornapiParser_1.parseNornApiFile)(content);
557
+ // Check for duplicate header groups
558
+ for (const group of apiDef.headerGroups) {
559
+ const existingSource = headerGroupSources.get(group.name);
560
+ if (existingSource) {
561
+ errors.push({
562
+ path: imp.path,
563
+ error: `Duplicate header group '${group.name}': already defined in '${existingSource}'`,
564
+ lineNumber: imp.lineNumber
565
+ });
566
+ }
567
+ else {
568
+ headerGroupSources.set(group.name, imp.path);
569
+ headerGroups.push(group);
570
+ }
571
+ }
572
+ // Check for duplicate endpoints
573
+ for (const endpoint of apiDef.endpoints) {
574
+ const existingSource = endpointSources.get(endpoint.name);
575
+ if (existingSource) {
576
+ errors.push({
577
+ path: imp.path,
578
+ error: `Duplicate endpoint '${endpoint.name}': already defined in '${existingSource}'`,
579
+ lineNumber: imp.lineNumber
580
+ });
581
+ }
582
+ else {
583
+ endpointSources.set(endpoint.name, imp.path);
584
+ endpoints.push(endpoint);
585
+ }
586
+ }
587
+ continue;
588
+ }
589
+ // Regular .norn file - process as before
590
+ // Recursively resolve imports in the imported file
591
+ const importDir = path.dirname(absolutePath);
592
+ const nestedResult = await resolveImports(content, importDir, readFile, alreadyImported, importStack);
593
+ // Add nested errors
594
+ errors.push(...nestedResult.errors);
595
+ resolvedPaths.push(...nestedResult.resolvedPaths);
596
+ // Add nested API definitions with duplicate checking
597
+ for (const group of nestedResult.headerGroups) {
598
+ const existingSource = headerGroupSources.get(group.name);
599
+ if (existingSource) {
600
+ errors.push({
601
+ path: imp.path,
602
+ error: `Duplicate header group '${group.name}': already defined in '${existingSource}'`,
603
+ lineNumber: imp.lineNumber
604
+ });
605
+ }
606
+ else {
607
+ headerGroupSources.set(group.name, imp.path);
608
+ headerGroups.push(group);
609
+ }
610
+ }
611
+ for (const endpoint of nestedResult.endpoints) {
612
+ const existingSource = endpointSources.get(endpoint.name);
613
+ if (existingSource) {
614
+ errors.push({
615
+ path: imp.path,
616
+ error: `Duplicate endpoint '${endpoint.name}': already defined in '${existingSource}'`,
617
+ lineNumber: imp.lineNumber
618
+ });
619
+ }
620
+ else {
621
+ endpointSources.set(endpoint.name, imp.path);
622
+ endpoints.push(endpoint);
623
+ }
624
+ }
625
+ // Add nested imported content first (dependencies first)
626
+ if (nestedResult.importedContent) {
627
+ importedContents.push(nestedResult.importedContent);
628
+ }
629
+ // Extract only file-level variables to resolve references in imported requests/sequences.
630
+ // Sequence-local vars must stay runtime-evaluated and must not be pre-baked.
631
+ const importedVariables = extractFileLevelVariables(content);
632
+ const importedNamedRequests = extractNamedRequests(content);
633
+ const importedSequences = extractSequencesFromText(content);
634
+ // Reconstruct named requests with variables already substituted (with duplicate checking)
635
+ for (const req of importedNamedRequests) {
636
+ const lowerName = req.name.toLowerCase();
637
+ const existingSource = namedRequestSources.get(lowerName);
638
+ if (existingSource) {
639
+ errors.push({
640
+ path: imp.path,
641
+ error: `Duplicate named request '${req.name}': already defined in '${existingSource}'`,
642
+ lineNumber: imp.lineNumber
643
+ });
644
+ }
645
+ else {
646
+ namedRequestSources.set(lowerName, imp.path);
647
+ const resolvedContent = substituteVariables(req.content, importedVariables);
648
+ importedContents.push(`[${req.name}]\n${resolvedContent}`);
649
+ }
650
+ }
651
+ // Reconstruct sequences with variables already substituted (with duplicate checking)
652
+ for (const seq of importedSequences) {
653
+ const lowerName = seq.name.toLowerCase();
654
+ const existingSource = sequenceSources.get(lowerName);
655
+ if (existingSource) {
656
+ errors.push({
657
+ path: imp.path,
658
+ error: `Duplicate sequence '${seq.name}': already defined in '${existingSource}'`,
659
+ lineNumber: imp.lineNumber
660
+ });
661
+ }
662
+ else {
663
+ // Store the ABSOLUTE path so script paths can be resolved correctly
664
+ sequenceSources.set(lowerName, absolutePath);
665
+ const resolvedContent = substituteVariables(seq.content, importedVariables);
666
+ importedContents.push(`sequence ${seq.name}\n${resolvedContent}\nend sequence`);
667
+ }
668
+ }
669
+ }
670
+ catch (error) {
671
+ errors.push({
672
+ path: imp.path,
673
+ error: error.message || 'Failed to read file',
674
+ lineNumber: imp.lineNumber
675
+ });
676
+ }
677
+ finally {
678
+ importStack.delete(absolutePath);
679
+ }
680
+ }
681
+ return {
682
+ importedContent: importedContents.join('\n\n'),
683
+ errors,
684
+ resolvedPaths,
685
+ headerGroups,
686
+ endpoints,
687
+ sequenceSources
688
+ };
689
+ }
690
+ /**
691
+ * Helper function to extract sequences from text without importing sequenceRunner
692
+ * (avoids circular dependencies)
693
+ */
694
+ function extractSequencesFromText(text) {
695
+ const lines = text.split('\n');
696
+ const sequences = [];
697
+ let currentSequence = null;
698
+ for (let i = 0; i < lines.length; i++) {
699
+ const line = lines[i].trim();
700
+ // Check for sequence start
701
+ const sequenceMatch = line.match(/^sequence\s+([a-zA-Z_][a-zA-Z0-9_-]*)$/);
702
+ if (sequenceMatch) {
703
+ currentSequence = {
704
+ name: sequenceMatch[1],
705
+ lines: []
706
+ };
707
+ continue;
708
+ }
709
+ // Check for sequence end
710
+ if (line === 'end sequence' && currentSequence) {
711
+ sequences.push({
712
+ name: currentSequence.name,
713
+ content: currentSequence.lines.join('\n')
714
+ });
715
+ currentSequence = null;
716
+ continue;
717
+ }
718
+ // Add line to current sequence
719
+ if (currentSequence) {
720
+ currentSequence.lines.push(lines[i]);
721
+ }
722
+ }
723
+ return sequences;
724
+ }
725
+ //# sourceMappingURL=parser.js.map