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/AGENTS.md +72 -0
- package/CHANGELOG.md +39 -1
- package/README.md +7 -3
- package/dist/cli.js +113 -54
- package/out/assertionRunner.js +537 -0
- package/out/cli/colors.js +129 -0
- package/out/cli/formatters/assertion.js +75 -0
- package/out/cli/formatters/index.js +23 -0
- package/out/cli/formatters/response.js +106 -0
- package/out/cli/formatters/summary.js +187 -0
- package/out/cli/redaction.js +237 -0
- package/out/cli/reporters/html.js +634 -0
- package/out/cli/reporters/index.js +22 -0
- package/out/cli/reporters/junit.js +211 -0
- package/out/cli.js +926 -0
- package/out/codeLensProvider.js +254 -0
- package/out/compareContentProvider.js +85 -0
- package/out/completionProvider.js +1886 -0
- package/out/contractDecorationProvider.js +243 -0
- package/out/coverageCalculator.js +756 -0
- package/out/coveragePanel.js +542 -0
- package/out/diagnosticProvider.js +980 -0
- package/out/environmentProvider.js +373 -0
- package/out/extension.js +1025 -0
- package/out/httpClient.js +269 -0
- package/out/jsonFileReader.js +320 -0
- package/out/nornapiParser.js +326 -0
- package/out/parser.js +725 -0
- package/out/responsePanel.js +4674 -0
- package/out/schemaGenerator.js +393 -0
- package/out/scriptRunner.js +419 -0
- package/out/sequenceRunner.js +3046 -0
- package/out/swaggerParser.js +339 -0
- package/out/test/extension.test.js +48 -0
- package/out/testProvider.js +658 -0
- package/out/validationCache.js +245 -0
- package/package.json +1 -1
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
|