norn-cli 1.3.16 → 1.3.18
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 +34 -1
- package/README.md +4 -2
- package/dist/cli.js +135 -63
- 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
|
@@ -0,0 +1,980 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.DiagnosticProvider = void 0;
|
|
37
|
+
const vscode = __importStar(require("vscode"));
|
|
38
|
+
const path = __importStar(require("path"));
|
|
39
|
+
const fs = __importStar(require("fs"));
|
|
40
|
+
const parser_1 = require("./parser");
|
|
41
|
+
const sequenceRunner_1 = require("./sequenceRunner");
|
|
42
|
+
const environmentProvider_1 = require("./environmentProvider");
|
|
43
|
+
const nornapiParser_1 = require("./nornapiParser");
|
|
44
|
+
/**
|
|
45
|
+
* Extracts variables defined within a specific sequence, including:
|
|
46
|
+
* - var statements
|
|
47
|
+
* - var x = run Sequence (capture from returned sequences)
|
|
48
|
+
* - Response captures (var x = $1.body)
|
|
49
|
+
* - Sequence parameters (from the sequence declaration)
|
|
50
|
+
*/
|
|
51
|
+
function getSequenceLocalVariables(sequenceContent, parameters) {
|
|
52
|
+
const vars = new Set();
|
|
53
|
+
// Add sequence parameters as local variables
|
|
54
|
+
if (parameters) {
|
|
55
|
+
for (const param of parameters) {
|
|
56
|
+
vars.add(param.name);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const lines = sequenceContent.split('\n');
|
|
60
|
+
for (const line of lines) {
|
|
61
|
+
const trimmed = line.trim();
|
|
62
|
+
// Match any var assignment
|
|
63
|
+
const varMatch = trimmed.match(/^var\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*=/);
|
|
64
|
+
if (varMatch) {
|
|
65
|
+
vars.add(varMatch[1]);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return vars;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Check if a line is a comment (starts with #)
|
|
72
|
+
*/
|
|
73
|
+
function isCommentLine(line) {
|
|
74
|
+
return line.trim().startsWith('#');
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Strip inline comment from a line.
|
|
78
|
+
* Finds # that is not inside quotes and removes everything after it.
|
|
79
|
+
*/
|
|
80
|
+
function stripInlineComment(line) {
|
|
81
|
+
let inSingleQuote = false;
|
|
82
|
+
let inDoubleQuote = false;
|
|
83
|
+
for (let i = 0; i < line.length; i++) {
|
|
84
|
+
const char = line[i];
|
|
85
|
+
const prevChar = i > 0 ? line[i - 1] : '';
|
|
86
|
+
// Skip escaped quotes
|
|
87
|
+
if (prevChar === '\\') {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (char === '"' && !inSingleQuote) {
|
|
91
|
+
inDoubleQuote = !inDoubleQuote;
|
|
92
|
+
}
|
|
93
|
+
else if (char === "'" && !inDoubleQuote) {
|
|
94
|
+
inSingleQuote = !inSingleQuote;
|
|
95
|
+
}
|
|
96
|
+
else if (char === '#' && !inSingleQuote && !inDoubleQuote) {
|
|
97
|
+
// Found an unquoted #, strip from here
|
|
98
|
+
return line.substring(0, i).trimEnd();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return line;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Tokenize print statement content into individual tokens.
|
|
105
|
+
* Respects quoted strings and splits on operators/whitespace.
|
|
106
|
+
*/
|
|
107
|
+
function tokenizePrintContent(content) {
|
|
108
|
+
const tokens = [];
|
|
109
|
+
let current = '';
|
|
110
|
+
let currentStart = 0;
|
|
111
|
+
let inString = false;
|
|
112
|
+
let stringChar = '';
|
|
113
|
+
let i = 0;
|
|
114
|
+
const pushToken = () => {
|
|
115
|
+
if (current.trim()) {
|
|
116
|
+
tokens.push({ text: current.trim(), start: currentStart + (current.length - current.trimStart().length) });
|
|
117
|
+
}
|
|
118
|
+
current = '';
|
|
119
|
+
};
|
|
120
|
+
while (i < content.length) {
|
|
121
|
+
const char = content[i];
|
|
122
|
+
if (inString) {
|
|
123
|
+
current += char;
|
|
124
|
+
if (char === stringChar && content[i - 1] !== '\\') {
|
|
125
|
+
inString = false;
|
|
126
|
+
pushToken();
|
|
127
|
+
}
|
|
128
|
+
i++;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
// Start of string
|
|
132
|
+
if (char === '"' || char === "'") {
|
|
133
|
+
pushToken();
|
|
134
|
+
inString = true;
|
|
135
|
+
stringChar = char;
|
|
136
|
+
current = char;
|
|
137
|
+
currentStart = i;
|
|
138
|
+
i++;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
// Operators and whitespace are delimiters
|
|
142
|
+
if (/[\s+\-*/|&<>=!]/.test(char)) {
|
|
143
|
+
pushToken();
|
|
144
|
+
// If it's an operator, add it as its own token
|
|
145
|
+
if (/[+\-*/|&<>=!]/.test(char)) {
|
|
146
|
+
tokens.push({ text: char, start: i });
|
|
147
|
+
}
|
|
148
|
+
currentStart = i + 1;
|
|
149
|
+
i++;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
// Regular character
|
|
153
|
+
if (current === '') {
|
|
154
|
+
currentStart = i;
|
|
155
|
+
}
|
|
156
|
+
current += char;
|
|
157
|
+
i++;
|
|
158
|
+
}
|
|
159
|
+
// Push any remaining token
|
|
160
|
+
pushToken();
|
|
161
|
+
return tokens;
|
|
162
|
+
}
|
|
163
|
+
class DiagnosticProvider {
|
|
164
|
+
diagnosticCollection;
|
|
165
|
+
constructor() {
|
|
166
|
+
this.diagnosticCollection = vscode.languages.createDiagnosticCollection('norn');
|
|
167
|
+
}
|
|
168
|
+
updateDiagnostics(document) {
|
|
169
|
+
if (document.languageId === 'nornenv') {
|
|
170
|
+
this.updateNornenvDiagnostics(document);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (document.languageId === 'nornapi') {
|
|
174
|
+
this.updateNornapiDiagnostics(document);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (document.languageId !== 'norn') {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const diagnostics = [];
|
|
181
|
+
const text = document.getText();
|
|
182
|
+
// Extract file-level variables (outside sequences) + environment
|
|
183
|
+
const fileLevelVariables = (0, parser_1.extractFileLevelVariables)(text);
|
|
184
|
+
const envVariables = (0, environmentProvider_1.getEnvironmentVariables)();
|
|
185
|
+
const globalVariables = { ...envVariables, ...fileLevelVariables };
|
|
186
|
+
// Extract sequences for scope-aware checking
|
|
187
|
+
const sequences = (0, sequenceRunner_1.extractSequences)(text);
|
|
188
|
+
const lines = text.split('\n');
|
|
189
|
+
// Check for duplicate sequence declarations
|
|
190
|
+
const declaredSequences = new Map(); // seqName -> first declaration line
|
|
191
|
+
for (const seq of sequences) {
|
|
192
|
+
if (declaredSequences.has(seq.name)) {
|
|
193
|
+
const firstLine = declaredSequences.get(seq.name);
|
|
194
|
+
const line = lines[seq.startLine];
|
|
195
|
+
// Find the sequence name position in the line
|
|
196
|
+
const nameStart = line.indexOf(seq.name);
|
|
197
|
+
const range = new vscode.Range(new vscode.Position(seq.startLine, nameStart), new vscode.Position(seq.startLine, nameStart + seq.name.length));
|
|
198
|
+
const diagnostic = new vscode.Diagnostic(range, `Duplicate sequence: '${seq.name}' is already declared on line ${firstLine + 1}.`, vscode.DiagnosticSeverity.Error);
|
|
199
|
+
diagnostic.source = 'Norn';
|
|
200
|
+
diagnostic.code = 'duplicate-sequence';
|
|
201
|
+
diagnostics.push(diagnostic);
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
declaredSequences.set(seq.name, seq.startLine);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// Check for duplicate variable declarations at file level (outside sequences)
|
|
208
|
+
const varDeclRegex = /^\s*var\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*=/;
|
|
209
|
+
const declaredVars = new Map(); // varName -> first declaration line
|
|
210
|
+
for (let i = 0; i < lines.length; i++) {
|
|
211
|
+
const line = lines[i];
|
|
212
|
+
// Skip comment lines
|
|
213
|
+
if (isCommentLine(line)) {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
// Check if this line is inside a sequence (skip sequence-local vars)
|
|
217
|
+
const isInsideSequence = sequences.some(seq => i > seq.startLine && i < seq.endLine);
|
|
218
|
+
if (isInsideSequence) {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const lineWithoutComment = stripInlineComment(line);
|
|
222
|
+
const varMatch = lineWithoutComment.match(varDeclRegex);
|
|
223
|
+
if (varMatch) {
|
|
224
|
+
const varName = varMatch[1];
|
|
225
|
+
if (declaredVars.has(varName)) {
|
|
226
|
+
const firstLine = declaredVars.get(varName);
|
|
227
|
+
const startCol = line.indexOf(varName);
|
|
228
|
+
const range = new vscode.Range(new vscode.Position(i, startCol), new vscode.Position(i, startCol + varName.length));
|
|
229
|
+
const diagnostic = new vscode.Diagnostic(range, `Duplicate variable declaration: '${varName}' is already declared on line ${firstLine + 1}.`, vscode.DiagnosticSeverity.Error);
|
|
230
|
+
diagnostic.source = 'Norn';
|
|
231
|
+
diagnostic.code = 'duplicate-variable';
|
|
232
|
+
diagnostics.push(diagnostic);
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
declaredVars.set(varName, i);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// Check for invalid named request declarations (names with spaces) and duplicates
|
|
240
|
+
const invalidNameRegex = /^\s*\[(?:Name:\s*)?(.+)\]\s*$/;
|
|
241
|
+
const namedRequestRegex = /^\s*\[(?:Name:\s*)?([a-zA-Z_][a-zA-Z0-9_-]*)\]/;
|
|
242
|
+
const validNameRegex = /^[a-zA-Z_][a-zA-Z0-9_-]*$/;
|
|
243
|
+
const declaredNamedRequests = new Map(); // name -> first declaration line
|
|
244
|
+
for (let i = 0; i < lines.length; i++) {
|
|
245
|
+
const line = lines[i];
|
|
246
|
+
// Skip comment lines
|
|
247
|
+
if (isCommentLine(line)) {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
// Strip inline comments before checking
|
|
251
|
+
const lineWithoutComment = stripInlineComment(line);
|
|
252
|
+
// Check for valid named request and track for duplicates
|
|
253
|
+
const namedMatch = lineWithoutComment.match(namedRequestRegex);
|
|
254
|
+
if (namedMatch) {
|
|
255
|
+
const name = namedMatch[1];
|
|
256
|
+
if (declaredNamedRequests.has(name)) {
|
|
257
|
+
const firstLine = declaredNamedRequests.get(name);
|
|
258
|
+
const startCol = line.indexOf('[');
|
|
259
|
+
const endCol = line.indexOf(']') + 1;
|
|
260
|
+
const range = new vscode.Range(new vscode.Position(i, startCol), new vscode.Position(i, endCol));
|
|
261
|
+
const diagnostic = new vscode.Diagnostic(range, `Duplicate named request: '${name}' is already declared on line ${firstLine + 1}.`, vscode.DiagnosticSeverity.Error);
|
|
262
|
+
diagnostic.source = 'Norn';
|
|
263
|
+
diagnostic.code = 'duplicate-named-request';
|
|
264
|
+
diagnostics.push(diagnostic);
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
declaredNamedRequests.set(name, i);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// Check for invalid names (with spaces etc)
|
|
271
|
+
const match = lineWithoutComment.match(invalidNameRegex);
|
|
272
|
+
if (match) {
|
|
273
|
+
const name = match[1].trim();
|
|
274
|
+
if (!validNameRegex.test(name)) {
|
|
275
|
+
const startCol = line.indexOf('[');
|
|
276
|
+
const endCol = line.indexOf(']') + 1;
|
|
277
|
+
const range = new vscode.Range(new vscode.Position(i, startCol), new vscode.Position(i, endCol));
|
|
278
|
+
const diagnostic = new vscode.Diagnostic(range, `Invalid request name: '${name}'. Names cannot contain spaces and must start with a letter or underscore.`, vscode.DiagnosticSeverity.Error);
|
|
279
|
+
diagnostic.source = 'Norn';
|
|
280
|
+
diagnostic.code = 'invalid-request-name';
|
|
281
|
+
diagnostics.push(diagnostic);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// Check for import statement errors
|
|
286
|
+
const imports = (0, parser_1.extractImports)(text);
|
|
287
|
+
const documentDir = path.dirname(document.uri.fsPath);
|
|
288
|
+
// Track imported names with source info for duplicate detection
|
|
289
|
+
// Maps name (lowercase) -> { sourcePath, lineNumber }
|
|
290
|
+
const importedRequests = new Map();
|
|
291
|
+
const importedSequences = new Map();
|
|
292
|
+
// Track imported API definitions (endpoints and header groups from .nornapi files)
|
|
293
|
+
// Maps name -> { sourcePath, lineNumber } to detect duplicates
|
|
294
|
+
const importedEndpoints = new Map();
|
|
295
|
+
const importedHeaderGroups = new Map();
|
|
296
|
+
for (const imp of imports) {
|
|
297
|
+
const absolutePath = path.resolve(documentDir, imp.path);
|
|
298
|
+
// Check if file exists
|
|
299
|
+
if (!fs.existsSync(absolutePath)) {
|
|
300
|
+
const line = lines[imp.lineNumber];
|
|
301
|
+
const startCol = line.indexOf(imp.path);
|
|
302
|
+
const range = new vscode.Range(new vscode.Position(imp.lineNumber, startCol), new vscode.Position(imp.lineNumber, startCol + imp.path.length));
|
|
303
|
+
const diagnostic = new vscode.Diagnostic(range, `Import file not found: '${imp.path}'`, vscode.DiagnosticSeverity.Error);
|
|
304
|
+
diagnostic.source = 'Norn';
|
|
305
|
+
diagnostic.code = 'import-not-found';
|
|
306
|
+
diagnostics.push(diagnostic);
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
// Read the imported file and extract its named requests/sequences
|
|
310
|
+
try {
|
|
311
|
+
const importedContent = fs.readFileSync(absolutePath, 'utf-8');
|
|
312
|
+
// Check if this is a .nornapi file
|
|
313
|
+
if (imp.path.endsWith('.nornapi')) {
|
|
314
|
+
const apiDef = (0, nornapiParser_1.parseNornApiFile)(importedContent);
|
|
315
|
+
for (const endpoint of apiDef.endpoints) {
|
|
316
|
+
const existing = importedEndpoints.get(endpoint.name);
|
|
317
|
+
if (existing) {
|
|
318
|
+
// Duplicate endpoint found
|
|
319
|
+
const line = lines[imp.lineNumber];
|
|
320
|
+
const startCol = line.indexOf(imp.path);
|
|
321
|
+
const range = new vscode.Range(new vscode.Position(imp.lineNumber, startCol), new vscode.Position(imp.lineNumber, startCol + imp.path.length));
|
|
322
|
+
const diagnostic = new vscode.Diagnostic(range, `Duplicate endpoint '${endpoint.name}': already defined in '${existing.sourcePath}'`, vscode.DiagnosticSeverity.Error);
|
|
323
|
+
diagnostic.source = 'Norn';
|
|
324
|
+
diagnostic.code = 'duplicate-endpoint';
|
|
325
|
+
diagnostics.push(diagnostic);
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
importedEndpoints.set(endpoint.name, { sourcePath: imp.path, lineNumber: imp.lineNumber });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
for (const group of apiDef.headerGroups) {
|
|
332
|
+
const existing = importedHeaderGroups.get(group.name);
|
|
333
|
+
if (existing) {
|
|
334
|
+
// Duplicate header group found
|
|
335
|
+
const line = lines[imp.lineNumber];
|
|
336
|
+
const startCol = line.indexOf(imp.path);
|
|
337
|
+
const range = new vscode.Range(new vscode.Position(imp.lineNumber, startCol), new vscode.Position(imp.lineNumber, startCol + imp.path.length));
|
|
338
|
+
const diagnostic = new vscode.Diagnostic(range, `Duplicate header group '${group.name}': already defined in '${existing.sourcePath}'`, vscode.DiagnosticSeverity.Error);
|
|
339
|
+
diagnostic.source = 'Norn';
|
|
340
|
+
diagnostic.code = 'duplicate-header-group';
|
|
341
|
+
diagnostics.push(diagnostic);
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
importedHeaderGroups.set(group.name, { sourcePath: imp.path, lineNumber: imp.lineNumber });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
// Regular .norn file
|
|
350
|
+
const fileRequests = (0, parser_1.extractNamedRequests)(importedContent);
|
|
351
|
+
const fileSequences = (0, sequenceRunner_1.extractSequences)(importedContent);
|
|
352
|
+
// Check for duplicate named requests
|
|
353
|
+
for (const req of fileRequests) {
|
|
354
|
+
const lowerName = req.name.toLowerCase();
|
|
355
|
+
const existing = importedRequests.get(lowerName);
|
|
356
|
+
if (existing) {
|
|
357
|
+
const line = lines[imp.lineNumber];
|
|
358
|
+
const startCol = line.indexOf(imp.path);
|
|
359
|
+
const range = new vscode.Range(new vscode.Position(imp.lineNumber, startCol), new vscode.Position(imp.lineNumber, startCol + imp.path.length));
|
|
360
|
+
const diagnostic = new vscode.Diagnostic(range, `Duplicate named request '${req.name}': already defined in '${existing.sourcePath}'`, vscode.DiagnosticSeverity.Error);
|
|
361
|
+
diagnostic.source = 'Norn';
|
|
362
|
+
diagnostic.code = 'duplicate-named-request-import';
|
|
363
|
+
diagnostics.push(diagnostic);
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
importedRequests.set(lowerName, { sourcePath: imp.path, lineNumber: imp.lineNumber });
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
// Check for duplicate sequences
|
|
370
|
+
for (const seq of fileSequences) {
|
|
371
|
+
const lowerName = seq.name.toLowerCase();
|
|
372
|
+
const existing = importedSequences.get(lowerName);
|
|
373
|
+
if (existing) {
|
|
374
|
+
const line = lines[imp.lineNumber];
|
|
375
|
+
const startCol = line.indexOf(imp.path);
|
|
376
|
+
const range = new vscode.Range(new vscode.Position(imp.lineNumber, startCol), new vscode.Position(imp.lineNumber, startCol + imp.path.length));
|
|
377
|
+
const diagnostic = new vscode.Diagnostic(range, `Duplicate sequence '${seq.name}': already defined in '${existing.sourcePath}'`, vscode.DiagnosticSeverity.Error);
|
|
378
|
+
diagnostic.source = 'Norn';
|
|
379
|
+
diagnostic.code = 'duplicate-sequence-import';
|
|
380
|
+
diagnostics.push(diagnostic);
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
importedSequences.set(lowerName, { sourcePath: imp.path, lineNumber: imp.lineNumber });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
// Ignore read errors - file exists but couldn't be read
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
// Check for misplaced tags/annotations.
|
|
394
|
+
// Any @decorator line must be directly associated with a following sequence declaration.
|
|
395
|
+
const tagPattern = /^\s*@([a-zA-Z_][a-zA-Z0-9_-]*)(?:\(([^)]*)\))?/;
|
|
396
|
+
const sequencePattern = /^\s*(?:test\s+)?sequence\s+[a-zA-Z_][a-zA-Z0-9_-]*/;
|
|
397
|
+
for (let i = 0; i < lines.length; i++) {
|
|
398
|
+
const line = lines[i];
|
|
399
|
+
// Skip comment lines
|
|
400
|
+
if (isCommentLine(line)) {
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
// Check if this line contains a tag/annotation decorator
|
|
404
|
+
if (tagPattern.test(line)) {
|
|
405
|
+
// Look ahead to find what this decorator applies to
|
|
406
|
+
let foundSequence = false;
|
|
407
|
+
let j = i + 1;
|
|
408
|
+
// Skip empty lines and additional decorator lines
|
|
409
|
+
while (j < lines.length) {
|
|
410
|
+
const nextLine = lines[j].trim();
|
|
411
|
+
if (!nextLine) {
|
|
412
|
+
j++;
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (tagPattern.test(nextLine)) {
|
|
416
|
+
j++;
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
if (sequencePattern.test(nextLine)) {
|
|
420
|
+
foundSequence = true;
|
|
421
|
+
}
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
const tagMatch = line.match(/@([a-zA-Z_][a-zA-Z0-9_-]*)(?:\([^)]+\))?/);
|
|
425
|
+
if (tagMatch) {
|
|
426
|
+
const startCol = line.indexOf('@');
|
|
427
|
+
const range = new vscode.Range(new vscode.Position(i, startCol), new vscode.Position(i, startCol + tagMatch[0].length));
|
|
428
|
+
if (!foundSequence) {
|
|
429
|
+
const annotationName = tagMatch[1];
|
|
430
|
+
const isDataAnnotation = annotationName === 'data' || annotationName === 'theory';
|
|
431
|
+
const message = isDataAnnotation
|
|
432
|
+
? `@${annotationName} must be placed immediately before a sequence declaration.`
|
|
433
|
+
: `Tags must be placed immediately before a sequence declaration.`;
|
|
434
|
+
const diagnostic = new vscode.Diagnostic(range, message, vscode.DiagnosticSeverity.Error);
|
|
435
|
+
diagnostic.source = 'Norn';
|
|
436
|
+
diagnostic.code = 'misplaced-tag';
|
|
437
|
+
diagnostics.push(diagnostic);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
// Check for test sequences with required parameters but no @data/@theory
|
|
443
|
+
for (const seq of sequences) {
|
|
444
|
+
if (seq.isTest) {
|
|
445
|
+
const requiredParams = seq.parameters.filter(p => p.defaultValue === undefined);
|
|
446
|
+
if (requiredParams.length > 0 && !seq.theoryData) {
|
|
447
|
+
// Find the line with the 'test sequence' declaration
|
|
448
|
+
let declarationLine = seq.startLine;
|
|
449
|
+
for (let i = seq.startLine; i <= seq.endLine; i++) {
|
|
450
|
+
if (/^\s*test\s+sequence\s+/.test(lines[i])) {
|
|
451
|
+
declarationLine = i;
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
const line = lines[declarationLine];
|
|
456
|
+
const startCol = line.indexOf('test');
|
|
457
|
+
const endCol = line.length;
|
|
458
|
+
const range = new vscode.Range(new vscode.Position(declarationLine, startCol), new vscode.Position(declarationLine, endCol));
|
|
459
|
+
const paramNames = requiredParams.map(p => p.name).join(', ');
|
|
460
|
+
const diagnostic = new vscode.Diagnostic(range, `Test sequence '${seq.name}' has required parameters (${paramNames}) but no @data or @theory annotation. Add @data(...) or @theory("file.json") to provide test values.`, vscode.DiagnosticSeverity.Error);
|
|
461
|
+
diagnostic.source = 'Norn';
|
|
462
|
+
diagnostic.code = 'test-missing-data';
|
|
463
|
+
diagnostics.push(diagnostic);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
// Check for undefined named requests/sequences in 'run' commands
|
|
468
|
+
const namedRequests = (0, parser_1.extractNamedRequests)(text);
|
|
469
|
+
const namedRequestNames = new Set(namedRequests.map(r => r.name.toLowerCase()));
|
|
470
|
+
const sequenceNames = new Set(sequences.map(s => s.name.toLowerCase()));
|
|
471
|
+
// Build a map of sequence name -> required parameter count
|
|
472
|
+
const sequenceParamInfo = new Map();
|
|
473
|
+
for (const seq of sequences) {
|
|
474
|
+
const requiredParams = seq.parameters.filter(p => p.defaultValue === undefined).length;
|
|
475
|
+
sequenceParamInfo.set(seq.name.toLowerCase(), { required: requiredParams, total: seq.parameters.length });
|
|
476
|
+
}
|
|
477
|
+
// Updated regex to capture optional arguments: run Name or run Name(args)
|
|
478
|
+
const runCommandRegex = /^\s*(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?run\s+([a-zA-Z_][a-zA-Z0-9_-]*)(?:\(([^)]*)\))?\s*$/;
|
|
479
|
+
for (let i = 0; i < lines.length; i++) {
|
|
480
|
+
const line = lines[i];
|
|
481
|
+
// Skip comment lines
|
|
482
|
+
if (isCommentLine(line)) {
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
const match = line.match(runCommandRegex);
|
|
486
|
+
if (match) {
|
|
487
|
+
const requestName = match[1];
|
|
488
|
+
const argsStr = match[2]; // undefined if no parens, empty string if (), or "arg1, arg2"
|
|
489
|
+
const lowerName = requestName.toLowerCase();
|
|
490
|
+
// Skip if it's a script command
|
|
491
|
+
if (['bash', 'js', 'powershell'].includes(lowerName)) {
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
// Check if it's a local named request, local sequence, or imported
|
|
495
|
+
const isLocalRequest = namedRequestNames.has(lowerName);
|
|
496
|
+
const isLocalSequence = sequenceNames.has(lowerName);
|
|
497
|
+
const isImportedRequest = importedRequests.has(lowerName);
|
|
498
|
+
const isImportedSequence = importedSequences.has(lowerName);
|
|
499
|
+
if (!isLocalRequest && !isLocalSequence && !isImportedRequest && !isImportedSequence) {
|
|
500
|
+
const startCol = line.indexOf(requestName);
|
|
501
|
+
const range = new vscode.Range(new vscode.Position(i, startCol), new vscode.Position(i, startCol + requestName.length));
|
|
502
|
+
const diagnostic = new vscode.Diagnostic(range, `Undefined request or sequence: '${requestName}'`, vscode.DiagnosticSeverity.Error);
|
|
503
|
+
diagnostic.source = 'Norn';
|
|
504
|
+
diagnostic.code = 'undefined-request';
|
|
505
|
+
diagnostics.push(diagnostic);
|
|
506
|
+
}
|
|
507
|
+
else if (isLocalSequence) {
|
|
508
|
+
// Check if sequence requires parameters but none were provided
|
|
509
|
+
const paramInfo = sequenceParamInfo.get(lowerName);
|
|
510
|
+
if (paramInfo && paramInfo.required > 0) {
|
|
511
|
+
// Count provided arguments
|
|
512
|
+
let providedCount = 0;
|
|
513
|
+
if (argsStr !== undefined && argsStr.trim() !== '') {
|
|
514
|
+
// Split by comma, but respect quoted strings
|
|
515
|
+
const args = argsStr.split(',').map(a => a.trim()).filter(a => a !== '');
|
|
516
|
+
providedCount = args.length;
|
|
517
|
+
}
|
|
518
|
+
if (providedCount < paramInfo.required) {
|
|
519
|
+
const startCol = line.indexOf(requestName);
|
|
520
|
+
const range = new vscode.Range(new vscode.Position(i, startCol), new vscode.Position(i, startCol + requestName.length));
|
|
521
|
+
const seq = sequences.find(s => s.name.toLowerCase() === lowerName);
|
|
522
|
+
const requiredParamNames = seq ?
|
|
523
|
+
seq.parameters.filter(p => p.defaultValue === undefined).map(p => p.name).join(', ') :
|
|
524
|
+
'';
|
|
525
|
+
const diagnostic = new vscode.Diagnostic(range, `Sequence '${requestName}' requires ${paramInfo.required} parameter(s): ${requiredParamNames}`, vscode.DiagnosticSeverity.Error);
|
|
526
|
+
diagnostic.source = 'Norn';
|
|
527
|
+
diagnostic.code = 'missing-sequence-parameters';
|
|
528
|
+
diagnostics.push(diagnostic);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
// Check for undefined API endpoints and header groups
|
|
535
|
+
// Match patterns like: GET EndpointName("arg") HeaderGroup
|
|
536
|
+
const apiRequestRegex = /^\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+([A-Z][a-zA-Z0-9_]*)(?:\([^)]*\))?(?:\s+([A-Z][a-zA-Z0-9_]*))?/;
|
|
537
|
+
// Match standalone header group on its own line (after an API request)
|
|
538
|
+
const standaloneHeaderGroupRegex = /^\s+([A-Z][a-zA-Z0-9_]*)\s*$/;
|
|
539
|
+
for (let i = 0; i < lines.length; i++) {
|
|
540
|
+
const line = lines[i];
|
|
541
|
+
// Skip comment lines
|
|
542
|
+
if (isCommentLine(line)) {
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
// Strip inline comments before checking API request syntax
|
|
546
|
+
const lineWithoutComment = stripInlineComment(line);
|
|
547
|
+
// Check API request syntax
|
|
548
|
+
const apiMatch = lineWithoutComment.match(apiRequestRegex);
|
|
549
|
+
if (apiMatch) {
|
|
550
|
+
const endpointName = apiMatch[2];
|
|
551
|
+
const headerGroupName = apiMatch[3];
|
|
552
|
+
// Check if endpoint exists
|
|
553
|
+
if (!importedEndpoints.has(endpointName)) {
|
|
554
|
+
const startCol = line.indexOf(endpointName);
|
|
555
|
+
const range = new vscode.Range(new vscode.Position(i, startCol), new vscode.Position(i, startCol + endpointName.length));
|
|
556
|
+
const diagnostic = new vscode.Diagnostic(range, `Undefined endpoint: '${endpointName}'. Import a .nornapi file that defines this endpoint.`, vscode.DiagnosticSeverity.Error);
|
|
557
|
+
diagnostic.source = 'Norn';
|
|
558
|
+
diagnostic.code = 'undefined-endpoint';
|
|
559
|
+
diagnostics.push(diagnostic);
|
|
560
|
+
}
|
|
561
|
+
// Check if header group exists (if specified)
|
|
562
|
+
if (headerGroupName && !importedHeaderGroups.has(headerGroupName)) {
|
|
563
|
+
const startCol = line.lastIndexOf(headerGroupName);
|
|
564
|
+
const range = new vscode.Range(new vscode.Position(i, startCol), new vscode.Position(i, startCol + headerGroupName.length));
|
|
565
|
+
const diagnostic = new vscode.Diagnostic(range, `Undefined header group: '${headerGroupName}'. Import a .nornapi file that defines this header group.`, vscode.DiagnosticSeverity.Error);
|
|
566
|
+
diagnostic.source = 'Norn';
|
|
567
|
+
diagnostic.code = 'undefined-header-group';
|
|
568
|
+
diagnostics.push(diagnostic);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
// Check standalone header group lines (indented, capitalized identifier only)
|
|
572
|
+
const standaloneMatch = lineWithoutComment.match(standaloneHeaderGroupRegex);
|
|
573
|
+
if (standaloneMatch) {
|
|
574
|
+
const headerGroupName = standaloneMatch[1];
|
|
575
|
+
// Only flag as undefined if we have some .nornapi imports (otherwise it might just be a typo or URL)
|
|
576
|
+
if (importedEndpoints.size > 0 || importedHeaderGroups.size > 0) {
|
|
577
|
+
if (!importedHeaderGroups.has(headerGroupName)) {
|
|
578
|
+
const startCol = line.indexOf(headerGroupName);
|
|
579
|
+
const range = new vscode.Range(new vscode.Position(i, startCol), new vscode.Position(i, startCol + headerGroupName.length));
|
|
580
|
+
const diagnostic = new vscode.Diagnostic(range, `Undefined header group: '${headerGroupName}'. Import a .nornapi file that defines this header group.`, vscode.DiagnosticSeverity.Error);
|
|
581
|
+
diagnostic.source = 'Norn';
|
|
582
|
+
diagnostic.code = 'undefined-header-group';
|
|
583
|
+
diagnostics.push(diagnostic);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
// Find empty variable references {{}} or {{ }}
|
|
589
|
+
const emptyVarRegex = /\{\{\s*\}\}/g;
|
|
590
|
+
let emptyMatch;
|
|
591
|
+
while ((emptyMatch = emptyVarRegex.exec(text)) !== null) {
|
|
592
|
+
const startPos = document.positionAt(emptyMatch.index);
|
|
593
|
+
const endPos = document.positionAt(emptyMatch.index + emptyMatch[0].length);
|
|
594
|
+
const range = new vscode.Range(startPos, endPos);
|
|
595
|
+
const diagnostic = new vscode.Diagnostic(range, `Empty variable reference`, vscode.DiagnosticSeverity.Error);
|
|
596
|
+
diagnostic.source = 'Norn';
|
|
597
|
+
diagnostic.code = 'empty-variable';
|
|
598
|
+
diagnostics.push(diagnostic);
|
|
599
|
+
}
|
|
600
|
+
// Check for invalid variable assignments inside sequences
|
|
601
|
+
// Valid patterns:
|
|
602
|
+
// - var x = "string" (quoted string)
|
|
603
|
+
// - var x = 123 (number)
|
|
604
|
+
// - var x = true/false/null (literals)
|
|
605
|
+
// - var x = varName.path (existing variable path)
|
|
606
|
+
// - var x = $1.body.id (response capture)
|
|
607
|
+
// - var x = run ... (script/json command)
|
|
608
|
+
// - var x = https://... (URL - for backward compat outside sequences)
|
|
609
|
+
const varAssignRegex = /^\s*var\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.+)$/;
|
|
610
|
+
for (let i = 0; i < lines.length; i++) {
|
|
611
|
+
const line = lines[i];
|
|
612
|
+
// Skip comment lines
|
|
613
|
+
if (isCommentLine(line)) {
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
// Strip inline comments before processing
|
|
617
|
+
const lineWithoutComment = stripInlineComment(line);
|
|
618
|
+
const match = lineWithoutComment.match(varAssignRegex);
|
|
619
|
+
if (match) {
|
|
620
|
+
const varName = match[1];
|
|
621
|
+
const valueExpr = match[2].trim();
|
|
622
|
+
// Skip valid patterns
|
|
623
|
+
// 1. Response capture: $1.path
|
|
624
|
+
if (/^\$\d+/.test(valueExpr)) {
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
// 2. Run command: run bash, run js, run powershell, run readJson
|
|
628
|
+
if (/^run\s+/i.test(valueExpr)) {
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
// 3. Quoted string: "..." or '...'
|
|
632
|
+
if ((valueExpr.startsWith('"') && valueExpr.endsWith('"')) ||
|
|
633
|
+
(valueExpr.startsWith("'") && valueExpr.endsWith("'"))) {
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
// 4. Number
|
|
637
|
+
if (/^-?\d+(\.\d+)?$/.test(valueExpr)) {
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
// 5. Boolean/null
|
|
641
|
+
if (/^(true|false|null)$/i.test(valueExpr)) {
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
// 6. URL (common case outside sequences)
|
|
645
|
+
if (/^https?:\/\//.test(valueExpr)) {
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
// 7. HTTP request: GET/POST/etc followed by URL or endpoint
|
|
649
|
+
const httpRequestMatch = valueExpr.match(/^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(.+)$/i);
|
|
650
|
+
if (httpRequestMatch) {
|
|
651
|
+
const afterMethod = httpRequestMatch[2].trim();
|
|
652
|
+
// Check if it's a URL (starts with http, https, or {{ - also handle quoted URLs)
|
|
653
|
+
if (/^["']?(https?:\/\/|\{\{)/.test(afterMethod)) {
|
|
654
|
+
// It's a URL - skip validation
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
// It might be an endpoint name: EndpointName(params) HeaderGroup1 HeaderGroup2
|
|
658
|
+
const endpointMatch = afterMethod.match(/^([a-zA-Z_][a-zA-Z0-9_]*)(?:\([^)]*\))?(.*)$/);
|
|
659
|
+
if (endpointMatch) {
|
|
660
|
+
const endpointName = endpointMatch[1];
|
|
661
|
+
const afterEndpoint = endpointMatch[2]?.trim() || '';
|
|
662
|
+
// Validate endpoint exists
|
|
663
|
+
if (importedEndpoints.size > 0 && !importedEndpoints.has(endpointName)) {
|
|
664
|
+
const endpointStart = line.indexOf(endpointName, line.indexOf('='));
|
|
665
|
+
const range = new vscode.Range(new vscode.Position(i, endpointStart), new vscode.Position(i, endpointStart + endpointName.length));
|
|
666
|
+
diagnostics.push(new vscode.Diagnostic(range, `Undefined endpoint: '${endpointName}'. Import a .nornapi file that defines this endpoint.`, vscode.DiagnosticSeverity.Error));
|
|
667
|
+
}
|
|
668
|
+
// Validate header groups
|
|
669
|
+
if (afterEndpoint) {
|
|
670
|
+
// Strip retry and backoff options before checking header groups
|
|
671
|
+
// Use \b word boundaries instead of \s+ since afterEndpoint is already trimmed
|
|
672
|
+
const afterEndpointClean = afterEndpoint
|
|
673
|
+
.replace(/\bretry\s+\d+/gi, '')
|
|
674
|
+
.replace(/\bbackoff\s+\d+(?:\.\d+)?\s*(?:s|ms|seconds?|milliseconds?)?/gi, '');
|
|
675
|
+
const headerGroupNames = afterEndpointClean.split(/\s+/).filter(n => n);
|
|
676
|
+
for (const hgName of headerGroupNames) {
|
|
677
|
+
if (!importedHeaderGroups.has(hgName)) {
|
|
678
|
+
const hgStart = line.lastIndexOf(hgName);
|
|
679
|
+
const range = new vscode.Range(new vscode.Position(i, hgStart), new vscode.Position(i, hgStart + hgName.length));
|
|
680
|
+
diagnostics.push(new vscode.Diagnostic(range, `Undefined header group: '${hgName}'. Import a .nornapi file that defines this header group.`, vscode.DiagnosticSeverity.Error));
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
// 8. Variable path: must be an existing variable
|
|
688
|
+
const varPathMatch = valueExpr.match(/^([a-zA-Z_][a-zA-Z0-9_]*)((?:\.[a-zA-Z_][a-zA-Z0-9_]*|\[\d+\])*)$/);
|
|
689
|
+
if (varPathMatch) {
|
|
690
|
+
const refVarName = varPathMatch[1];
|
|
691
|
+
// Check if this assignment is inside a sequence
|
|
692
|
+
const containingSeq = sequences.find(seq => i > seq.startLine && i < seq.endLine);
|
|
693
|
+
let isValid = false;
|
|
694
|
+
if (containingSeq) {
|
|
695
|
+
// Inside sequence: check global vars + local sequence vars + params
|
|
696
|
+
const localVars = getSequenceLocalVariables(containingSeq.content, containingSeq.parameters);
|
|
697
|
+
isValid = refVarName in globalVariables || localVars.has(refVarName);
|
|
698
|
+
}
|
|
699
|
+
else {
|
|
700
|
+
// Outside sequences: only global variables
|
|
701
|
+
isValid = refVarName in globalVariables;
|
|
702
|
+
}
|
|
703
|
+
if (isValid) {
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
// Variable not defined - show error
|
|
707
|
+
const valueStart = line.indexOf('=') + 1;
|
|
708
|
+
const valueStartTrimmed = valueStart + (line.substring(valueStart).length - line.substring(valueStart).trimStart().length);
|
|
709
|
+
const range = new vscode.Range(new vscode.Position(i, valueStartTrimmed), new vscode.Position(i, valueStartTrimmed + refVarName.length));
|
|
710
|
+
const diagnostic = new vscode.Diagnostic(range, `Undefined variable: '${refVarName}'. Use quotes for string literals: var ${varName} = "${valueExpr}"`, vscode.DiagnosticSeverity.Error);
|
|
711
|
+
diagnostic.source = 'Norn';
|
|
712
|
+
diagnostic.code = 'undefined-var-reference';
|
|
713
|
+
diagnostics.push(diagnostic);
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
// If we get here, it's an invalid expression (has spaces, not quoted, not valid pattern)
|
|
717
|
+
const valueStart = line.indexOf('=') + 1;
|
|
718
|
+
const valueStartTrimmed = valueStart + (line.substring(valueStart).length - line.substring(valueStart).trimStart().length);
|
|
719
|
+
const range = new vscode.Range(new vscode.Position(i, valueStartTrimmed), new vscode.Position(i, line.length));
|
|
720
|
+
const diagnostic = new vscode.Diagnostic(range, `Invalid variable value. Use quotes for strings: var ${varName} = "${valueExpr}"`, vscode.DiagnosticSeverity.Error);
|
|
721
|
+
diagnostic.source = 'Norn';
|
|
722
|
+
diagnostic.code = 'invalid-var-value';
|
|
723
|
+
diagnostics.push(diagnostic);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
// Find all variable references {{variableName}} or {{variableName.path}}
|
|
727
|
+
// Match simple refs and the base name of path refs
|
|
728
|
+
const variableRefRegex = /\{\{([a-zA-Z_][a-zA-Z0-9_]*)((?:\.[a-zA-Z_][a-zA-Z0-9_]*|\[\d+\])*)\}\}/g;
|
|
729
|
+
let match;
|
|
730
|
+
while ((match = variableRefRegex.exec(text)) !== null) {
|
|
731
|
+
const varName = match[1];
|
|
732
|
+
const startPos = document.positionAt(match.index);
|
|
733
|
+
const lineNumber = startPos.line;
|
|
734
|
+
// Skip if this line is a comment
|
|
735
|
+
if (isCommentLine(lines[lineNumber])) {
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
// Check if this reference is inside a sequence
|
|
739
|
+
const containingSequence = sequences.find(seq => lineNumber > seq.startLine && lineNumber < seq.endLine);
|
|
740
|
+
let isValid = false;
|
|
741
|
+
if (containingSequence) {
|
|
742
|
+
// Inside a sequence: check global vars + local sequence vars + params
|
|
743
|
+
const localVars = getSequenceLocalVariables(containingSequence.content, containingSequence.parameters);
|
|
744
|
+
isValid = varName in globalVariables || localVars.has(varName);
|
|
745
|
+
}
|
|
746
|
+
else {
|
|
747
|
+
// Outside sequences: only global variables are available
|
|
748
|
+
isValid = varName in globalVariables;
|
|
749
|
+
}
|
|
750
|
+
if (!isValid) {
|
|
751
|
+
const endPos = document.positionAt(match.index + match[0].length);
|
|
752
|
+
const range = new vscode.Range(startPos, endPos);
|
|
753
|
+
const diagnostic = new vscode.Diagnostic(range, `Undefined variable: '${varName}'`, vscode.DiagnosticSeverity.Error);
|
|
754
|
+
diagnostic.source = 'Norn';
|
|
755
|
+
diagnostic.code = 'undefined-variable';
|
|
756
|
+
diagnostics.push(diagnostic);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
// Check for bare text in print statements that isn't a string, variable, or keyword
|
|
760
|
+
const printCommandRegex = /^\s*print\s+(.+)$/i;
|
|
761
|
+
const validKeywords = new Set(['true', 'false', 'null']);
|
|
762
|
+
for (let i = 0; i < lines.length; i++) {
|
|
763
|
+
const line = lines[i];
|
|
764
|
+
// Skip comment lines
|
|
765
|
+
if (isCommentLine(line)) {
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
// Strip inline comments before processing print statement
|
|
769
|
+
const lineWithoutComment = stripInlineComment(line);
|
|
770
|
+
const printMatch = lineWithoutComment.match(printCommandRegex);
|
|
771
|
+
if (!printMatch) {
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
const printContent = printMatch[1];
|
|
775
|
+
const printStartCol = line.indexOf(printContent);
|
|
776
|
+
// Check if this print is inside a sequence
|
|
777
|
+
const containingSeq = sequences.find(seq => i > seq.startLine && i < seq.endLine);
|
|
778
|
+
// Get available variables for this scope
|
|
779
|
+
let availableVars;
|
|
780
|
+
if (containingSeq) {
|
|
781
|
+
const localVars = getSequenceLocalVariables(containingSeq.content, containingSeq.parameters);
|
|
782
|
+
availableVars = new Set([...Object.keys(globalVariables), ...localVars]);
|
|
783
|
+
}
|
|
784
|
+
else {
|
|
785
|
+
availableVars = new Set(Object.keys(globalVariables));
|
|
786
|
+
}
|
|
787
|
+
// Parse print content into tokens (respecting quoted strings and operators)
|
|
788
|
+
const tokens = tokenizePrintContent(printContent);
|
|
789
|
+
// Check for missing concatenation operators between tokens
|
|
790
|
+
for (let t = 0; t < tokens.length - 1; t++) {
|
|
791
|
+
const currentToken = tokens[t];
|
|
792
|
+
const nextToken = tokens[t + 1];
|
|
793
|
+
// Skip if current token is an operator
|
|
794
|
+
if (/^[+\-*/|&<>=!]+$/.test(currentToken.text)) {
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
// If next token is not an operator, we're missing concatenation
|
|
798
|
+
if (!/^[+\-*/|&<>=!]+$/.test(nextToken.text)) {
|
|
799
|
+
// Calculate position between the two tokens
|
|
800
|
+
const errorStartCol = printStartCol + currentToken.start + currentToken.text.length;
|
|
801
|
+
const errorEndCol = printStartCol + nextToken.start;
|
|
802
|
+
const range = new vscode.Range(new vscode.Position(i, errorStartCol), new vscode.Position(i, errorEndCol));
|
|
803
|
+
const diagnostic = new vscode.Diagnostic(range, `Missing concatenation operator '+' between '${currentToken.text}' and '${nextToken.text}'`, vscode.DiagnosticSeverity.Error);
|
|
804
|
+
diagnostic.source = 'Norn';
|
|
805
|
+
diagnostic.code = 'missing-concatenation';
|
|
806
|
+
diagnostics.push(diagnostic);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
for (const token of tokens) {
|
|
810
|
+
// Skip operators (+, -, etc.)
|
|
811
|
+
if (/^[+\-*/|&<>=!]+$/.test(token.text)) {
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
// Skip quoted strings
|
|
815
|
+
if ((token.text.startsWith('"') && token.text.endsWith('"')) ||
|
|
816
|
+
(token.text.startsWith("'") && token.text.endsWith("'"))) {
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
// Skip numbers
|
|
820
|
+
if (/^-?\d+(\.\d+)?$/.test(token.text)) {
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
// Skip keywords (true, false, null)
|
|
824
|
+
if (validKeywords.has(token.text.toLowerCase())) {
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
// Skip variable references {{var}}
|
|
828
|
+
if (/^\{\{.+\}\}$/.test(token.text)) {
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
// Check if it's a valid variable (with optional path)
|
|
832
|
+
const varPathMatch = token.text.match(/^([a-zA-Z_][a-zA-Z0-9_]*)((?:\.[a-zA-Z_][a-zA-Z0-9_]*|\[\d+\])*)$/);
|
|
833
|
+
if (varPathMatch) {
|
|
834
|
+
const refVarName = varPathMatch[1];
|
|
835
|
+
if (availableVars.has(refVarName)) {
|
|
836
|
+
continue;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
// Check if it looks like a plain text word (not a valid identifier pattern)
|
|
840
|
+
// Flag words that look like they should be quoted
|
|
841
|
+
if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(token.text)) {
|
|
842
|
+
// It's a valid identifier but not a defined variable - flag it
|
|
843
|
+
const tokenStartCol = printStartCol + token.start;
|
|
844
|
+
const range = new vscode.Range(new vscode.Position(i, tokenStartCol), new vscode.Position(i, tokenStartCol + token.text.length));
|
|
845
|
+
const diagnostic = new vscode.Diagnostic(range, `Undefined variable: '${token.text}'. Use quotes for string literals: "${token.text}"`, vscode.DiagnosticSeverity.Error);
|
|
846
|
+
diagnostic.source = 'Norn';
|
|
847
|
+
diagnostic.code = 'undefined-print-variable';
|
|
848
|
+
diagnostics.push(diagnostic);
|
|
849
|
+
}
|
|
850
|
+
else if (!/^[a-zA-Z_]/.test(token.text) === false && !varPathMatch) {
|
|
851
|
+
// Some other bare text that doesn't look like anything valid
|
|
852
|
+
const tokenStartCol = printStartCol + token.start;
|
|
853
|
+
const range = new vscode.Range(new vscode.Position(i, tokenStartCol), new vscode.Position(i, tokenStartCol + token.text.length));
|
|
854
|
+
const diagnostic = new vscode.Diagnostic(range, `Invalid expression: '${token.text}'. Use quotes for string literals.`, vscode.DiagnosticSeverity.Error);
|
|
855
|
+
diagnostic.source = 'Norn';
|
|
856
|
+
diagnostic.code = 'invalid-print-expression';
|
|
857
|
+
diagnostics.push(diagnostic);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
this.diagnosticCollection.set(document.uri, diagnostics);
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Update diagnostics for .nornapi files
|
|
865
|
+
*/
|
|
866
|
+
updateNornapiDiagnostics(document) {
|
|
867
|
+
const diagnostics = [];
|
|
868
|
+
const lines = document.getText().split('\n');
|
|
869
|
+
const httpMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
|
|
870
|
+
let inEndpointsBlock = false;
|
|
871
|
+
for (let i = 0; i < lines.length; i++) {
|
|
872
|
+
const line = lines[i];
|
|
873
|
+
const trimmed = stripInlineComment(line).trim();
|
|
874
|
+
// Skip empty lines/comments
|
|
875
|
+
if (trimmed === '' || trimmed.startsWith('#')) {
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
// Track endpoints block state
|
|
879
|
+
if (/^endpoints$/i.test(trimmed)) {
|
|
880
|
+
inEndpointsBlock = true;
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
if (/^end\s+endpoints$/i.test(trimmed)) {
|
|
884
|
+
inEndpointsBlock = false;
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
if (!inEndpointsBlock) {
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
// Valid endpoint declaration: Name: METHOD path
|
|
891
|
+
const validEndpoint = trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(.+)$/i);
|
|
892
|
+
if (validEndpoint) {
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
const endpointMatch = trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/);
|
|
896
|
+
if (!endpointMatch) {
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
899
|
+
const endpointName = endpointMatch[1];
|
|
900
|
+
const rhs = endpointMatch[2].trim();
|
|
901
|
+
const colonIndex = line.indexOf(':');
|
|
902
|
+
const valueStart = colonIndex >= 0 ? colonIndex + 1 : 0;
|
|
903
|
+
const valueRange = new vscode.Range(new vscode.Position(i, Math.max(0, valueStart)), new vscode.Position(i, line.length));
|
|
904
|
+
// While typing: "Name:" or "Name: G" should not error yet
|
|
905
|
+
if (rhs === '') {
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
const firstToken = rhs.split(/\s+/)[0];
|
|
909
|
+
const upperFirstToken = firstToken.toUpperCase();
|
|
910
|
+
const isPartialMethod = httpMethods.some(m => m.startsWith(upperFirstToken));
|
|
911
|
+
if (!rhs.includes(' ') && isPartialMethod) {
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
if (httpMethods.includes(upperFirstToken) && !rhs.includes(' ')) {
|
|
915
|
+
const diagnostic = new vscode.Diagnostic(valueRange, `Endpoint '${endpointName}' is missing a URL/path after method. Use: ${endpointName}: ${upperFirstToken} /path`, vscode.DiagnosticSeverity.Error);
|
|
916
|
+
diagnostic.source = 'Norn';
|
|
917
|
+
diagnostic.code = 'missing-endpoint-path';
|
|
918
|
+
diagnostics.push(diagnostic);
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
const diagnostic = new vscode.Diagnostic(valueRange, `Endpoint '${endpointName}' is missing HTTP method. Use: ${endpointName}: GET /path`, vscode.DiagnosticSeverity.Error);
|
|
922
|
+
diagnostic.source = 'Norn';
|
|
923
|
+
diagnostic.code = 'missing-endpoint-method';
|
|
924
|
+
diagnostics.push(diagnostic);
|
|
925
|
+
}
|
|
926
|
+
this.diagnosticCollection.set(document.uri, diagnostics);
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Update diagnostics for .nornenv files
|
|
930
|
+
*/
|
|
931
|
+
updateNornenvDiagnostics(document) {
|
|
932
|
+
const diagnostics = [];
|
|
933
|
+
const text = document.getText();
|
|
934
|
+
const lines = text.split('\n');
|
|
935
|
+
for (let i = 0; i < lines.length; i++) {
|
|
936
|
+
const line = lines[i];
|
|
937
|
+
const trimmed = line.trim();
|
|
938
|
+
// Skip comments and empty lines
|
|
939
|
+
if (trimmed === '' || trimmed.startsWith('#')) {
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
// Check for invalid "secret var" pattern
|
|
943
|
+
const secretVarMatch = trimmed.match(/^(secret)\s+(var)\s*/i);
|
|
944
|
+
if (secretVarMatch) {
|
|
945
|
+
const varStart = line.indexOf('var', line.indexOf('secret') + 6);
|
|
946
|
+
const range = new vscode.Range(new vscode.Position(i, varStart), new vscode.Position(i, varStart + 3));
|
|
947
|
+
const diagnostic = new vscode.Diagnostic(range, `Invalid syntax: 'var' is not allowed after 'secret'. Use 'secret variableName = value'.`, vscode.DiagnosticSeverity.Error);
|
|
948
|
+
diagnostic.source = 'Norn';
|
|
949
|
+
diagnostic.code = 'invalid-secret-var';
|
|
950
|
+
diagnostics.push(diagnostic);
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
// Check for valid patterns - if none match, it's an error
|
|
954
|
+
const validPatterns = [
|
|
955
|
+
/^\[env:[a-zA-Z_][a-zA-Z0-9_-]*\]$/, // Environment section
|
|
956
|
+
/^(secret|var)\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*.+$/, // Complete declaration
|
|
957
|
+
/^(secret|var)\s*$/, // Just keyword (while typing)
|
|
958
|
+
/^(secret|var)\s+[a-zA-Z_][a-zA-Z0-9_]*\s*$/, // Keyword + name (while typing)
|
|
959
|
+
/^(secret|var)\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*$/, // Keyword + name + = (while typing)
|
|
960
|
+
];
|
|
961
|
+
const isValid = validPatterns.some(pattern => pattern.test(trimmed));
|
|
962
|
+
if (!isValid) {
|
|
963
|
+
const range = new vscode.Range(new vscode.Position(i, 0), new vscode.Position(i, line.length));
|
|
964
|
+
const diagnostic = new vscode.Diagnostic(range, `Invalid syntax. Expected: 'var name = value', 'secret name = value', or '[env:name]'.`, vscode.DiagnosticSeverity.Error);
|
|
965
|
+
diagnostic.source = 'Norn';
|
|
966
|
+
diagnostic.code = 'invalid-nornenv-syntax';
|
|
967
|
+
diagnostics.push(diagnostic);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
this.diagnosticCollection.set(document.uri, diagnostics);
|
|
971
|
+
}
|
|
972
|
+
clearDiagnostics(document) {
|
|
973
|
+
this.diagnosticCollection.delete(document.uri);
|
|
974
|
+
}
|
|
975
|
+
dispose() {
|
|
976
|
+
this.diagnosticCollection.dispose();
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
exports.DiagnosticProvider = DiagnosticProvider;
|
|
980
|
+
//# sourceMappingURL=diagnosticProvider.js.map
|