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