norn-cli 1.6.0 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/AGENTS.md +9 -1
  2. package/CHANGELOG.md +23 -0
  3. package/dist/cli.js +246 -80
  4. package/package.json +1 -1
  5. package/out/assertionRunner.js +0 -537
  6. package/out/chatParticipant.js +0 -722
  7. package/out/cli/colors.js +0 -129
  8. package/out/cli/formatters/assertion.js +0 -75
  9. package/out/cli/formatters/index.js +0 -23
  10. package/out/cli/formatters/response.js +0 -106
  11. package/out/cli/formatters/summary.js +0 -187
  12. package/out/cli/redaction.js +0 -237
  13. package/out/cli/reporters/html.js +0 -634
  14. package/out/cli/reporters/index.js +0 -22
  15. package/out/cli/reporters/junit.js +0 -211
  16. package/out/cli.js +0 -989
  17. package/out/codeLensProvider.js +0 -248
  18. package/out/compareContentProvider.js +0 -85
  19. package/out/completionProvider.js +0 -2404
  20. package/out/contractDecorationProvider.js +0 -243
  21. package/out/coverageCalculator.js +0 -837
  22. package/out/coveragePanel.js +0 -545
  23. package/out/diagnosticProvider.js +0 -1113
  24. package/out/environmentProvider.js +0 -442
  25. package/out/extension.js +0 -1114
  26. package/out/httpClient.js +0 -269
  27. package/out/jsonFileReader.js +0 -320
  28. package/out/nornPrompt.js +0 -580
  29. package/out/nornapiParser.js +0 -326
  30. package/out/parser.js +0 -725
  31. package/out/responsePanel.js +0 -4674
  32. package/out/schemaGenerator.js +0 -393
  33. package/out/scriptRunner.js +0 -419
  34. package/out/sequenceRunner.js +0 -3046
  35. package/out/swaggerBodyIntellisenseCache.js +0 -147
  36. package/out/swaggerParser.js +0 -419
  37. package/out/test/coverageCalculator.test.js +0 -100
  38. package/out/test/extension.test.js +0 -48
  39. package/out/testProvider.js +0 -658
  40. package/out/validationCache.js +0 -245
@@ -1,2404 +0,0 @@
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.HttpCompletionProvider = void 0;
37
- const vscode = __importStar(require("vscode"));
38
- const fs = __importStar(require("fs"));
39
- const path = __importStar(require("path"));
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
- const swaggerBodyIntellisenseCache_1 = require("./swaggerBodyIntellisenseCache");
45
- class HttpCompletionProvider {
46
- httpMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
47
- keywords = ['var', 'test', 'test sequence', 'sequence', 'end sequence', 'if', 'end if', 'wait', 'run bash', 'run powershell', 'run js', 'run readJson', 'run', 'print', 'assert', 'import', 'return', 'retry', 'backoff'];
48
- nornapiKeywords = ['headers', 'end headers', 'endpoints', 'end endpoints'];
49
- commonHeaders = [
50
- 'Content-Type',
51
- 'Authorization',
52
- 'Accept',
53
- 'Cache-Control',
54
- 'User-Agent',
55
- 'Accept-Encoding',
56
- 'Accept-Language',
57
- 'Connection',
58
- 'Host',
59
- 'Origin',
60
- 'Referer',
61
- 'Cookie',
62
- 'X-Requested-With',
63
- 'X-API-Key',
64
- ];
65
- contentTypes = [
66
- 'application/json',
67
- 'application/xml',
68
- 'application/x-www-form-urlencoded',
69
- 'multipart/form-data',
70
- 'text/plain',
71
- 'text/html',
72
- ];
73
- provideCompletionItems(document, position) {
74
- const lineText = document.lineAt(position).text;
75
- const linePrefix = lineText.substring(0, position.character);
76
- const lineSuffix = lineText.substring(position.character);
77
- const trimmedPrefix = linePrefix.trim().toUpperCase();
78
- // Handle .nornapi files separately
79
- if (document.languageId === 'nornapi') {
80
- return this.getNornapiCompletions(document, position, linePrefix, lineSuffix);
81
- }
82
- // Don't provide completions inside comments
83
- // Comments start with # but not #import
84
- if (this.isInsideComment(linePrefix)) {
85
- return [];
86
- }
87
- // EARLY CHECK: If typing a header value for Content-Type (works for both URL and API endpoint requests)
88
- if (linePrefix.toLowerCase().includes('content-type:')) {
89
- return this.getContentTypeCompletions();
90
- }
91
- // Check if user is typing a sequence tag (@)
92
- if (this.isTypingSequenceTag(linePrefix)) {
93
- return this.getSequenceTagCompletions(document, linePrefix);
94
- }
95
- // Check if user is typing a response capture reference ($N. or $N.body.)
96
- if (this.isTypingResponseCapture(linePrefix)) {
97
- return this.getResponseCaptureCompletions(document, position, linePrefix);
98
- }
99
- // Check if user is typing a variable property reference ({{varname.)
100
- if (this.isTypingVariableProperty(linePrefix)) {
101
- return this.getVariablePropertyCompletions(document, linePrefix, lineSuffix);
102
- }
103
- // Check if user is typing a variable reference (after { or {{)
104
- if (this.isTypingVariable(linePrefix)) {
105
- return this.getVariableCompletions(document, linePrefix, lineSuffix);
106
- }
107
- // Check if user is typing in a bare variable context (inside parentheses, after print, etc.)
108
- // This should be checked early - before run command or endpoint checks
109
- if (this.isTypingBareVariableContext(linePrefix, document, position)) {
110
- return this.getBareVariableCompletions(document, position, linePrefix);
111
- }
112
- // Check if user is typing after "run " - suggest named requests
113
- if (this.isTypingRunCommand(linePrefix)) {
114
- return this.getNamedRequestCompletions(document, linePrefix);
115
- }
116
- // Check if user is typing after a URL in a var request - suggest retry/backoff
117
- // e.g., "var result = GET "https://..." " or "var data = GET https://api.com "
118
- if (this.isTypingAfterRequestUrl(linePrefix)) {
119
- return this.getRetryBackoffCompletions(linePrefix);
120
- }
121
- // Check if user is typing after HTTP method + endpoint - suggest header groups and retry/backoff
122
- // e.g., "GET GetUser("1") " or "GET GetAllUsers " or "var x = GET GetUser(1) "
123
- if (this.isTypingAfterApiEndpoint(linePrefix, document)) {
124
- // Only suggest header groups in this context.
125
- // If no header groups are defined in imported .nornapi files, show nothing.
126
- return this.getHeaderGroupCompletions(document, linePrefix);
127
- }
128
- // Check if user is typing after HTTP method - suggest endpoints (in addition to URLs)
129
- // e.g., "GET " could suggest "GetUser" endpoint
130
- if (this.isTypingAfterHttpMethod(linePrefix)) {
131
- const endpointCompletions = this.getEndpointCompletions(document, linePrefix);
132
- if (endpointCompletions.length > 0) {
133
- return endpointCompletions;
134
- }
135
- }
136
- // Check if user is typing after "var x = " - suggest run commands
137
- if (this.isTypingVarAssignment(linePrefix)) {
138
- return this.getRunCompletionsForVarAssignment(document, linePrefix);
139
- }
140
- // Check if user is typing a variable name for property assignment (e.g., "config" or "config.")
141
- if (this.isTypingPropertyAssignment(linePrefix)) {
142
- return this.getPropertyAssignmentCompletions(document, linePrefix);
143
- }
144
- // Swagger-based request body IntelliSense for endpoint POST/PUT/PATCH calls.
145
- const requestBodyContext = this.getRequestBodyCompletionContext(document, position);
146
- if (requestBodyContext) {
147
- if (this.isTypingJsonBodyStart(linePrefix)) {
148
- return this.getRequestBodyTemplateCompletions(requestBodyContext, position, lineText);
149
- }
150
- const inlineBodyCompletions = this.getInlineRequestBodyKeyCompletions(requestBodyContext, document, position, linePrefix);
151
- if (inlineBodyCompletions.length > 0) {
152
- return inlineBodyCompletions;
153
- }
154
- }
155
- const isAfterApiRequest = this.isAfterApiRequest(document, position);
156
- // If a JSON body is being started right below an API request, don't show IntelliSense.
157
- // We don't know body shape, and header/group suggestions are noisy in this context.
158
- if (isAfterApiRequest && this.isTypingJsonBodyStart(linePrefix)) {
159
- return [];
160
- }
161
- // Check if we're after an API request (GET EndpointName, etc.)
162
- // Provide header groups and inline headers
163
- if (isAfterApiRequest) {
164
- // If typing a header name (no colon yet), provide both header groups and inline headers
165
- if (!linePrefix.includes(':')) {
166
- const headerGroupCompletions = this.getStandaloneHeaderGroupCompletions(document, linePrefix);
167
- const headerCompletions = this.getHeaderCompletions();
168
- return [...headerGroupCompletions, ...headerCompletions];
169
- }
170
- // If line has a colon but not Content-Type (which is handled earlier), return empty
171
- return [];
172
- }
173
- // Check if user might be typing a JSON variable name for property assignment
174
- const jsonVarCompletions = this.getJsonVariableCompletions(document, linePrefix);
175
- // If we have JSON variable completions, show those (for property assignment)
176
- if (jsonVarCompletions.length > 0) {
177
- return jsonVarCompletions;
178
- }
179
- // Show HTTP methods/keywords at line start (or while typing them),
180
- // but not after a METHOD ... context where endpoint/header logic applies.
181
- if ((position.character === 0 || linePrefix.trim() === '' || this.couldBeMethodOrKeyword(trimmedPrefix)) &&
182
- !this.isTypingAfterHttpMethod(linePrefix)) {
183
- const typedPrefix = linePrefix.trim().toLowerCase();
184
- const methodItems = this.getMethodCompletions(typedPrefix);
185
- const keywordItems = this.getKeywordCompletions();
186
- if (!typedPrefix) {
187
- return [...methodItems, ...keywordItems];
188
- }
189
- const filteredKeywords = keywordItems.filter(item => item.label.toString().toLowerCase().startsWith(typedPrefix));
190
- return [...methodItems, ...filteredKeywords];
191
- }
192
- return [];
193
- }
194
- /**
195
- * Check if the cursor is inside a comment.
196
- * Comments start with # but not #import
197
- * This handles both line comments (# at start of line) and inline comments (# after code)
198
- */
199
- isInsideComment(linePrefix) {
200
- const trimmed = linePrefix.trimStart();
201
- // Check if line starts with # but not #import
202
- if (trimmed.startsWith('#') && !trimmed.toLowerCase().startsWith('#import')) {
203
- return true;
204
- }
205
- // Check for inline comments: find # that's not inside quotes and not part of #import
206
- // We need to scan through the line and find if cursor is after an unquoted #
207
- let inSingleQuote = false;
208
- let inDoubleQuote = false;
209
- for (let i = 0; i < linePrefix.length; i++) {
210
- const char = linePrefix[i];
211
- const prevChar = i > 0 ? linePrefix[i - 1] : '';
212
- // Skip escaped quotes
213
- if (prevChar === '\\') {
214
- continue;
215
- }
216
- if (char === '"' && !inSingleQuote) {
217
- inDoubleQuote = !inDoubleQuote;
218
- }
219
- else if (char === "'" && !inDoubleQuote) {
220
- inSingleQuote = !inSingleQuote;
221
- }
222
- else if (char === '#' && !inSingleQuote && !inDoubleQuote) {
223
- // Found an unquoted #, cursor is in a comment
224
- return true;
225
- }
226
- }
227
- return false;
228
- }
229
- /**
230
- * Check if cursor is inside a quoted string
231
- */
232
- isInsideQuotes(linePrefix) {
233
- let inSingleQuote = false;
234
- let inDoubleQuote = false;
235
- for (let i = 0; i < linePrefix.length; i++) {
236
- const char = linePrefix[i];
237
- const prevChar = i > 0 ? linePrefix[i - 1] : '';
238
- // Skip escaped quotes
239
- if (prevChar === '\\') {
240
- continue;
241
- }
242
- if (char === '"' && !inSingleQuote) {
243
- inDoubleQuote = !inDoubleQuote;
244
- }
245
- else if (char === "'" && !inDoubleQuote) {
246
- inSingleQuote = !inSingleQuote;
247
- }
248
- }
249
- return inSingleQuote || inDoubleQuote;
250
- }
251
- /**
252
- * Detect the start of a JSON body line (e.g., "{" or "[") under an API request.
253
- */
254
- isTypingJsonBodyStart(linePrefix) {
255
- const trimmed = linePrefix.trimStart();
256
- return trimmed.startsWith('{') || trimmed.startsWith('[');
257
- }
258
- /**
259
- * Check if user is in a context where bare variable names should be suggested.
260
- * This includes:
261
- * - Inside function/endpoint parameters: GetUser(|) or GetUser(a, |)
262
- * - After print keyword: print |
263
- * - After operators in expressions: print "text" + |
264
- * - In assertion expressions: assert | == something
265
- *
266
- * NOT triggered when:
267
- * - Inside quoted strings
268
- * - Inside {{ }} (handled by getVariableCompletions)
269
- */
270
- isTypingBareVariableContext(linePrefix, document, position) {
271
- // Don't suggest if inside quotes
272
- if (this.isInsideQuotes(linePrefix)) {
273
- return false;
274
- }
275
- const trimmed = linePrefix.trim();
276
- // Check if inside parentheses (endpoint parameters)
277
- // e.g., "GET GetUser(" or "GetUser(a, "
278
- const openParens = (linePrefix.match(/\(/g) || []).length;
279
- const closeParens = (linePrefix.match(/\)/g) || []).length;
280
- if (openParens > closeParens) {
281
- // We're inside parentheses
282
- const lastOpenParen = linePrefix.lastIndexOf('(');
283
- const afterParen = linePrefix.substring(lastOpenParen + 1);
284
- // Check we're not inside a string within the parens
285
- if (!this.isInsideQuotes(afterParen)) {
286
- return true;
287
- }
288
- }
289
- // Check if after print keyword (but not in quotes)
290
- // e.g., "print " or "print text + "
291
- if (/^\s*print\s/i.test(linePrefix)) {
292
- // Get everything after "print "
293
- const printMatch = linePrefix.match(/print\s+(.*)$/i);
294
- if (printMatch) {
295
- const afterPrint = printMatch[1];
296
- if (!this.isInsideQuotes(afterPrint)) {
297
- return true;
298
- }
299
- }
300
- // If just "print " with nothing after, still show variables
301
- if (/print\s+$/i.test(linePrefix)) {
302
- return true;
303
- }
304
- }
305
- // Check if after assert keyword
306
- if (/^\s*assert\s/i.test(linePrefix)) {
307
- const assertMatch = linePrefix.match(/assert\s+(.*)$/i);
308
- if (assertMatch) {
309
- const afterAssert = assertMatch[1];
310
- if (!this.isInsideQuotes(afterAssert)) {
311
- return true;
312
- }
313
- }
314
- if (/assert\s+$/i.test(linePrefix)) {
315
- return true;
316
- }
317
- }
318
- // Check if after return keyword
319
- if (/^\s*return\s/i.test(linePrefix)) {
320
- const returnMatch = linePrefix.match(/return\s+(.*)$/i);
321
- if (returnMatch) {
322
- const afterReturn = returnMatch[1];
323
- if (!this.isInsideQuotes(afterReturn)) {
324
- return true;
325
- }
326
- }
327
- if (/return\s+$/i.test(linePrefix)) {
328
- return true;
329
- }
330
- }
331
- return false;
332
- }
333
- /**
334
- * Get completions for bare variable names in contexts like print, parameters, etc.
335
- */
336
- getBareVariableCompletions(document, position, linePrefix) {
337
- const fullText = document.getText();
338
- const fileVariables = (0, parser_1.extractVariables)(fullText);
339
- const envVariables = (0, environmentProvider_1.getEnvironmentVariables)(document.uri.fsPath);
340
- // Get sequence-local variables if we're inside a sequence
341
- const sequences = (0, sequenceRunner_1.extractSequences)(fullText);
342
- const cursorLine = position.line;
343
- const containingSequence = sequences.find(seq => cursorLine > seq.startLine && cursorLine < seq.endLine);
344
- const localVars = new Set();
345
- if (containingSequence) {
346
- // Extract local var declarations from the sequence content
347
- const lines = containingSequence.content.split('\n');
348
- for (const line of lines) {
349
- const varMatch = line.trim().match(/^var\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*=/);
350
- if (varMatch) {
351
- localVars.add(varMatch[1]);
352
- }
353
- }
354
- // Add sequence parameters
355
- if (containingSequence.parameters) {
356
- for (const param of containingSequence.parameters) {
357
- localVars.add(param.name);
358
- }
359
- }
360
- }
361
- // Merge all variables
362
- const allVariables = new Map();
363
- // Add environment variables
364
- for (const [name, value] of Object.entries(envVariables)) {
365
- allVariables.set(name, { value, source: 'env' });
366
- }
367
- // Add file variables
368
- for (const [name, value] of Object.entries(fileVariables)) {
369
- allVariables.set(name, { value, source: 'file' });
370
- }
371
- // Add local variables (highest priority)
372
- for (const name of localVars) {
373
- allVariables.set(name, { value: '(local)', source: 'local' });
374
- }
375
- if (allVariables.size === 0) {
376
- return [];
377
- }
378
- // Determine what the user is already typing
379
- let partialName = '';
380
- const partialMatch = linePrefix.match(/[a-zA-Z_][a-zA-Z0-9_]*$/);
381
- if (partialMatch) {
382
- partialName = partialMatch[0];
383
- }
384
- const items = [];
385
- for (const [name, { value, source }] of allVariables) {
386
- // Filter by partial name if user has started typing
387
- if (partialName && !name.toLowerCase().startsWith(partialName.toLowerCase())) {
388
- continue;
389
- }
390
- const kind = source === 'env'
391
- ? vscode.CompletionItemKind.Constant
392
- : source === 'local'
393
- ? vscode.CompletionItemKind.Variable
394
- : vscode.CompletionItemKind.Field;
395
- const item = new vscode.CompletionItem(name, kind);
396
- item.insertText = name;
397
- // Show source in detail
398
- const sourceLabel = source === 'env' ? 'env' : source === 'local' ? 'local' : 'file';
399
- item.detail = value !== '(local)' ? value : undefined;
400
- const sourceDesc = source === 'env'
401
- ? '**Source:** Environment'
402
- : source === 'local'
403
- ? '**Source:** Local sequence variable'
404
- : '**Source:** File';
405
- item.documentation = new vscode.MarkdownString(`**Variable:** \`${name}\`\n\n${sourceDesc}`);
406
- // Sort: local first, then file, then env
407
- item.sortText = source === 'local' ? `0_${name}` : source === 'file' ? `1_${name}` : `2_${name}`;
408
- items.push(item);
409
- }
410
- return items;
411
- }
412
- /**
413
- * Check if the line looks like code (variable assignment, etc.) rather than headers
414
- */
415
- looksLikeCode(linePrefix) {
416
- const trimmed = linePrefix.trim();
417
- // Looks like code if it starts with a lowercase letter followed by more text
418
- // Headers typically start with uppercase (Content-Type, Authorization, etc.)
419
- return /^[a-z_][a-zA-Z0-9_]*/.test(trimmed);
420
- }
421
- // Check if user is typing inside {{ }} for variable reference
422
- // Only trigger when there's content before the {
423
- isTypingVariable(linePrefix) {
424
- const trimmed = linePrefix.trim();
425
- // JSON body/object start should not trigger variable completions.
426
- // Body IntelliSense handles this context separately.
427
- if (trimmed === '{' || trimmed === '[') {
428
- return false;
429
- }
430
- // Must have something before the brace (not just starting with {)
431
- // e.g., "GET {{" or "Authorization: Bearer {{"
432
- // Check for {{ with content before it
433
- const doubleOpenMatch = trimmed.match(/\S+.*\{\{([a-zA-Z_][a-zA-Z0-9_]*)?$/);
434
- if (doubleOpenMatch) {
435
- return true;
436
- }
437
- // Check for single { at the end with content before it
438
- const singleOpenMatch = trimmed.match(/\S+.*\{$/);
439
- if (singleOpenMatch) {
440
- return true;
441
- }
442
- return false;
443
- }
444
- /**
445
- * Check if user is typing a property access on a variable: {{varname. OR varname. (for assertions)
446
- * Also handles nested access like user.body. or user.headers.
447
- */
448
- isTypingVariableProperty(linePrefix) {
449
- // Match pattern like {{varname. where varname is a valid identifier
450
- if (/\{\{[a-zA-Z_][a-zA-Z0-9_]*\.$/.test(linePrefix)) {
451
- return true;
452
- }
453
- // Match nested property like {{varname.body.
454
- if (/\{\{[a-zA-Z_][a-zA-Z0-9_]*\.(body|headers)\.$/.test(linePrefix)) {
455
- return true;
456
- }
457
- // Match pattern like assert varname. or varname.body. (for assertions without braces)
458
- // Only if preceded by assert, whitespace at line start, or common comparison contexts
459
- if (/(?:^|\s|assert\s+)[a-zA-Z_][a-zA-Z0-9_]*\.$/.test(linePrefix)) {
460
- return true;
461
- }
462
- // Match nested property for assertions: user.body. or user.headers.
463
- if (/(?:^|\s|assert\s+)[a-zA-Z_][a-zA-Z0-9_]*\.(body|headers)\.$/.test(linePrefix)) {
464
- return true;
465
- }
466
- return false;
467
- }
468
- /**
469
- * Get completions for variable properties based on what the variable was assigned from.
470
- * If variable came from a sequence with a return statement, show the return fields.
471
- * If variable came from a request (var x = GET url), show response properties.
472
- * Also handles nested properties like user.body. or user.headers.
473
- */
474
- getVariablePropertyCompletions(document, linePrefix, lineSuffix) {
475
- const fullText = document.getText();
476
- // For {{varName.}} style, check for closing braces
477
- let needsBraces = false;
478
- let closingBraces = '';
479
- if (/\{\{/.test(linePrefix)) {
480
- needsBraces = true;
481
- const closingBracesAhead = lineSuffix.match(/^\}+/);
482
- const numClosingBraces = closingBracesAhead ? closingBracesAhead[0].length : 0;
483
- const bracesToAdd = Math.max(0, 2 - numClosingBraces);
484
- closingBraces = '}'.repeat(bracesToAdd);
485
- }
486
- // Check for nested property access like user.body. or user.headers.
487
- const nestedMatch = linePrefix.match(/(?:\{\{)?([a-zA-Z_][a-zA-Z0-9_]*)\.(body|headers)\.([a-zA-Z_][a-zA-Z0-9_-]*)?$/i);
488
- if (nestedMatch) {
489
- const varName = nestedMatch[1];
490
- const parentProp = nestedMatch[2].toLowerCase();
491
- // Verify this variable is a captured response
492
- const varRequestMatch = fullText.match(new RegExp(`var\\s+${varName}\\s*=\\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\\s+`, 'i'));
493
- if (varRequestMatch) {
494
- if (parentProp === 'headers') {
495
- // Suggest common header names
496
- return this.getCommonHeaderCompletions(closingBraces);
497
- }
498
- // For body, we can't know the shape - return empty
499
- return [];
500
- }
501
- return [];
502
- }
503
- // Extract the variable name from {{varname. or just varname.
504
- let match = linePrefix.match(/\{\{([a-zA-Z_][a-zA-Z0-9_]*)\.$/);
505
- if (!match) {
506
- // Try bare variable (for assertions like assert user.body.username)
507
- match = linePrefix.match(/(?:^|\s)([a-zA-Z_][a-zA-Z0-9_]*)\.$/);
508
- }
509
- if (!match) {
510
- return [];
511
- }
512
- const varName = match[1];
513
- // Check if this variable was assigned from a request (var x = GET url)
514
- const varRequestMatch = fullText.match(new RegExp(`var\\s+${varName}\\s*=\\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\\s+`, 'i'));
515
- if (varRequestMatch) {
516
- // Variable is a captured response - suggest response properties
517
- return this.getResponsePropertyCompletions(closingBraces, varName);
518
- }
519
- // Find what this variable was assigned from
520
- // Look for "var varName = run SequenceName" pattern
521
- const varRunMatch = fullText.match(new RegExp(`var\\s+${varName}\\s*=\\s*run\\s+([a-zA-Z_][a-zA-Z0-9_-]*)(?:\\s*\\([^)]*\\))?`, 'i'));
522
- if (!varRunMatch) {
523
- return [];
524
- }
525
- const sequenceName = varRunMatch[1];
526
- // Find the sequence content - first in current file, then in imports
527
- let sequenceContent;
528
- // Find the sequence in current file first
529
- const sequences = (0, sequenceRunner_1.extractSequences)(fullText);
530
- const localSequence = sequences.find(s => s.name === sequenceName);
531
- if (localSequence) {
532
- sequenceContent = localSequence.content;
533
- }
534
- else {
535
- // Search imported files
536
- const importedSequence = this.findSequenceInImports(document, sequenceName);
537
- if (importedSequence) {
538
- sequenceContent = importedSequence.content;
539
- }
540
- }
541
- if (!sequenceContent) {
542
- return [];
543
- }
544
- // Get the return fields from the sequence
545
- const returnFields = (0, sequenceRunner_1.extractReturnVariables)(sequenceContent);
546
- if (!returnFields || returnFields.length === 0) {
547
- return [];
548
- }
549
- // Create completions for each return field
550
- return returnFields.map(field => {
551
- // Extract just the field name (last part after dots)
552
- const fieldName = field.includes('.') ? field.split('.').pop() : field;
553
- const item = new vscode.CompletionItem(fieldName, vscode.CompletionItemKind.Property);
554
- item.insertText = fieldName + closingBraces;
555
- item.detail = `from ${sequenceName}`;
556
- item.documentation = new vscode.MarkdownString(`Return field from sequence \`${sequenceName}\`\n\n**Expression:** \`${field}\``);
557
- item.sortText = `0_${fieldName}`;
558
- return item;
559
- });
560
- }
561
- /**
562
- * Get completions for response properties (used for variables that captured an HTTP response).
563
- * Suggests: body, status, statusText, headers, duration, cookies
564
- */
565
- getResponsePropertyCompletions(closingBraces, varName) {
566
- const items = [];
567
- const responseProperties = [
568
- { name: 'body', detail: 'Response body (parsed JSON or string)', doc: 'The response body. Access nested properties with `.body.property`' },
569
- { name: 'status', detail: 'HTTP status code (number)', doc: 'The HTTP status code, e.g., 200, 404, 500' },
570
- { name: 'statusText', detail: 'HTTP status text', doc: 'The HTTP status text, e.g., "OK", "Not Found"' },
571
- { name: 'headers', detail: 'Response headers', doc: 'Access headers with `.headers.Content-Type`' },
572
- { name: 'duration', detail: 'Request duration (ms)', doc: 'Time taken for the request in milliseconds' },
573
- { name: 'cookies', detail: 'Response cookies', doc: 'Cookies set by the response' },
574
- ];
575
- for (const prop of responseProperties) {
576
- const item = new vscode.CompletionItem(prop.name, vscode.CompletionItemKind.Property);
577
- item.insertText = prop.name + closingBraces;
578
- item.detail = prop.detail;
579
- item.documentation = new vscode.MarkdownString(`**${prop.name}**\n\n${prop.doc}\n\n**Usage:** \`{{${varName}.${prop.name}}}\``);
580
- item.sortText = `0_${prop.name}`;
581
- items.push(item);
582
- }
583
- return items;
584
- }
585
- /**
586
- * Get completions for common HTTP header names (used for .headers. context)
587
- */
588
- getCommonHeaderCompletions(closingBraces) {
589
- const commonHeaders = [
590
- 'Content-Type',
591
- 'Content-Length',
592
- 'Cache-Control',
593
- 'Set-Cookie',
594
- 'Authorization',
595
- 'Location',
596
- 'X-Request-Id',
597
- 'X-Correlation-Id',
598
- 'ETag',
599
- 'Last-Modified',
600
- 'Expires',
601
- 'Date',
602
- 'Server',
603
- ];
604
- return commonHeaders.map(header => {
605
- const item = new vscode.CompletionItem(header, vscode.CompletionItemKind.Property);
606
- item.insertText = header + closingBraces;
607
- item.detail = 'HTTP header';
608
- return item;
609
- });
610
- }
611
- /**
612
- * Search imported files for a sequence by name.
613
- * Returns the sequence if found, or undefined if not.
614
- */
615
- findSequenceInImports(document, sequenceName) {
616
- const importedDefinitions = this.getImportedRunDefinitions(document);
617
- const lowerName = sequenceName.toLowerCase();
618
- const found = importedDefinitions.sequences.find(entry => entry.sequence.name.toLowerCase() === lowerName);
619
- if (!found) {
620
- return undefined;
621
- }
622
- return { name: found.sequence.name, content: found.sequence.content };
623
- }
624
- /**
625
- * Collect named requests and sequences from imported .norn files (including nested imports).
626
- */
627
- getImportedRunDefinitions(document) {
628
- const fullText = document.getText();
629
- const imports = (0, parser_1.extractImports)(fullText);
630
- const namedRequests = [];
631
- const sequences = [];
632
- if (imports.length === 0) {
633
- return { namedRequests, sequences };
634
- }
635
- const documentDir = path.dirname(document.uri.fsPath);
636
- const visitedPaths = new Set([document.uri.fsPath]);
637
- const collectFromFile = (absolutePath) => {
638
- if (visitedPaths.has(absolutePath)) {
639
- return;
640
- }
641
- visitedPaths.add(absolutePath);
642
- let importedContent;
643
- try {
644
- importedContent = fs.readFileSync(absolutePath, 'utf8');
645
- }
646
- catch {
647
- return;
648
- }
649
- const fileNamedRequests = (0, parser_1.extractNamedRequests)(importedContent);
650
- for (const request of fileNamedRequests) {
651
- namedRequests.push({ request, sourcePath: absolutePath });
652
- }
653
- const fileSequences = (0, sequenceRunner_1.extractSequences)(importedContent);
654
- for (const sequence of fileSequences) {
655
- sequences.push({ sequence, sourcePath: absolutePath });
656
- }
657
- const nestedImports = (0, parser_1.extractImports)(importedContent);
658
- const importDir = path.dirname(absolutePath);
659
- for (const nestedImport of nestedImports) {
660
- if (nestedImport.path.endsWith('.nornapi')) {
661
- continue;
662
- }
663
- collectFromFile(path.resolve(importDir, nestedImport.path));
664
- }
665
- };
666
- for (const imp of imports) {
667
- if (imp.path.endsWith('.nornapi')) {
668
- continue;
669
- }
670
- collectFromFile(path.resolve(documentDir, imp.path));
671
- }
672
- return { namedRequests, sequences };
673
- }
674
- /**
675
- * Provide completions for .nornapi files
676
- */
677
- getNornapiCompletions(document, position, linePrefix, lineSuffix) {
678
- const trimmed = linePrefix.trim().toLowerCase();
679
- // Check if typing a variable reference ({{)
680
- if (this.isTypingVariable(linePrefix)) {
681
- return this.getVariableCompletions(document, linePrefix, lineSuffix);
682
- }
683
- // Determine context based on where we are in the file
684
- const textBefore = document.getText(new vscode.Range(new vscode.Position(0, 0), position));
685
- // Check if we're inside a headers block
686
- const lastHeadersStart = textBefore.lastIndexOf('headers ');
687
- const lastHeadersEnd = textBefore.lastIndexOf('end headers');
688
- const inHeadersBlock = lastHeadersStart > lastHeadersEnd;
689
- // Check if we're inside an endpoints block
690
- const lastEndpointsStart = textBefore.lastIndexOf('endpoints');
691
- const lastEndpointsEnd = textBefore.lastIndexOf('end endpoints');
692
- const inEndpointsBlock = lastEndpointsStart > lastEndpointsEnd &&
693
- !textBefore.substring(lastEndpointsStart).startsWith('endpoints '); // Not a header block named "endpoints"
694
- const items = [];
695
- // Inside headers block - suggest common header names or header values
696
- if (inHeadersBlock) {
697
- // Check if typing a header value for Content-Type
698
- if (linePrefix.toLowerCase().includes('content-type:')) {
699
- return this.getContentTypeCompletions();
700
- }
701
- // Check if typing "end"
702
- if ('end headers'.startsWith(trimmed) || trimmed === 'end' || trimmed === 'end ') {
703
- const endItem = new vscode.CompletionItem('end headers', vscode.CompletionItemKind.Keyword);
704
- endItem.detail = 'Close the headers block';
705
- endItem.sortText = '0_end';
706
- items.push(endItem);
707
- }
708
- // Suggest common headers
709
- for (const header of this.commonHeaders) {
710
- if (!trimmed || header.toLowerCase().startsWith(trimmed)) {
711
- const item = new vscode.CompletionItem(header, vscode.CompletionItemKind.Field);
712
- item.insertText = header + ': ';
713
- item.detail = 'HTTP header';
714
- // Trigger IntelliSense again after inserting the header (for Content-Type values, etc.)
715
- item.command = {
716
- command: 'editor.action.triggerSuggest',
717
- title: 'Trigger Suggest'
718
- };
719
- items.push(item);
720
- }
721
- }
722
- return items;
723
- }
724
- // Inside endpoints block - suggest HTTP methods and "end endpoints"
725
- if (inEndpointsBlock) {
726
- // Check if typing "end"
727
- if ('end endpoints'.startsWith(trimmed) || trimmed === 'end' || trimmed === 'end ') {
728
- const endItem = new vscode.CompletionItem('end endpoints', vscode.CompletionItemKind.Keyword);
729
- endItem.detail = 'Close the endpoints block';
730
- endItem.sortText = '0_end';
731
- items.push(endItem);
732
- }
733
- // Suggest endpoint definition pattern
734
- if (!trimmed || /^[a-z]/i.test(trimmed)) {
735
- const endpointItem = new vscode.CompletionItem('EndpointName: GET', vscode.CompletionItemKind.Snippet);
736
- endpointItem.insertText = new vscode.SnippetString('${1:EndpointName}: ${2|GET,POST,PUT,DELETE,PATCH|} ${3:url}');
737
- endpointItem.detail = 'Define an API endpoint';
738
- endpointItem.documentation = 'Create a new endpoint definition.\n\nExample: `GetUser: GET /users/{id}`';
739
- items.push(endpointItem);
740
- }
741
- return items;
742
- }
743
- // At top level - suggest "headers" and "endpoints" keywords
744
- if (!trimmed || 'headers'.startsWith(trimmed)) {
745
- const headersItem = new vscode.CompletionItem('headers', vscode.CompletionItemKind.Keyword);
746
- headersItem.insertText = new vscode.SnippetString('headers ${1:GroupName}\n$0\nend headers');
747
- headersItem.detail = 'Define a header group';
748
- headersItem.documentation = 'Create a reusable group of headers.\n\nExample:\n```\nheaders Auth\nAuthorization: Bearer {{token}}\nend headers\n```';
749
- items.push(headersItem);
750
- }
751
- if (!trimmed || 'endpoints'.startsWith(trimmed)) {
752
- const endpointsItem = new vscode.CompletionItem('endpoints', vscode.CompletionItemKind.Keyword);
753
- endpointsItem.insertText = new vscode.SnippetString('endpoints\n${1:EndpointName}: ${2|GET,POST,PUT,DELETE,PATCH|} ${3:url}\nend endpoints');
754
- endpointsItem.detail = 'Define API endpoints';
755
- endpointsItem.documentation = 'Create a block of endpoint definitions.\n\nExample:\n```\nendpoints\nGetUser: GET /users/{id}\nCreateUser: POST /users\nend endpoints\n```';
756
- items.push(endpointsItem);
757
- }
758
- if (!trimmed || 'swagger'.startsWith(trimmed)) {
759
- const swaggerItem = new vscode.CompletionItem('swagger', vscode.CompletionItemKind.Keyword);
760
- swaggerItem.insertText = new vscode.SnippetString('swagger "$0"');
761
- swaggerItem.detail = 'Import from OpenAPI/Swagger spec';
762
- swaggerItem.documentation = new vscode.MarkdownString('Import endpoints from an OpenAPI/Swagger specification URL.\n\n' +
763
- '**Usage:**\n```norn\nswagger https://petstore.swagger.io/v2/swagger.json\n```\n\n' +
764
- 'Click the ▶ button to parse the spec and generate endpoints.\n\n' +
765
- 'Supports OpenAPI 2.0 (Swagger) and OpenAPI 3.x specifications.');
766
- items.push(swaggerItem);
767
- }
768
- return items;
769
- }
770
- /**
771
- * Load API definitions (header groups and endpoints) from imported .nornapi files.
772
- */
773
- getApiDefinitionsFromImports(document) {
774
- const apiMetadata = this.getApiImportMetadata(document);
775
- return {
776
- headerGroups: apiMetadata.headerGroups,
777
- endpoints: apiMetadata.endpoints
778
- };
779
- }
780
- /**
781
- * Load imported .nornapi files with endpoint/header data and swagger URL metadata.
782
- */
783
- getApiImportMetadata(document) {
784
- const fullText = document.getText();
785
- const imports = (0, parser_1.extractImports)(fullText);
786
- const headerGroups = [];
787
- const endpoints = [];
788
- const apiFiles = [];
789
- if (imports.length === 0) {
790
- return { headerGroups, endpoints, apiFiles };
791
- }
792
- const documentDir = path.dirname(document.uri.fsPath);
793
- for (const imp of imports) {
794
- // Only process .nornapi files
795
- if (!imp.path.endsWith('.nornapi')) {
796
- continue;
797
- }
798
- try {
799
- const importPath = path.resolve(documentDir, imp.path);
800
- const importedContent = fs.readFileSync(importPath, 'utf8');
801
- const apiDef = (0, nornapiParser_1.parseNornApiFile)(importedContent);
802
- headerGroups.push(...apiDef.headerGroups);
803
- endpoints.push(...apiDef.endpoints);
804
- apiFiles.push({
805
- sourcePath: importPath,
806
- headerGroups: apiDef.headerGroups,
807
- endpoints: apiDef.endpoints,
808
- swaggerUrls: this.extractSwaggerUrlsFromNornapi(importedContent)
809
- });
810
- }
811
- catch {
812
- // Ignore import errors
813
- }
814
- }
815
- return { headerGroups, endpoints, apiFiles };
816
- }
817
- extractSwaggerUrlsFromNornapi(content) {
818
- const urls = new Set();
819
- const lines = content.split('\n');
820
- for (const line of lines) {
821
- const trimmed = line.trim();
822
- if (!trimmed || trimmed.startsWith('#')) {
823
- continue;
824
- }
825
- const quotedMatch = trimmed.match(/^swagger\s+["']([^"']+)["']\s*$/i);
826
- if (quotedMatch) {
827
- urls.add(quotedMatch[1]);
828
- continue;
829
- }
830
- const unquotedMatch = trimmed.match(/^swagger\s+(https?:\/\/\S+)\s*$/i);
831
- if (unquotedMatch) {
832
- urls.add(unquotedMatch[1]);
833
- }
834
- }
835
- return Array.from(urls);
836
- }
837
- getRequestBodyCompletionContext(document, position) {
838
- const apiMetadata = this.getApiImportMetadata(document);
839
- if (apiMetadata.endpoints.length === 0 || apiMetadata.apiFiles.length === 0) {
840
- return undefined;
841
- }
842
- const endpointSchemaMap = this.getEndpointRequestBodySchemaMap(apiMetadata);
843
- if (endpointSchemaMap.size === 0) {
844
- return undefined;
845
- }
846
- const headerGroupNames = new Set(apiMetadata.headerGroups.map(group => group.name));
847
- for (let lineNum = position.line; lineNum >= 0 && lineNum >= position.line - 80; lineNum--) {
848
- const trimmed = document.lineAt(lineNum).text.trim();
849
- if (!trimmed || trimmed.startsWith('#')) {
850
- continue;
851
- }
852
- const requestMatch = trimmed.match(/^(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+([a-zA-Z_][a-zA-Z0-9_]*)(?:\([^)]*\))?(?:\s+.*)?$/i);
853
- if (requestMatch) {
854
- const method = requestMatch[1].toUpperCase();
855
- const endpointName = requestMatch[2];
856
- if (!this.isRequestBodyMethod(method)) {
857
- return undefined;
858
- }
859
- const mapped = endpointSchemaMap.get(endpointName);
860
- if (!mapped) {
861
- return undefined;
862
- }
863
- if (mapped.endpoint.method.toUpperCase() !== method) {
864
- return undefined;
865
- }
866
- const bodyStartLine = this.findRequestBodyStartLine(document, lineNum, position.line, headerGroupNames);
867
- if (bodyStartLine === undefined || position.line < bodyStartLine) {
868
- return undefined;
869
- }
870
- return {
871
- method,
872
- endpointName,
873
- endpoint: mapped.endpoint,
874
- schema: mapped.schema,
875
- bodyStartLine
876
- };
877
- }
878
- if (lineNum !== position.line && this.isBoundaryCommandLine(trimmed)) {
879
- return undefined;
880
- }
881
- }
882
- return undefined;
883
- }
884
- getEndpointRequestBodySchemaMap(apiMetadata) {
885
- const endpointSchemaMap = new Map();
886
- for (const apiFile of apiMetadata.apiFiles) {
887
- if (apiFile.swaggerUrls.length === 0 || apiFile.endpoints.length === 0) {
888
- continue;
889
- }
890
- const cacheEntries = apiFile.swaggerUrls
891
- .map(url => (0, swaggerBodyIntellisenseCache_1.getCachedRequestBodySchemasForUrl)(url))
892
- .filter((entry) => !!entry);
893
- if (cacheEntries.length === 0) {
894
- continue;
895
- }
896
- for (const endpoint of apiFile.endpoints) {
897
- if (!this.isRequestBodyMethod(endpoint.method) || endpointSchemaMap.has(endpoint.name)) {
898
- continue;
899
- }
900
- const method = endpoint.method.toUpperCase();
901
- for (const cacheEntry of cacheEntries) {
902
- const normalizedPath = this.normalizeEndpointPathForSwagger(endpoint.path, cacheEntry.baseUrl);
903
- if (!normalizedPath) {
904
- continue;
905
- }
906
- const matchedSchema = cacheEntry.schemas.find(schema => schema.method.toUpperCase() === method &&
907
- schema.path === normalizedPath);
908
- if (!matchedSchema) {
909
- continue;
910
- }
911
- endpointSchemaMap.set(endpoint.name, {
912
- endpoint,
913
- schema: matchedSchema
914
- });
915
- break;
916
- }
917
- }
918
- }
919
- return endpointSchemaMap;
920
- }
921
- normalizeEndpointPathForSwagger(endpointPath, baseUrl) {
922
- const rawPath = endpointPath.trim();
923
- if (!rawPath) {
924
- return undefined;
925
- }
926
- const getBasePath = () => {
927
- if (!baseUrl) {
928
- return '';
929
- }
930
- try {
931
- const parsed = new URL(baseUrl);
932
- return parsed.pathname.replace(/\/$/, '');
933
- }
934
- catch {
935
- return '';
936
- }
937
- };
938
- const basePath = getBasePath();
939
- const normalizeAndStripBasePath = (candidatePath) => {
940
- const withoutQuery = candidatePath.split(/[?#]/)[0];
941
- let normalized = withoutQuery.startsWith('/') ? withoutQuery : `/${withoutQuery}`;
942
- if (basePath) {
943
- if (normalized === basePath) {
944
- return '/';
945
- }
946
- if (normalized.startsWith(`${basePath}/`)) {
947
- normalized = normalized.slice(basePath.length);
948
- }
949
- }
950
- return normalized || '/';
951
- };
952
- // Handle variable-prefix paths: {{baseUrl}}/users, {{apiRoot}}/v2/users, etc.
953
- const variablePrefixMatch = rawPath.match(/^\{\{[^}]+\}\}(.*)$/);
954
- if (variablePrefixMatch) {
955
- const suffix = variablePrefixMatch[1] || '';
956
- return normalizeAndStripBasePath(suffix || '/');
957
- }
958
- // Most imported endpoints are generated as full URLs, so strip baseUrl first when possible.
959
- if (baseUrl && rawPath.startsWith(baseUrl)) {
960
- const stripped = rawPath.slice(baseUrl.length);
961
- return normalizeAndStripBasePath(stripped || '/');
962
- }
963
- if (/^https?:\/\//i.test(rawPath)) {
964
- try {
965
- const endpointUrl = new URL(rawPath);
966
- if (baseUrl) {
967
- try {
968
- const parsedBaseUrl = new URL(baseUrl, endpointUrl.origin);
969
- const basePath = parsedBaseUrl.pathname.replace(/\/$/, '');
970
- // Compare by host to tolerate scheme differences (http vs https).
971
- if (endpointUrl.hostname === parsedBaseUrl.hostname) {
972
- if (basePath && endpointUrl.pathname.startsWith(`${basePath}/`)) {
973
- return endpointUrl.pathname.slice(basePath.length);
974
- }
975
- if (basePath && endpointUrl.pathname === basePath) {
976
- return '/';
977
- }
978
- }
979
- }
980
- catch {
981
- // Ignore malformed base URL values (e.g., templated server URLs)
982
- }
983
- }
984
- return normalizeAndStripBasePath(endpointUrl.pathname || '/');
985
- }
986
- catch {
987
- return undefined;
988
- }
989
- }
990
- return normalizeAndStripBasePath(rawPath);
991
- }
992
- findRequestBodyStartLine(document, requestLine, currentLine, headerGroupNames) {
993
- for (let lineNum = requestLine + 1; lineNum <= currentLine; lineNum++) {
994
- const trimmed = document.lineAt(lineNum).text.trim();
995
- if (!trimmed || trimmed.startsWith('#')) {
996
- continue;
997
- }
998
- if (headerGroupNames.has(trimmed)) {
999
- continue;
1000
- }
1001
- if (this.isInlineHeaderLine(trimmed)) {
1002
- continue;
1003
- }
1004
- return lineNum;
1005
- }
1006
- return undefined;
1007
- }
1008
- isInlineHeaderLine(trimmedLine) {
1009
- return /^[A-Za-z][A-Za-z0-9-]*:\s*.+$/.test(trimmedLine);
1010
- }
1011
- isBoundaryCommandLine(trimmedLine) {
1012
- return /^(###|\[|(?:test\s+)?sequence\b|end\s+sequence\b|end\s+if\b|endif\b|var\s+|run\s+|print\s+|assert\s+|if\s+|wait\s+|import\s+|return\s+|swagger\b|(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+)/i.test(trimmedLine);
1013
- }
1014
- isRequestBodyMethod(method) {
1015
- return method === 'POST' || method === 'PUT' || method === 'PATCH';
1016
- }
1017
- getRequestBodyTemplateCompletions(context, position, lineText) {
1018
- const templateValue = this.buildTemplateValueFromSchema(context.schema.schema);
1019
- const rawTemplate = JSON.stringify(templateValue, null, 4);
1020
- if (!rawTemplate) {
1021
- return [];
1022
- }
1023
- const indentation = lineText.match(/^\s*/)[0];
1024
- const indentedTemplate = rawTemplate
1025
- .split('\n')
1026
- .map(line => indentation + line)
1027
- .join('\n');
1028
- const replaceRange = new vscode.Range(new vscode.Position(position.line, 0), new vscode.Position(position.line, lineText.length));
1029
- const item = new vscode.CompletionItem('Insert request body template', vscode.CompletionItemKind.Snippet);
1030
- item.insertText = indentedTemplate;
1031
- item.range = replaceRange;
1032
- const triggerChar = lineText.trimStart().startsWith('[') ? '[' : '{';
1033
- item.filterText = triggerChar;
1034
- item.preselect = true;
1035
- item.detail = `${context.method} ${context.endpointName}`;
1036
- item.documentation = new vscode.MarkdownString(`Insert a request body skeleton for \`${context.endpointName}\` from cached Swagger schema.\n\n` +
1037
- 'Template includes required fields only.');
1038
- item.sortText = '0_request_body_template';
1039
- return [item];
1040
- }
1041
- buildTemplateValueFromSchema(schema) {
1042
- const normalizedSchema = this.normalizeSchemaForCompletions(schema);
1043
- const value = this.buildDefaultSchemaValue(normalizedSchema, true);
1044
- return value === undefined ? {} : value;
1045
- }
1046
- buildDefaultSchemaValue(schema, requiredOnly) {
1047
- const normalizedSchema = this.normalizeSchemaForCompletions(schema);
1048
- if (!normalizedSchema || typeof normalizedSchema !== 'object') {
1049
- return null;
1050
- }
1051
- if (normalizedSchema.default !== undefined) {
1052
- return normalizedSchema.default;
1053
- }
1054
- if (Array.isArray(normalizedSchema.enum) && normalizedSchema.enum.length > 0) {
1055
- return normalizedSchema.enum[0];
1056
- }
1057
- if (normalizedSchema.type === 'object' || normalizedSchema.properties) {
1058
- const properties = normalizedSchema.properties;
1059
- if (!properties || Object.keys(properties).length === 0) {
1060
- return {};
1061
- }
1062
- const required = Array.isArray(normalizedSchema.required)
1063
- ? normalizedSchema.required.filter((name) => typeof name === 'string')
1064
- : [];
1065
- const keys = requiredOnly
1066
- ? (required.length > 0 ? required : Object.keys(properties))
1067
- : Object.keys(properties);
1068
- const result = {};
1069
- for (const key of keys) {
1070
- result[key] = this.buildDefaultSchemaValue(properties[key], requiredOnly);
1071
- }
1072
- return result;
1073
- }
1074
- if (normalizedSchema.type === 'array' || normalizedSchema.items) {
1075
- return [];
1076
- }
1077
- switch (normalizedSchema.type) {
1078
- case 'number':
1079
- case 'integer':
1080
- return 0;
1081
- case 'boolean':
1082
- return false;
1083
- case 'string':
1084
- return '';
1085
- case 'null':
1086
- return null;
1087
- default:
1088
- return null;
1089
- }
1090
- }
1091
- getInlineRequestBodyKeyCompletions(context, document, position, linePrefix) {
1092
- if (!this.isTypingJsonObjectKey(linePrefix)) {
1093
- return [];
1094
- }
1095
- const bodyLinesBeforeCursor = [];
1096
- for (let lineNum = context.bodyStartLine; lineNum < position.line; lineNum++) {
1097
- bodyLinesBeforeCursor.push(document.lineAt(lineNum).text);
1098
- }
1099
- const jsonContext = this.parseJsonObjectContext(bodyLinesBeforeCursor);
1100
- const schemaNode = this.getSchemaNodeForPath(context.schema.schema, jsonContext.path);
1101
- const normalizedSchema = this.normalizeSchemaForCompletions(schemaNode);
1102
- const properties = normalizedSchema?.properties;
1103
- if (!properties || Object.keys(properties).length === 0) {
1104
- return [];
1105
- }
1106
- const required = new Set(Array.isArray(normalizedSchema.required)
1107
- ? normalizedSchema.required.filter((name) => typeof name === 'string')
1108
- : []);
1109
- const replaceTokenMatch = linePrefix.match(/"?[a-zA-Z0-9_-]*$/);
1110
- const replaceToken = replaceTokenMatch ? replaceTokenMatch[0] : '';
1111
- const partialMatch = linePrefix.match(/"?([a-zA-Z_][a-zA-Z0-9_-]*)?$/);
1112
- const partial = partialMatch?.[1]?.toLowerCase() || '';
1113
- const replaceStart = Math.max(0, position.character - replaceToken.length);
1114
- const replaceRange = new vscode.Range(new vscode.Position(position.line, replaceStart), position);
1115
- const items = [];
1116
- for (const [propertyName, propertySchema] of Object.entries(properties)) {
1117
- if (jsonContext.existingKeys.has(propertyName)) {
1118
- continue;
1119
- }
1120
- if (partial && !propertyName.toLowerCase().startsWith(partial)) {
1121
- continue;
1122
- }
1123
- const item = new vscode.CompletionItem(propertyName, vscode.CompletionItemKind.Property);
1124
- item.range = replaceRange;
1125
- item.insertText = `"${propertyName}": `;
1126
- item.detail = required.has(propertyName)
1127
- ? `${this.getSchemaTypeDescription(propertySchema)} (required)`
1128
- : this.getSchemaTypeDescription(propertySchema);
1129
- item.documentation = new vscode.MarkdownString(`Schema property for \`${context.endpointName}\` request body.`);
1130
- item.sortText = required.has(propertyName)
1131
- ? `0_${propertyName}`
1132
- : `1_${propertyName}`;
1133
- items.push(item);
1134
- }
1135
- return items;
1136
- }
1137
- isTypingJsonObjectKey(linePrefix) {
1138
- const trimmed = linePrefix.trim();
1139
- if (trimmed.startsWith('{') || trimmed.startsWith('[') || trimmed.startsWith('}') || trimmed.startsWith(']')) {
1140
- return false;
1141
- }
1142
- if (trimmed.includes(':') || trimmed.includes(',')) {
1143
- return false;
1144
- }
1145
- if (trimmed === '' || trimmed === '"') {
1146
- return true;
1147
- }
1148
- return /^"?[a-zA-Z_][a-zA-Z0-9_-]*$/.test(trimmed);
1149
- }
1150
- parseJsonObjectContext(lines) {
1151
- const pathStack = [];
1152
- const keysByPath = new Map();
1153
- const getPathKey = () => pathStack.join('.');
1154
- const ensureKeySet = (pathKey) => {
1155
- if (!keysByPath.has(pathKey)) {
1156
- keysByPath.set(pathKey, new Set());
1157
- }
1158
- return keysByPath.get(pathKey);
1159
- };
1160
- for (const line of lines) {
1161
- const trimmed = line.trim();
1162
- if (!trimmed || trimmed.startsWith('#')) {
1163
- continue;
1164
- }
1165
- const closingMatch = trimmed.match(/^[}\]]+/);
1166
- if (closingMatch) {
1167
- for (const _ of closingMatch[0]) {
1168
- if (pathStack.length > 0) {
1169
- pathStack.pop();
1170
- }
1171
- }
1172
- }
1173
- const currentPath = getPathKey();
1174
- const addExistingKey = (key) => {
1175
- ensureKeySet(currentPath).add(key);
1176
- };
1177
- const objectStartMatch = trimmed.match(/^"([^"]+)"\s*:\s*\{\s*,?\s*$/);
1178
- if (objectStartMatch) {
1179
- addExistingKey(objectStartMatch[1]);
1180
- pathStack.push(objectStartMatch[1]);
1181
- continue;
1182
- }
1183
- const arrayStartMatch = trimmed.match(/^"([^"]+)"\s*:\s*\[\s*,?\s*$/);
1184
- if (arrayStartMatch) {
1185
- addExistingKey(arrayStartMatch[1]);
1186
- pathStack.push(arrayStartMatch[1]);
1187
- continue;
1188
- }
1189
- const propertyMatch = trimmed.match(/^"([^"]+)"\s*:/);
1190
- if (propertyMatch) {
1191
- addExistingKey(propertyMatch[1]);
1192
- }
1193
- }
1194
- const finalPath = getPathKey();
1195
- return {
1196
- path: [...pathStack],
1197
- existingKeys: new Set(keysByPath.get(finalPath) || [])
1198
- };
1199
- }
1200
- getSchemaNodeForPath(schema, pathSegments) {
1201
- let currentSchema = this.normalizeSchemaForCompletions(schema);
1202
- for (const segment of pathSegments) {
1203
- currentSchema = this.normalizeSchemaForCompletions(currentSchema);
1204
- if (!currentSchema || typeof currentSchema !== 'object') {
1205
- return undefined;
1206
- }
1207
- const properties = currentSchema.properties;
1208
- if (!properties || !(segment in properties)) {
1209
- return undefined;
1210
- }
1211
- const propertySchema = this.normalizeSchemaForCompletions(properties[segment]);
1212
- if (propertySchema?.type === 'array' && propertySchema.items) {
1213
- currentSchema = this.normalizeSchemaForCompletions(propertySchema.items);
1214
- }
1215
- else {
1216
- currentSchema = propertySchema;
1217
- }
1218
- }
1219
- return this.normalizeSchemaForCompletions(currentSchema);
1220
- }
1221
- normalizeSchemaForCompletions(schema) {
1222
- if (!schema || typeof schema !== 'object') {
1223
- return schema;
1224
- }
1225
- if (Array.isArray(schema.allOf) && schema.allOf.length > 0) {
1226
- const merged = { ...schema };
1227
- delete merged.allOf;
1228
- for (const part of schema.allOf) {
1229
- const normalizedPart = this.normalizeSchemaForCompletions(part);
1230
- if (!normalizedPart || typeof normalizedPart !== 'object') {
1231
- continue;
1232
- }
1233
- if (!merged.type && normalizedPart.type) {
1234
- merged.type = normalizedPart.type;
1235
- }
1236
- if (normalizedPart.properties && typeof normalizedPart.properties === 'object') {
1237
- merged.properties = {
1238
- ...(merged.properties || {}),
1239
- ...normalizedPart.properties
1240
- };
1241
- }
1242
- if (normalizedPart.items && !merged.items) {
1243
- merged.items = normalizedPart.items;
1244
- }
1245
- if (Array.isArray(normalizedPart.required)) {
1246
- const existing = Array.isArray(merged.required) ? merged.required : [];
1247
- merged.required = Array.from(new Set([...existing, ...normalizedPart.required]));
1248
- }
1249
- }
1250
- return merged;
1251
- }
1252
- if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) {
1253
- return this.normalizeSchemaForCompletions(schema.oneOf[0]);
1254
- }
1255
- if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
1256
- return this.normalizeSchemaForCompletions(schema.anyOf[0]);
1257
- }
1258
- return schema;
1259
- }
1260
- getSchemaTypeDescription(schema) {
1261
- const normalizedSchema = this.normalizeSchemaForCompletions(schema);
1262
- if (!normalizedSchema || typeof normalizedSchema !== 'object') {
1263
- return 'value';
1264
- }
1265
- if (Array.isArray(normalizedSchema.enum) && normalizedSchema.enum.length > 0) {
1266
- const enumPreview = normalizedSchema.enum
1267
- .slice(0, 3)
1268
- .map((value) => JSON.stringify(value))
1269
- .join(', ');
1270
- return normalizedSchema.enum.length > 3
1271
- ? `enum (${enumPreview}, ...)`
1272
- : `enum (${enumPreview})`;
1273
- }
1274
- if (typeof normalizedSchema.type === 'string') {
1275
- return normalizedSchema.type;
1276
- }
1277
- if (normalizedSchema.properties) {
1278
- return 'object';
1279
- }
1280
- if (normalizedSchema.items) {
1281
- return 'array';
1282
- }
1283
- return 'value';
1284
- }
1285
- /**
1286
- * Check if user is typing after an HTTP method (e.g., "GET " or "POST " or "var x = GET ")
1287
- * Returns false if inside open parentheses (user is typing parameters)
1288
- */
1289
- isTypingAfterHttpMethod(linePrefix) {
1290
- // Don't match if we're inside open parentheses (typing parameters)
1291
- const openParens = (linePrefix.match(/\(/g) || []).length;
1292
- const closeParens = (linePrefix.match(/\)/g) || []).length;
1293
- if (openParens > closeParens) {
1294
- return false;
1295
- }
1296
- // Match "METHOD " or "METHOD partial" (preserve trailing spaces)
1297
- let match = linePrefix.match(/^\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+.*$/i);
1298
- if (match) {
1299
- return true;
1300
- }
1301
- // Also match "var x = METHOD " pattern
1302
- match = linePrefix.match(/^\s*var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+.*$/i);
1303
- return !!match;
1304
- }
1305
- /**
1306
- * Check if user is typing after an API endpoint (for header group suggestions)
1307
- * e.g., "GET GetUser("1") " or "GET GetAllUsers " or "var x = GET GetUser(1) "
1308
- */
1309
- isTypingAfterApiEndpoint(linePrefix, document) {
1310
- // Match: METHOD EndpointName or METHOD EndpointName(params) followed by space
1311
- // Also match: var x = METHOD EndpointName(params) followed by space
1312
- let match = linePrefix.match(/^\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+([a-zA-Z_][a-zA-Z0-9_]*)(?:\([^)]*\))?\s+.*$/i);
1313
- if (!match) {
1314
- // Try var x = METHOD EndpointName(params) pattern
1315
- match = linePrefix.match(/^\s*var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+([a-zA-Z_][a-zA-Z0-9_]*)(?:\([^)]*\))?\s+.*$/i);
1316
- }
1317
- if (!match) {
1318
- return false;
1319
- }
1320
- const potentialEndpointName = match[2];
1321
- // Check if this is actually an endpoint from .nornapi imports
1322
- const apiDefs = this.getApiDefinitionsFromImports(document);
1323
- return apiDefs.endpoints.some(ep => ep.name === potentialEndpointName);
1324
- }
1325
- /**
1326
- * Check if user is typing after a URL in a var request line
1327
- * e.g., "var result = GET "https://..." " or "var data = GET https://api.com "
1328
- */
1329
- isTypingAfterRequestUrl(linePrefix) {
1330
- const trimmed = linePrefix.trim();
1331
- // Match: var name = METHOD "url" (quoted URL with everything after closing quote)
1332
- // or: var name = METHOD url (unquoted URL ending in space)
1333
- const quotedMatch = trimmed.match(/^var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+"[^"]+"\s+/i);
1334
- if (quotedMatch) {
1335
- return true;
1336
- }
1337
- // Also match unquoted URLs: var x = GET https://url space
1338
- const unquotedMatch = trimmed.match(/^var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+https?:\/\/\S+\s+/i);
1339
- if (unquotedMatch) {
1340
- return true;
1341
- }
1342
- return false;
1343
- }
1344
- /**
1345
- * Get retry/backoff completions after a request URL
1346
- */
1347
- getRetryBackoffCompletions(linePrefix) {
1348
- const items = [];
1349
- const trimmed = linePrefix.trim().toLowerCase();
1350
- // If retry is not already on the line, suggest it
1351
- if (!trimmed.includes('retry')) {
1352
- const retryItem = new vscode.CompletionItem('retry', vscode.CompletionItemKind.Keyword);
1353
- retryItem.detail = 'Retry failed requests';
1354
- retryItem.documentation = 'retry N - Retry the request N times on failure (5xx, 429, network errors)';
1355
- retryItem.insertText = 'retry ';
1356
- items.push(retryItem);
1357
- }
1358
- // If backoff is not already on the line, suggest it
1359
- if (!trimmed.includes('backoff')) {
1360
- const backoffItem = new vscode.CompletionItem('backoff', vscode.CompletionItemKind.Keyword);
1361
- backoffItem.detail = 'Backoff duration between retries';
1362
- backoffItem.documentation = 'backoff N ms - Wait N milliseconds between retries (linear: N * attempt)';
1363
- backoffItem.insertText = 'backoff ';
1364
- items.push(backoffItem);
1365
- }
1366
- return items;
1367
- }
1368
- /**
1369
- * Get endpoint completions after HTTP method
1370
- */
1371
- getEndpointCompletions(document, linePrefix) {
1372
- const apiDefs = this.getApiDefinitionsFromImports(document);
1373
- if (apiDefs.endpoints.length === 0) {
1374
- return [];
1375
- }
1376
- // Extract what's typed after the method
1377
- // Try "METHOD partial" first, then "var x = METHOD partial"
1378
- let match = linePrefix.match(/^\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(.*)$/i);
1379
- if (!match) {
1380
- match = linePrefix.match(/^\s*var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(.*)$/i);
1381
- }
1382
- if (!match) {
1383
- return [];
1384
- }
1385
- const methodFromLine = match[1].toUpperCase();
1386
- const partial = (match[2] || '').trim();
1387
- const items = [];
1388
- for (const endpoint of apiDefs.endpoints) {
1389
- // Only show endpoints matching the currently typed method
1390
- if (endpoint.method !== methodFromLine) {
1391
- continue;
1392
- }
1393
- // Filter by typed endpoint prefix (if any)
1394
- if (partial && !endpoint.name.toLowerCase().startsWith(partial.toLowerCase())) {
1395
- continue;
1396
- }
1397
- const item = new vscode.CompletionItem(endpoint.name, vscode.CompletionItemKind.Function);
1398
- // Just insert the endpoint name - let user add () and arguments manually
1399
- item.insertText = endpoint.name;
1400
- // Show endpoint details
1401
- const endpointSignature = `${endpoint.method} ${endpoint.path}`;
1402
- item.detail = endpointSignature;
1403
- item.documentation = new vscode.MarkdownString(`**${endpoint.name}**\n\n\`${endpointSignature}\`\n\n` +
1404
- (endpoint.parameters.length > 0
1405
- ? `Parameters: ${endpoint.parameters.map(p => `\`{${p}}\``).join(', ')}`
1406
- : 'No parameters'));
1407
- item.sortText = `0_${endpoint.name}`;
1408
- items.push(item);
1409
- }
1410
- return items;
1411
- }
1412
- /**
1413
- * Get header group completions after an API endpoint
1414
- */
1415
- getHeaderGroupCompletions(document, linePrefix) {
1416
- const apiDefs = this.getApiDefinitionsFromImports(document);
1417
- if (apiDefs.headerGroups.length === 0) {
1418
- return [];
1419
- }
1420
- const trimmed = linePrefix.trim();
1421
- // Extract what's already typed after the endpoint
1422
- // Pattern: METHOD EndpointName(params) [HeaderGroups...] partial
1423
- // Also: var x = METHOD EndpointName(params) [HeaderGroups...] partial
1424
- let match = trimmed.match(/^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+([a-zA-Z_][a-zA-Z0-9_]*)(?:\([^)]*\))?\s+(.*)$/i);
1425
- if (!match) {
1426
- // Try var x = METHOD EndpointName(params) pattern
1427
- match = trimmed.match(/^var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+([a-zA-Z_][a-zA-Z0-9_]*)(?:\([^)]*\))?\s+(.*)$/i);
1428
- }
1429
- let alreadyUsed = [];
1430
- let partial = '';
1431
- if (match) {
1432
- const afterEndpoint = match[3] || '';
1433
- const tokens = afterEndpoint.split(/\s+/).filter(t => t); // Filter out empty tokens
1434
- // The last token might be a partial word being typed
1435
- if (tokens.length > 0) {
1436
- const lastToken = tokens[tokens.length - 1];
1437
- // Check if last token is a complete header group name
1438
- if (apiDefs.headerGroups.some(hg => hg.name === lastToken)) {
1439
- // It's complete, so partial is empty
1440
- alreadyUsed = tokens;
1441
- }
1442
- else {
1443
- // Last token is a partial
1444
- partial = lastToken;
1445
- alreadyUsed = tokens.slice(0, -1);
1446
- }
1447
- }
1448
- // If no tokens, partial stays empty and we show all header groups
1449
- }
1450
- const items = [];
1451
- for (const hg of apiDefs.headerGroups) {
1452
- // Skip already used header groups
1453
- if (alreadyUsed.includes(hg.name)) {
1454
- continue;
1455
- }
1456
- // Filter by partial
1457
- if (partial && !hg.name.toLowerCase().startsWith(partial.toLowerCase())) {
1458
- continue;
1459
- }
1460
- const item = new vscode.CompletionItem(hg.name, vscode.CompletionItemKind.Module);
1461
- item.insertText = hg.name + ' ';
1462
- // Show headers in the group
1463
- const headerList = Object.entries(hg.headers)
1464
- .map(([name, value]) => ` ${name}: ${value}`)
1465
- .join('\n');
1466
- item.detail = `Header group (${Object.keys(hg.headers).length} headers)`;
1467
- item.documentation = new vscode.MarkdownString(`**${hg.name}**\n\nHeaders:\n\`\`\`\n${headerList}\n\`\`\``);
1468
- item.sortText = `0_${hg.name}`;
1469
- items.push(item);
1470
- }
1471
- return items;
1472
- }
1473
- /**
1474
- * Check if we're on a line after an HTTP request.
1475
- * Supports:
1476
- * - METHOD EndpointName / METHOD URL
1477
- * - var x = METHOD EndpointName / URL
1478
- * Allows inline headers and header groups on following lines.
1479
- */
1480
- isAfterApiRequest(document, position) {
1481
- const apiDefs = this.getApiDefinitionsFromImports(document);
1482
- // Look at previous lines to see if we're after a request block start.
1483
- for (let lineNum = position.line - 1; lineNum >= 0 && lineNum >= position.line - 10; lineNum--) {
1484
- const prevLine = document.lineAt(lineNum).text.trim();
1485
- // Skip empty lines
1486
- if (!prevLine) {
1487
- // Empty line means we're past the request block
1488
- return false;
1489
- }
1490
- // Skip comment lines
1491
- if (prevLine.startsWith('#')) {
1492
- continue;
1493
- }
1494
- // Skip header group names on their own line
1495
- if (apiDefs.headerGroups.some(hg => hg.name === prevLine)) {
1496
- continue;
1497
- }
1498
- // Skip inline headers (lines with HeaderName: value pattern)
1499
- if (/^[A-Za-z][A-Za-z0-9-]*:\s*.+$/.test(prevLine)) {
1500
- continue;
1501
- }
1502
- // Request start: METHOD ...
1503
- if (/^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+.+$/i.test(prevLine)) {
1504
- return true;
1505
- }
1506
- // Request start: var x = METHOD ...
1507
- if (/^var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+.+$/i.test(prevLine)) {
1508
- return true;
1509
- }
1510
- // If we hit another kind of line that's not recognized, stop
1511
- break;
1512
- }
1513
- return false;
1514
- }
1515
- /**
1516
- * Check if user might be typing a header group name on its own line
1517
- * This happens after an API request when header groups are on separate lines:
1518
- * GET GetAllUsers
1519
- * Json
1520
- * Auth
1521
- */
1522
- isTypingStandaloneHeaderGroup(linePrefix, document, position) {
1523
- const trimmed = linePrefix.trim();
1524
- // Must be a simple identifier (no spaces, colons, or special chars except what's being typed)
1525
- if (trimmed.includes(':') || trimmed.includes(' ') || trimmed.includes('/')) {
1526
- return false;
1527
- }
1528
- // Check if there are any .nornapi imports with header groups
1529
- const apiDefs = this.getApiDefinitionsFromImports(document);
1530
- if (apiDefs.headerGroups.length === 0) {
1531
- return false;
1532
- }
1533
- // Check if any header group name starts with what's typed
1534
- if (trimmed && !apiDefs.headerGroups.some(hg => hg.name.toLowerCase().startsWith(trimmed.toLowerCase()))) {
1535
- return false;
1536
- }
1537
- // Look at previous lines to see if we're after an API request
1538
- for (let lineNum = position.line - 1; lineNum >= 0 && lineNum >= position.line - 10; lineNum--) {
1539
- const prevLine = document.lineAt(lineNum).text.trim();
1540
- // Skip empty lines and header group names
1541
- if (!prevLine || apiDefs.headerGroups.some(hg => hg.name === prevLine)) {
1542
- continue;
1543
- }
1544
- // Check if this line is an API request (METHOD EndpointName)
1545
- const match = prevLine.match(/^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+([a-zA-Z_][a-zA-Z0-9_]*)(?:\([^)]*\))?/i);
1546
- if (match) {
1547
- const endpointName = match[2];
1548
- if (apiDefs.endpoints.some(ep => ep.name === endpointName)) {
1549
- return true;
1550
- }
1551
- }
1552
- // If we hit another kind of line (not empty, not header group, not API request), stop
1553
- break;
1554
- }
1555
- return false;
1556
- }
1557
- /**
1558
- * Get header group completions for a standalone line (after an API request)
1559
- */
1560
- getStandaloneHeaderGroupCompletions(document, linePrefix) {
1561
- const apiDefs = this.getApiDefinitionsFromImports(document);
1562
- const trimmed = linePrefix.trim();
1563
- const items = [];
1564
- for (const hg of apiDefs.headerGroups) {
1565
- if (trimmed && !hg.name.toLowerCase().startsWith(trimmed.toLowerCase())) {
1566
- continue;
1567
- }
1568
- const item = new vscode.CompletionItem(hg.name, vscode.CompletionItemKind.Module);
1569
- item.insertText = hg.name;
1570
- const headerList = Object.entries(hg.headers)
1571
- .map(([name, value]) => ` ${name}: ${value}`)
1572
- .join('\n');
1573
- item.detail = `Header group (${Object.keys(hg.headers).length} headers)`;
1574
- item.documentation = new vscode.MarkdownString(`**${hg.name}**\n\nHeaders:\n\`\`\`\n${headerList}\n\`\`\``);
1575
- item.sortText = `0_${hg.name}`;
1576
- items.push(item);
1577
- }
1578
- return items;
1579
- }
1580
- getVariableCompletions(document, linePrefix, lineSuffix) {
1581
- const fullText = document.getText();
1582
- const fileVariables = (0, parser_1.extractVariables)(fullText);
1583
- const envVariables = (0, environmentProvider_1.getEnvironmentVariables)(document.uri.fsPath);
1584
- const activeEnv = (0, environmentProvider_1.getActiveEnvironment)();
1585
- // Merge: env variables first, then file variables (file takes precedence for values)
1586
- const allVariables = {};
1587
- // Add environment variables
1588
- for (const [name, value] of Object.entries(envVariables)) {
1589
- allVariables[name] = { value, source: 'env' };
1590
- }
1591
- // Add/override with file variables
1592
- for (const [name, value] of Object.entries(fileVariables)) {
1593
- allVariables[name] = { value, source: 'file' };
1594
- }
1595
- if (Object.keys(allVariables).length === 0) {
1596
- return [];
1597
- }
1598
- // Determine context: how many braces are already typed
1599
- const endsWithDoubleBrace = linePrefix.endsWith('{{');
1600
- const endsWithSingleBrace = linePrefix.endsWith('{') && !endsWithDoubleBrace;
1601
- // Check if typing variable name after {{
1602
- const partialVarMatch = linePrefix.match(/\{\{([a-zA-Z_][a-zA-Z0-9_]*)$/);
1603
- const partialVarName = partialVarMatch ? partialVarMatch[1] : '';
1604
- // Check how many closing braces are already ahead (from VS Code auto-close)
1605
- const closingBracesAhead = lineSuffix.match(/^\}+/);
1606
- const numClosingBraces = closingBracesAhead ? closingBracesAhead[0].length : 0;
1607
- // Calculate how many }} we need to add
1608
- const bracesToAdd = Math.max(0, 2 - numClosingBraces);
1609
- const closingBraces = '}'.repeat(bracesToAdd);
1610
- return Object.entries(allVariables).map(([name, { value, source }]) => {
1611
- // Use different icons for file vs environment variables
1612
- const kind = source === 'env'
1613
- ? vscode.CompletionItemKind.Constant // Globe-like icon
1614
- : vscode.CompletionItemKind.Variable;
1615
- const item = new vscode.CompletionItem(name, kind);
1616
- // Determine what to insert based on context
1617
- if (partialVarName) {
1618
- // User already typed {{ and partial name, just complete the name + }}
1619
- item.insertText = name + closingBraces;
1620
- item.range = new vscode.Range(new vscode.Position(0, linePrefix.length - partialVarName.length), new vscode.Position(0, linePrefix.length));
1621
- }
1622
- else if (endsWithDoubleBrace) {
1623
- // User typed {{, add name + }}
1624
- item.insertText = name + closingBraces;
1625
- }
1626
- else if (endsWithSingleBrace) {
1627
- // User typed {, add { + name + }}
1628
- item.insertText = '{' + name + closingBraces;
1629
- }
1630
- else {
1631
- // Fallback: add full {{name}}
1632
- item.insertText = '{{' + name + closingBraces;
1633
- }
1634
- // Show source in detail
1635
- const sourceLabel = source === 'env'
1636
- ? `$(globe) env${activeEnv ? `:${activeEnv}` : ''}`
1637
- : '$(file) file';
1638
- item.detail = `${value}`;
1639
- const sourceDesc = source === 'env'
1640
- ? `**Source:** Environment${activeEnv ? ` (${activeEnv})` : ''}`
1641
- : '**Source:** File';
1642
- item.documentation = new vscode.MarkdownString(`**Variable:** \`${name}\`\n\n**Value:** \`${value}\`\n\n${sourceDesc}`);
1643
- // Sort: file variables first (they override), then env
1644
- item.sortText = source === 'file' ? `0_${name}` : `1_${name}`;
1645
- return item;
1646
- });
1647
- }
1648
- // Check if the typed text could be the start of an HTTP method or keyword
1649
- couldBeMethodOrKeyword(text) {
1650
- if (text.length === 0) {
1651
- return false;
1652
- }
1653
- const lowerText = text.toLowerCase();
1654
- const allKeywords = [...this.httpMethods.map(m => m.toLowerCase()), ...this.keywords];
1655
- return allKeywords.some(kw => kw.startsWith(lowerText));
1656
- }
1657
- getMethodCompletions(typedText) {
1658
- const lowerTyped = typedText?.toLowerCase() || '';
1659
- return this.httpMethods
1660
- .filter(method => !lowerTyped || method.toLowerCase().startsWith(lowerTyped))
1661
- .map(method => {
1662
- const item = new vscode.CompletionItem(method, vscode.CompletionItemKind.Keyword);
1663
- item.insertText = method;
1664
- item.documentation = `HTTP ${method} request`;
1665
- // Sort HTTP methods at the top
1666
- item.sortText = '0_' + method;
1667
- // Preselect if user is actively typing this method
1668
- if (lowerTyped && method.toLowerCase().startsWith(lowerTyped)) {
1669
- item.preselect = true;
1670
- }
1671
- return item;
1672
- });
1673
- }
1674
- getKeywordCompletions() {
1675
- const items = [];
1676
- // var keyword
1677
- const varItem = new vscode.CompletionItem('var', vscode.CompletionItemKind.Keyword);
1678
- varItem.insertText = 'var ';
1679
- varItem.documentation = new vscode.MarkdownString('Declare a variable.\n\n`var myVar = someValue`\n\nReference with `{{myVar}}`');
1680
- varItem.sortText = '1_var';
1681
- items.push(varItem);
1682
- // sequence keyword
1683
- const seqItem = new vscode.CompletionItem('sequence', vscode.CompletionItemKind.Keyword);
1684
- seqItem.insertText = new vscode.SnippetString('sequence $0\n\nend sequence');
1685
- seqItem.documentation = new vscode.MarkdownString('Define a sequence of requests to run together.\n\n```\nsequence auth-flow\n\nPOST /login\n...\n\nvar token = $1.accessToken\n\nGET /profile\n...\n\nend sequence\n```');
1686
- seqItem.sortText = '1_sequence';
1687
- items.push(seqItem);
1688
- // test sequence keyword
1689
- const testSeqItem = new vscode.CompletionItem('test sequence', vscode.CompletionItemKind.Keyword);
1690
- testSeqItem.insertText = new vscode.SnippetString('test sequence $0\n\nend sequence');
1691
- testSeqItem.documentation = new vscode.MarkdownString('Define a test sequence (discoverable in Test Explorer and runnable via CLI).\n\n' +
1692
- '```\n' +
1693
- 'test sequence MyTest\n' +
1694
- '\n' +
1695
- 'GET /health\n' +
1696
- 'assert $1.status == 200\n' +
1697
- '\n' +
1698
- 'end sequence\n' +
1699
- '```');
1700
- testSeqItem.sortText = '1_test_sequence';
1701
- items.push(testSeqItem);
1702
- // end sequence keyword
1703
- const endSeqItem = new vscode.CompletionItem('end sequence', vscode.CompletionItemKind.Keyword);
1704
- endSeqItem.insertText = 'end sequence';
1705
- endSeqItem.documentation = 'End a sequence block';
1706
- endSeqItem.sortText = '1_end_sequence';
1707
- items.push(endSeqItem);
1708
- // run bash
1709
- const runBashItem = new vscode.CompletionItem('run bash', vscode.CompletionItemKind.Keyword);
1710
- runBashItem.insertText = 'run bash ';
1711
- runBashItem.documentation = new vscode.MarkdownString('Execute a bash script.\n\n```\nrun bash ./scripts/seed-db.sh\n\n# Or capture output:\nvar result = run bash ./scripts/generate.sh arg1\n```\n\nVariables are passed as `NORN_VARNAME` environment variables.');
1712
- runBashItem.sortText = '1_run_bash';
1713
- items.push(runBashItem);
1714
- // run powershell
1715
- const runPsItem = new vscode.CompletionItem('run powershell', vscode.CompletionItemKind.Keyword);
1716
- runPsItem.insertText = 'run powershell ';
1717
- runPsItem.documentation = new vscode.MarkdownString('Execute a PowerShell script.\n\n```\nrun powershell ./scripts/setup.ps1\n\n# Or capture output:\nvar token = run powershell ./scripts/get-token.ps1\n```\n\nVariables are passed as `NORN_VARNAME` environment variables.');
1718
- runPsItem.sortText = '1_run_powershell';
1719
- items.push(runPsItem);
1720
- // run js
1721
- const runJsItem = new vscode.CompletionItem('run js', vscode.CompletionItemKind.Keyword);
1722
- runJsItem.insertText = 'run js ';
1723
- runJsItem.documentation = new vscode.MarkdownString('Execute a Node.js script.\n\n```\nrun js ./scripts/transform.js\n\n# Or capture output:\nvar signature = run js ./scripts/sign.js {{payload}}\n```\n\nVariables are passed as `NORN_VARNAME` environment variables and also as `NORN_VARIABLES` JSON.');
1724
- runJsItem.sortText = '1_run_js';
1725
- items.push(runJsItem);
1726
- // run (plain - for running named requests)
1727
- const runItem = new vscode.CompletionItem('run', vscode.CompletionItemKind.Keyword);
1728
- runItem.insertText = 'run ';
1729
- runItem.documentation = new vscode.MarkdownString('Run a named request or script.\n\n**Run a named request:**\n```\nrun MyRequest\n```\n\n**Run a script:**\n```\nrun bash ./script.sh\nrun powershell ./script.ps1\nrun js ./script.js\n```');
1730
- runItem.sortText = '1_run';
1731
- items.push(runItem);
1732
- // print
1733
- const printItem = new vscode.CompletionItem('print', vscode.CompletionItemKind.Keyword);
1734
- printItem.insertText = 'print ';
1735
- printItem.documentation = new vscode.MarkdownString('Print a message to the result view.\n\n**Simple message (title only):**\n```\nprint Starting authentication flow...\nprint User ID: {{userId}}\n```\n\n**With expandable body:**\n```\nprint Request Details | Method: POST, URL: {{url}}\nprint Debug Info | Token: {{token}}, Expires: {{expiry}}\n```\n\nVariables are substituted in both title and body. Use `|` to separate title from body content.');
1736
- printItem.sortText = '1_print';
1737
- items.push(printItem);
1738
- // assert
1739
- const assertItem = new vscode.CompletionItem('assert', vscode.CompletionItemKind.Keyword);
1740
- assertItem.insertText = 'assert ';
1741
- assertItem.documentation = new vscode.MarkdownString('Assert a condition on the response. Fails the sequence if the assertion is false.\n\n' +
1742
- '**Check status code:**\n```norn\nassert $1.status == 200\nassert $1.status >= 200\nassert $1.status < 400\n```\n\n' +
1743
- '**Check response body:**\n```norn\nassert $1.body.success == true\nassert $1.body.name == "John"\nassert $1.body.email contains "@"\n```\n\n' +
1744
- '**Check type:**\n```norn\nassert $1.body.id isType number\nassert $1.body.items isType array\nassert $1.body.user isType object\n```\n\n' +
1745
- '**Validate schema:**\n```norn\nassert $1.body matchesSchema "./schemas/user.schema.json"\nassert user.body matchesSchema "./schemas/response.schema.json"\n```\n\n' +
1746
- '**Check headers:**\n```norn\nassert $1.headers.Content-Type contains "json"\n```\n\n' +
1747
- '**Check existence:**\n```norn\nassert $1.body.token exists\nassert $1.body.error !exists\n```\n\n' +
1748
- '**With custom message:**\n```norn\nassert $1.status == 200 | "Login should succeed"\n```\n\n' +
1749
- '**Operators:** `==`, `!=`, `>`, `>=`, `<`, `<=`, `contains`, `startsWith`, `endsWith`, `matches`, `matchesSchema`, `exists`, `!exists`, `isType`\n\n' +
1750
- '**Types for isType:** `number`, `string`, `boolean`, `object`, `array`, `null`');
1751
- assertItem.sortText = '1_assert';
1752
- items.push(assertItem);
1753
- // if statement
1754
- const ifItem = new vscode.CompletionItem('if', vscode.CompletionItemKind.Keyword);
1755
- ifItem.documentation = 'Conditional execution based on response values';
1756
- ifItem.sortText = '1_if';
1757
- items.push(ifItem);
1758
- // end if
1759
- const endIfItem = new vscode.CompletionItem('end if', vscode.CompletionItemKind.Keyword);
1760
- endIfItem.documentation = 'End an if conditional block';
1761
- endIfItem.sortText = '1_end_if';
1762
- items.push(endIfItem);
1763
- // wait command
1764
- const waitItem = new vscode.CompletionItem('wait', vscode.CompletionItemKind.Keyword);
1765
- waitItem.documentation = 'Pause execution for a specified duration (e.g., wait 1s, wait 500ms)';
1766
- waitItem.sortText = '1_wait';
1767
- items.push(waitItem);
1768
- // run readJson - load JSON file into variable
1769
- const jsonItem = new vscode.CompletionItem('run readJson', vscode.CompletionItemKind.Keyword);
1770
- jsonItem.insertText = 'run readJson';
1771
- jsonItem.documentation = new vscode.MarkdownString('Load a JSON file into a variable for use in your requests.\n\n' +
1772
- '**Load a JSON file:**\n```norn\nvar config = run readJson ./config.json\nvar testData = run readJson "./data/users.json"\n```\n\n' +
1773
- '**Access properties:**\n```norn\n# After loading: var data = run readJson ./test-data.json\n\n' +
1774
- '# Use top-level properties\nGET {{data.baseUrl}}/users\n\n' +
1775
- '# Access nested properties\nContent-Type: {{data.headers.contentType}}\n\n' +
1776
- '# Access array elements\nprint User: {{data.users[0].name}}\n```\n\n' +
1777
- 'Supports deep nesting with dot notation and array indexing `[n]`.');
1778
- jsonItem.sortText = '1_run_readJson';
1779
- items.push(jsonItem);
1780
- // import - import requests and sequences from another file
1781
- const importItem = new vscode.CompletionItem('import', vscode.CompletionItemKind.Keyword);
1782
- importItem.insertText = new vscode.SnippetString('import "$0"');
1783
- importItem.documentation = new vscode.MarkdownString('Import named requests and sequences from another .norn file.\n\n' +
1784
- '**Import a file:**\n```norn\nimport "./common/auth.norn"\nimport "./shared/utils.norn"\n```\n\n' +
1785
- '**Use imported requests and sequences:**\n```norn\nimport "./common.norn"\n\nsequence MyFlow\n run SharedRequest\n run SharedSequence\nend sequence\n```\n\n' +
1786
- 'Imported definitions are available throughout the file.');
1787
- importItem.sortText = '1_import';
1788
- items.push(importItem);
1789
- return items;
1790
- }
1791
- getHeaderCompletions() {
1792
- return this.commonHeaders.map(header => {
1793
- const item = new vscode.CompletionItem(header, vscode.CompletionItemKind.Field);
1794
- item.insertText = `${header}: `;
1795
- item.documentation = `HTTP header: ${header}`;
1796
- // Trigger IntelliSense again after inserting the header (for Content-Type values, etc.)
1797
- item.command = {
1798
- command: 'editor.action.triggerSuggest',
1799
- title: 'Trigger Suggest'
1800
- };
1801
- return item;
1802
- });
1803
- }
1804
- getContentTypeCompletions() {
1805
- return this.contentTypes.map(type => {
1806
- const item = new vscode.CompletionItem(type, vscode.CompletionItemKind.Value);
1807
- item.documentation = `Content type: ${type}`;
1808
- return item;
1809
- });
1810
- }
1811
- startsWithMethod(text) {
1812
- const trimmed = text.trim().toUpperCase();
1813
- return this.httpMethods.some(method => trimmed.startsWith(method));
1814
- }
1815
- /**
1816
- * Check if user is typing after "var x = " (for run command or variable completion)
1817
- * Does NOT match if already past an endpoint (header group context)
1818
- */
1819
- isTypingVarAssignment(linePrefix) {
1820
- const trimmed = linePrefix.trim();
1821
- // Match "var name = " or "var name = r" or "var name = run" etc.
1822
- const match = trimmed.match(/^var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*(.*)$/i);
1823
- if (!match) {
1824
- return false;
1825
- }
1826
- const afterEquals = match[1];
1827
- // Don't trigger if inside a quoted string
1828
- if (afterEquals.startsWith('"') || afterEquals.startsWith("'")) {
1829
- return false;
1830
- }
1831
- // Don't trigger if we're past an HTTP method + endpoint (that's header group context)
1832
- // Pattern: GET EndpointName(params) followed by space
1833
- const httpEndpointPattern = /^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+[a-zA-Z_][a-zA-Z0-9_]*(?:\([^)]*\))?\s+/i;
1834
- if (httpEndpointPattern.test(afterEquals)) {
1835
- return false;
1836
- }
1837
- // Trigger for any text after equals (including empty to show completions)
1838
- return true;
1839
- }
1840
- /**
1841
- * Get completions when inside "var x = " assignment
1842
- * Shows: run commands, HTTP methods (for var x = GET endpoint), defined variables, and keywords
1843
- */
1844
- getRunCompletionsForVarAssignment(document, linePrefix) {
1845
- const trimmed = linePrefix.trim();
1846
- const match = trimmed.match(/^var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*(.*)$/i);
1847
- const afterEquals = match ? match[1] : '';
1848
- const items = [];
1849
- // Add HTTP methods for "var x = GET endpoint" pattern
1850
- for (const method of this.httpMethods) {
1851
- if (afterEquals === '' || method.toLowerCase().startsWith(afterEquals.toLowerCase())) {
1852
- const item = new vscode.CompletionItem(method, vscode.CompletionItemKind.Method);
1853
- item.insertText = method;
1854
- item.detail = `Capture ${method} response into variable`;
1855
- item.documentation = new vscode.MarkdownString(`Capture an HTTP response into a variable for assertions.\n\n` +
1856
- `**Examples:**\n` +
1857
- `- \`var user = ${method} {{baseUrl}}/users/1\`\n` +
1858
- `- \`var user = ${method} GetUser(1) Json\`\n\n` +
1859
- `Then use: \`assert user.body.id == 1\``);
1860
- item.sortText = `0_${method}`;
1861
- items.push(item);
1862
- }
1863
- }
1864
- // Handle 'run' keyword - only show script types (bash, powershell, etc.) after user types "run "
1865
- const lowerAfter = afterEquals.toLowerCase();
1866
- // Check if line ends with space after 'run' - the original linePrefix preserves trailing space
1867
- const hasTrailingSpace = linePrefix.endsWith(' ');
1868
- const typedRunWithSpace = (lowerAfter === 'run' && hasTrailingSpace) || lowerAfter.startsWith('run ');
1869
- if (typedRunWithSpace) {
1870
- // User typed "var x = run " - show script types and named requests
1871
- items.push(...this.getRunCompletions(document, afterEquals, linePrefix));
1872
- }
1873
- else if (afterEquals === '' || 'run'.startsWith(lowerAfter)) {
1874
- // User typed "var x = " or "var x = r" or "var x = ru" or "var x = run" (no space) - just show 'run' keyword
1875
- const runItem = new vscode.CompletionItem('run', vscode.CompletionItemKind.Keyword);
1876
- runItem.insertText = 'run ';
1877
- runItem.detail = 'Run a script or named request';
1878
- runItem.documentation = new vscode.MarkdownString('Run a script or named request to capture its result.\n\n' +
1879
- '**Scripts:**\n```norn\nvar result = run bash ./script.sh\nvar data = run readJson ./data.json\n```\n\n' +
1880
- '**Named requests:**\n```norn\nvar user = run GetUser\n```');
1881
- runItem.sortText = '0_run';
1882
- runItem.command = {
1883
- command: 'editor.action.triggerSuggest',
1884
- title: 'Trigger Suggest'
1885
- };
1886
- items.push(runItem);
1887
- }
1888
- // Also show variable completions if not typing 'run' or HTTP method
1889
- const isTypingMethod = this.httpMethods.some(m => afterEquals.toLowerCase().startsWith(m.toLowerCase()));
1890
- if (!afterEquals.toLowerCase().startsWith('run') && !isTypingMethod) {
1891
- items.push(...this.getVariablePathCompletions(document, afterEquals));
1892
- }
1893
- // Add literal keywords
1894
- if (afterEquals === '' || 'true'.startsWith(afterEquals.toLowerCase())) {
1895
- const trueItem = new vscode.CompletionItem('true', vscode.CompletionItemKind.Keyword);
1896
- trueItem.detail = 'Boolean true';
1897
- items.push(trueItem);
1898
- }
1899
- if (afterEquals === '' || 'false'.startsWith(afterEquals.toLowerCase())) {
1900
- const falseItem = new vscode.CompletionItem('false', vscode.CompletionItemKind.Keyword);
1901
- falseItem.detail = 'Boolean false';
1902
- items.push(falseItem);
1903
- }
1904
- if (afterEquals === '' || 'null'.startsWith(afterEquals.toLowerCase())) {
1905
- const nullItem = new vscode.CompletionItem('null', vscode.CompletionItemKind.Keyword);
1906
- nullItem.detail = 'Null value';
1907
- items.push(nullItem);
1908
- }
1909
- return items;
1910
- }
1911
- /**
1912
- * Get variable path completions for var assignment (e.g., data, data.users, etc.)
1913
- */
1914
- getVariablePathCompletions(document, prefix) {
1915
- const fullText = document.getText();
1916
- const fileVariables = (0, parser_1.extractVariables)(fullText);
1917
- const envVariables = (0, environmentProvider_1.getEnvironmentVariables)(document.uri.fsPath);
1918
- const allVariables = { ...envVariables, ...fileVariables };
1919
- const items = [];
1920
- const lowerPrefix = prefix.toLowerCase();
1921
- for (const [name, value] of Object.entries(allVariables)) {
1922
- // Filter by prefix if user has typed something
1923
- if (lowerPrefix && !name.toLowerCase().startsWith(lowerPrefix)) {
1924
- continue;
1925
- }
1926
- const item = new vscode.CompletionItem(name, vscode.CompletionItemKind.Variable);
1927
- item.detail = value.length > 50 ? value.substring(0, 47) + '...' : value;
1928
- item.documentation = `Variable: ${name}`;
1929
- // If the value is JSON, indicate it can be accessed with paths
1930
- try {
1931
- const parsed = JSON.parse(value);
1932
- if (typeof parsed === 'object' && parsed !== null) {
1933
- item.documentation = `JSON variable - access properties with ${name}.property`;
1934
- item.detail = 'JSON object';
1935
- }
1936
- }
1937
- catch {
1938
- // Not JSON, use value as detail
1939
- }
1940
- items.push(item);
1941
- }
1942
- return items;
1943
- }
1944
- /**
1945
- * Check if user is typing "run" or after "run " (for named request completion)
1946
- */
1947
- isTypingRunCommand(linePrefix) {
1948
- const lowerPrefix = linePrefix.toLowerCase();
1949
- const trimmed = lowerPrefix.trim();
1950
- // Check if user is typing 'r', 'ru', or 'run' at the start of a line
1951
- if (trimmed && 'run'.startsWith(trimmed)) {
1952
- return true;
1953
- }
1954
- // Check if line is exactly "run" followed by space (user just typed "run ")
1955
- // Use the original linePrefix to detect trailing space
1956
- if (trimmed === 'run' && lowerPrefix.endsWith(' ')) {
1957
- return true;
1958
- }
1959
- // Check if line starts with "run " and has more content
1960
- if (!trimmed.startsWith('run ')) {
1961
- return false;
1962
- }
1963
- const afterRun = trimmed.substring(4).trim();
1964
- // Don't trigger for script commands that already have a path
1965
- if (/^(bash|js|powershell|readjson)\s+\S/i.test(afterRun)) {
1966
- return false;
1967
- }
1968
- return true;
1969
- }
1970
- /**
1971
- * Get completions for named requests and script types after "run "
1972
- */
1973
- getNamedRequestCompletions(document, linePrefix) {
1974
- const trimmed = linePrefix.trim();
1975
- // Use shared logic for run completions
1976
- return this.getRunCompletions(document, trimmed, linePrefix);
1977
- }
1978
- /**
1979
- * Shared logic for run command completions - used by both standalone "run" and "var x = run"
1980
- */
1981
- getRunCompletions(document, textAfterContext, originalLinePrefix) {
1982
- const fullText = document.getText();
1983
- const localNamedRequests = (0, parser_1.extractNamedRequests)(fullText);
1984
- const localSequences = (0, sequenceRunner_1.extractSequences)(fullText);
1985
- const importedDefinitions = this.getImportedRunDefinitions(document);
1986
- const items = [];
1987
- const lowerText = textAfterContext.toLowerCase().trim();
1988
- const namedRequests = [];
1989
- const seenNamedRequests = new Set();
1990
- for (const request of localNamedRequests) {
1991
- const lowerName = request.name.toLowerCase();
1992
- if (seenNamedRequests.has(lowerName)) {
1993
- continue;
1994
- }
1995
- seenNamedRequests.add(lowerName);
1996
- namedRequests.push({ request, source: 'local' });
1997
- }
1998
- for (const imported of importedDefinitions.namedRequests) {
1999
- const lowerName = imported.request.name.toLowerCase();
2000
- if (seenNamedRequests.has(lowerName)) {
2001
- continue;
2002
- }
2003
- seenNamedRequests.add(lowerName);
2004
- namedRequests.push({ request: imported.request, source: 'imported', sourcePath: imported.sourcePath });
2005
- }
2006
- const sequences = [];
2007
- const seenSequences = new Set();
2008
- for (const sequence of localSequences) {
2009
- const lowerName = sequence.name.toLowerCase();
2010
- if (seenSequences.has(lowerName)) {
2011
- continue;
2012
- }
2013
- seenSequences.add(lowerName);
2014
- sequences.push({ sequence, source: 'local' });
2015
- }
2016
- for (const imported of importedDefinitions.sequences) {
2017
- const lowerName = imported.sequence.name.toLowerCase();
2018
- if (seenSequences.has(lowerName)) {
2019
- continue;
2020
- }
2021
- seenSequences.add(lowerName);
2022
- sequences.push({ sequence: imported.sequence, source: 'imported', sourcePath: imported.sourcePath });
2023
- }
2024
- // Check if user typed 'run' exactly without trailing space
2025
- // VS Code filters completions by the word being typed, so 'bash' won't show when user typed 'run'
2026
- // We need to show 'run' as a completion that adds a space and retriggers
2027
- const typedRunExactly = (lowerText === 'run' || 'run'.startsWith(lowerText)) && lowerText.length > 0 && !originalLinePrefix.endsWith(' ');
2028
- if (typedRunExactly) {
2029
- const runItem = new vscode.CompletionItem('run', vscode.CompletionItemKind.Method);
2030
- runItem.insertText = 'run ';
2031
- runItem.documentation = new vscode.MarkdownString('Execute a script or named request\n\n**Options:** `bash`, `powershell`, `js`, `readJson`, or a sequence name');
2032
- runItem.sortText = '0_run';
2033
- // Trigger IntelliSense again after inserting 'run '
2034
- runItem.command = {
2035
- command: 'editor.action.triggerSuggest',
2036
- title: 'Trigger Suggest'
2037
- };
2038
- items.push(runItem);
2039
- return items;
2040
- }
2041
- // Determine what's after "run " (if anything)
2042
- let afterRun = '';
2043
- if (lowerText === 'run' || lowerText === '') {
2044
- afterRun = '';
2045
- }
2046
- else if (lowerText.startsWith('run ')) {
2047
- afterRun = lowerText.substring(4).trim();
2048
- }
2049
- else {
2050
- // Might be starting fresh (empty context)
2051
- afterRun = '';
2052
- }
2053
- // Add script type completions (bash, powershell, js, readJson)
2054
- const scriptTypes = [
2055
- { name: 'bash', doc: 'Execute a bash script' },
2056
- { name: 'powershell', doc: 'Execute a PowerShell script' },
2057
- { name: 'js', doc: 'Execute a Node.js script' },
2058
- { name: 'readJson', doc: 'Load a JSON file. Access properties with {{var.property}}' },
2059
- ];
2060
- for (const st of scriptTypes) {
2061
- if (!afterRun || st.name.toLowerCase().startsWith(afterRun.toLowerCase())) {
2062
- const item = new vscode.CompletionItem(st.name, vscode.CompletionItemKind.Method);
2063
- item.insertText = st.name + ' ';
2064
- item.documentation = new vscode.MarkdownString(st.doc);
2065
- item.sortText = `0_${st.name}`;
2066
- items.push(item);
2067
- }
2068
- }
2069
- // Add named request completions
2070
- for (const entry of namedRequests) {
2071
- const req = entry.request;
2072
- if (afterRun && !req.name.toLowerCase().startsWith(afterRun)) {
2073
- continue;
2074
- }
2075
- const item = new vscode.CompletionItem(req.name, vscode.CompletionItemKind.Function);
2076
- item.insertText = req.name;
2077
- // Show the request method and URL in detail
2078
- const methodMatch = req.content.match(/^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(.+)/im);
2079
- const sourceSuffix = entry.source === 'imported' && entry.sourcePath
2080
- ? ` (imported: ${path.basename(entry.sourcePath)})`
2081
- : '';
2082
- if (methodMatch) {
2083
- item.detail = `${methodMatch[1]} ${methodMatch[2].split('\n')[0]}${sourceSuffix}`;
2084
- }
2085
- else if (sourceSuffix) {
2086
- item.detail = `Named request${sourceSuffix}`;
2087
- }
2088
- item.documentation = new vscode.MarkdownString(`**Named Request:** \`${req.name}\`\n\n` +
2089
- '```http\n' + req.content.substring(0, 200) + (req.content.length > 200 ? '...' : '') + '\n```' +
2090
- (entry.source === 'imported' && entry.sourcePath
2091
- ? `\n\n**Source:** \`${entry.sourcePath}\``
2092
- : ''));
2093
- item.sortText = entry.source === 'local'
2094
- ? `1_${req.name}`
2095
- : `1z_${req.name}`;
2096
- items.push(item);
2097
- }
2098
- // Add sequence completions
2099
- for (const entry of sequences) {
2100
- const seq = entry.sequence;
2101
- if (afterRun && !seq.name.toLowerCase().startsWith(afterRun)) {
2102
- continue;
2103
- }
2104
- const item = new vscode.CompletionItem(seq.name, vscode.CompletionItemKind.Module);
2105
- item.insertText = seq.name;
2106
- const sequenceType = seq.isTest ? 'Test Sequence' : 'Sequence';
2107
- const sourceSuffix = entry.source === 'imported' && entry.sourcePath
2108
- ? ` (imported: ${path.basename(entry.sourcePath)})`
2109
- : '';
2110
- item.detail = `${sequenceType}${sourceSuffix}`;
2111
- // Count steps in the sequence for info
2112
- const stepCount = seq.content.split('\n').filter(l => l.trim() !== '').length;
2113
- item.documentation = new vscode.MarkdownString(`**Sequence:** \`${seq.name}\`\n\n` +
2114
- `~${stepCount} steps\n\n` +
2115
- 'Run this sequence. Variables set in the sequence will be available after it completes.' +
2116
- (entry.source === 'imported' && entry.sourcePath
2117
- ? `\n\n**Source:** \`${entry.sourcePath}\``
2118
- : ''));
2119
- item.sortText = entry.source === 'local'
2120
- ? `2_${seq.name}`
2121
- : `2z_${seq.name}`;
2122
- items.push(item);
2123
- }
2124
- return items;
2125
- }
2126
- /**
2127
- * Get JSON variables that can be used for property assignment.
2128
- * These are variables declared with "var x = run readJson ..."
2129
- */
2130
- getJsonVariableCompletions(document, linePrefix) {
2131
- const items = [];
2132
- const text = document.getText();
2133
- const trimmed = linePrefix.trim().toLowerCase();
2134
- // Only trigger if line starts with a letter (potential variable name) or is empty/whitespace
2135
- // Don't trigger if it looks like a keyword, method, or other construct
2136
- if (trimmed && !/^[a-zA-Z_]/.test(trimmed)) {
2137
- return items;
2138
- }
2139
- // Find all JSON variable declarations
2140
- const jsonVarRegex = /^\s*var\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*run\s+readJson\s+(.+)$/gm;
2141
- let match;
2142
- while ((match = jsonVarRegex.exec(text)) !== null) {
2143
- const varName = match[1];
2144
- const filePath = match[2].trim();
2145
- // Filter by what user has typed (if anything)
2146
- if (trimmed && !varName.toLowerCase().startsWith(trimmed)) {
2147
- continue;
2148
- }
2149
- const item = new vscode.CompletionItem(varName, vscode.CompletionItemKind.Variable);
2150
- item.insertText = varName;
2151
- item.detail = 'JSON object';
2152
- item.documentation = new vscode.MarkdownString(`**JSON Variable:** \`${varName}\`\n\n` +
2153
- `**Source:** \`${filePath}\`\n\n` +
2154
- 'Update a property:\n```norn\n' +
2155
- `${varName}.propertyName = newValue\n` +
2156
- `${varName}.nested.path = value\n` +
2157
- `${varName}[0].name = value\n` +
2158
- '```');
2159
- item.sortText = `0_json_${varName}`;
2160
- items.push(item);
2161
- }
2162
- return items;
2163
- }
2164
- /**
2165
- * Check if user is typing a variable name for property assignment.
2166
- * e.g., "config." or "data[0]."
2167
- */
2168
- isTypingPropertyAssignment(linePrefix) {
2169
- // Disabled - user prefers to type properties manually
2170
- return false;
2171
- }
2172
- /**
2173
- * Get property completions for a JSON variable after typing the dot.
2174
- */
2175
- getPropertyAssignmentCompletions(document, linePrefix) {
2176
- // Disabled - user prefers to type properties manually
2177
- return [];
2178
- }
2179
- /**
2180
- * Check if user is typing a response capture reference ($N. or $N.property.)
2181
- * This is used inside sequences to capture response data.
2182
- */
2183
- isTypingResponseCapture(linePrefix) {
2184
- // Match patterns like:
2185
- // $1. (just typed the dot)
2186
- // $1.st (typing a property)
2187
- // $1.body. (typed body., waiting for body path)
2188
- // $1.headers. (typed headers., waiting for header name)
2189
- return /\$\d+\.$/.test(linePrefix) || /\$\d+\.[a-zA-Z_][a-zA-Z0-9_]*$/.test(linePrefix);
2190
- }
2191
- /**
2192
- * Get completions for response capture ($N.property)
2193
- */
2194
- getResponseCaptureCompletions(document, position, linePrefix) {
2195
- const items = [];
2196
- // Extract what's been typed after $N.
2197
- // Match $N. or $N.partial
2198
- const captureMatch = linePrefix.match(/\$(\d+)\.([a-zA-Z_][a-zA-Z0-9_]*)?$/);
2199
- if (!captureMatch) {
2200
- return items;
2201
- }
2202
- const requestNum = captureMatch[1];
2203
- const partial = captureMatch[2] || '';
2204
- // Check if we're in a sub-property context like $1.headers. or $1.body.
2205
- const subPropertyMatch = linePrefix.match(/\$\d+\.(headers|body)\.([a-zA-Z_][a-zA-Z0-9_-]*)?$/i);
2206
- if (subPropertyMatch) {
2207
- const parentProp = subPropertyMatch[1].toLowerCase();
2208
- const subPartial = subPropertyMatch[2] || '';
2209
- if (parentProp === 'headers') {
2210
- // Suggest common header names
2211
- const commonHeaders = [
2212
- 'Content-Type',
2213
- 'Content-Length',
2214
- 'Cache-Control',
2215
- 'Set-Cookie',
2216
- 'Authorization',
2217
- 'X-Request-Id',
2218
- 'X-RateLimit-Remaining',
2219
- 'Location',
2220
- 'ETag',
2221
- ];
2222
- for (const header of commonHeaders) {
2223
- if (!subPartial || header.toLowerCase().startsWith(subPartial.toLowerCase())) {
2224
- const item = new vscode.CompletionItem(header, vscode.CompletionItemKind.Field);
2225
- item.insertText = header;
2226
- item.detail = 'Response header';
2227
- item.documentation = new vscode.MarkdownString(`Access the \`${header}\` response header from request $${requestNum}.`);
2228
- items.push(item);
2229
- }
2230
- }
2231
- return items;
2232
- }
2233
- // For body., we can't know the structure without executing, but we can hint
2234
- if (parentProp === 'body') {
2235
- const item = new vscode.CompletionItem('property', vscode.CompletionItemKind.Field);
2236
- item.insertText = '';
2237
- item.detail = 'Body property path';
2238
- item.documentation = new vscode.MarkdownString('Navigate into the response body.\n\n' +
2239
- 'Examples:\n' +
2240
- '- `$1.body.id` - get the id field\n' +
2241
- '- `$1.body.user.name` - nested property\n' +
2242
- '- `$1.body[0].id` - array access');
2243
- items.push(item);
2244
- return items;
2245
- }
2246
- }
2247
- // Top-level response properties
2248
- const responseProperties = [
2249
- {
2250
- name: 'status',
2251
- detail: 'number',
2252
- doc: 'HTTP status code (e.g., 200, 404, 500)',
2253
- example: 'assert $1.status == 200'
2254
- },
2255
- {
2256
- name: 'statusText',
2257
- detail: 'string',
2258
- doc: 'HTTP status message (e.g., "OK", "Not Found")',
2259
- example: 'var message = $1.statusText'
2260
- },
2261
- {
2262
- name: 'headers',
2263
- detail: 'object',
2264
- doc: 'Response headers. Use `headers.Name` to access specific header.',
2265
- example: '$1.headers.Content-Type'
2266
- },
2267
- {
2268
- name: 'duration',
2269
- detail: 'number',
2270
- doc: 'Request duration in milliseconds',
2271
- example: 'assert $1.duration < 1000'
2272
- },
2273
- {
2274
- name: 'body',
2275
- detail: 'any',
2276
- doc: 'Response body. Use `body.path` to access nested properties.',
2277
- example: 'var userId = $1.body.user.id'
2278
- },
2279
- ];
2280
- for (const prop of responseProperties) {
2281
- if (!partial || prop.name.toLowerCase().startsWith(partial.toLowerCase())) {
2282
- const item = new vscode.CompletionItem(prop.name, vscode.CompletionItemKind.Property);
2283
- item.insertText = prop.name;
2284
- item.detail = prop.detail;
2285
- item.documentation = new vscode.MarkdownString(`**${prop.name}** (\`${prop.detail}\`)\n\n${prop.doc}\n\n**Example:**\n\`\`\`norn\n${prop.example}\n\`\`\``);
2286
- // Sort: commonly used first
2287
- const sortOrder = ['status', 'body', 'headers', 'statusText', 'duration'];
2288
- item.sortText = `${sortOrder.indexOf(prop.name)}_${prop.name}`;
2289
- items.push(item);
2290
- }
2291
- }
2292
- return items;
2293
- }
2294
- /**
2295
- * Check if user is typing a sequence tag (starts with @ at the beginning of a line)
2296
- */
2297
- isTypingSequenceTag(linePrefix) {
2298
- const trimmed = linePrefix.trim();
2299
- // Check if the line starts with @ or has @ after other tags
2300
- // e.g., "@" or "@smo" or "@smoke @"
2301
- return /^\s*@[a-zA-Z0-9_-]*$/.test(linePrefix) ||
2302
- /^\s*(?:@[a-zA-Z_][a-zA-Z0-9_-]*(?:\([^)]+\))?\s+)+@[a-zA-Z0-9_-]*$/.test(linePrefix);
2303
- }
2304
- /**
2305
- * Get completion items for sequence tags.
2306
- * Scans the workspace for existing tags and suggests them.
2307
- */
2308
- getSequenceTagCompletions(document, linePrefix) {
2309
- const items = [];
2310
- const text = document.getText();
2311
- // Extract the partial tag being typed (after the last @)
2312
- const lastAtPos = linePrefix.lastIndexOf('@');
2313
- const partial = linePrefix.substring(lastAtPos + 1).toLowerCase();
2314
- // Collect all existing tags from the document
2315
- const existingTags = new Map(); // name -> values (empty set for simple tags)
2316
- const tagPattern = /@([a-zA-Z_][a-zA-Z0-9_-]*)(?:\(([^)]+)\))?/g;
2317
- let match;
2318
- while ((match = tagPattern.exec(text)) !== null) {
2319
- const tagName = match[1];
2320
- const tagValue = match[2];
2321
- if (!existingTags.has(tagName)) {
2322
- existingTags.set(tagName, new Set());
2323
- }
2324
- if (tagValue) {
2325
- existingTags.get(tagName).add(tagValue);
2326
- }
2327
- }
2328
- // Also check other .norn files in the workspace
2329
- for (const otherDoc of vscode.workspace.textDocuments) {
2330
- if (otherDoc.languageId === 'norn' && otherDoc.uri.toString() !== document.uri.toString()) {
2331
- const otherText = otherDoc.getText();
2332
- let otherMatch;
2333
- const otherPattern = /@([a-zA-Z_][a-zA-Z0-9_-]*)(?:\(([^)]+)\))?/g;
2334
- while ((otherMatch = otherPattern.exec(otherText)) !== null) {
2335
- const tagName = otherMatch[1];
2336
- const tagValue = otherMatch[2];
2337
- if (!existingTags.has(tagName)) {
2338
- existingTags.set(tagName, new Set());
2339
- }
2340
- if (tagValue) {
2341
- existingTags.get(tagName).add(tagValue);
2342
- }
2343
- }
2344
- }
2345
- }
2346
- // Add common/suggested tag names if no tags exist yet
2347
- const suggestedTags = ['smoke', 'regression', 'integration', 'unit', 'slow', 'fast', 'priority', 'team', 'feature', 'wip', 'skip'];
2348
- for (const tag of suggestedTags) {
2349
- if (!existingTags.has(tag)) {
2350
- existingTags.set(tag, new Set());
2351
- }
2352
- }
2353
- // Generate completion items
2354
- for (const [tagName, values] of existingTags) {
2355
- if (!partial || tagName.toLowerCase().startsWith(partial)) {
2356
- // Simple tag completion
2357
- const item = new vscode.CompletionItem(tagName, vscode.CompletionItemKind.Constant);
2358
- item.insertText = tagName;
2359
- item.detail = 'Sequence tag';
2360
- if (values.size > 0) {
2361
- item.documentation = new vscode.MarkdownString(`**@${tagName}**\n\nExisting values: ${Array.from(values).map(v => `\`${v}\``).join(', ')}`);
2362
- }
2363
- else {
2364
- item.documentation = new vscode.MarkdownString(`**@${tagName}**\n\nSimple tag for filtering sequences.`);
2365
- }
2366
- item.sortText = `0_${tagName}`;
2367
- items.push(item);
2368
- // If this tag has values, also suggest the key-value form
2369
- for (const value of values) {
2370
- const kvItem = new vscode.CompletionItem(`${tagName}(${value})`, vscode.CompletionItemKind.Constant);
2371
- kvItem.insertText = `${tagName}(${value})`;
2372
- kvItem.detail = 'Sequence tag with value';
2373
- kvItem.documentation = new vscode.MarkdownString(`**@${tagName}(${value})**\n\nKey-value tag for filtering sequences.`);
2374
- kvItem.sortText = `1_${tagName}_${value}`;
2375
- items.push(kvItem);
2376
- }
2377
- }
2378
- }
2379
- // Add @data completion for parameterized tests
2380
- if (!partial || 'data'.startsWith(partial)) {
2381
- const dataItem = new vscode.CompletionItem('data', vscode.CompletionItemKind.Keyword);
2382
- dataItem.insertText = new vscode.SnippetString('data(${1:value1}, ${2:value2})');
2383
- dataItem.detail = 'Parameterized test data';
2384
- dataItem.documentation = new vscode.MarkdownString(`**@data(...)**\n\nProvides inline test data for parameterized test sequences.\n\n` +
2385
- `Example:\n\`\`\`norn\n@data(1, "Widget")\n@data(2, "Gadget")\ntest sequence ItemTest(id, expectedName)\n ...\nend sequence\n\`\`\``);
2386
- dataItem.sortText = '00_data'; // Sort before regular tags
2387
- items.push(dataItem);
2388
- }
2389
- // Add @theory completion for external data files
2390
- if (!partial || 'theory'.startsWith(partial)) {
2391
- const theoryItem = new vscode.CompletionItem('theory', vscode.CompletionItemKind.Keyword);
2392
- theoryItem.insertText = new vscode.SnippetString('theory("${1:./testdata.json}")');
2393
- theoryItem.detail = 'External test data file';
2394
- theoryItem.documentation = new vscode.MarkdownString(`**@theory("file.json")**\n\nLoads test data from an external JSON file for parameterized test sequences.\n\n` +
2395
- `Example:\n\`\`\`norn\n@theory("./items.json")\ntest sequence BulkTest(id, name, price)\n ...\nend sequence\n\`\`\`\n\n` +
2396
- `JSON file format:\n\`\`\`json\n[\n { "id": 1, "name": "Widget", "price": 9.99 },\n { "id": 2, "name": "Gadget", "price": 19.99 }\n]\n\`\`\``);
2397
- theoryItem.sortText = '00_theory'; // Sort before regular tags
2398
- items.push(theoryItem);
2399
- }
2400
- return items;
2401
- }
2402
- }
2403
- exports.HttpCompletionProvider = HttpCompletionProvider;
2404
- //# sourceMappingURL=completionProvider.js.map