norn-cli 2.2.2 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/.claude/settings.local.json +18 -0
  2. package/.claude/skills/norn-social-campaign/SKILL.md +70 -0
  3. package/CHANGELOG.md +22 -1
  4. package/LICENSE +20 -29
  5. package/README.md +32 -1
  6. package/demos/nornenv-region-refactor/README.md +64 -0
  7. package/demos/nornenv-showcase/README.md +62 -0
  8. package/demos/nornenv-showcase/norn.config.json +16 -0
  9. package/demos/nornenv-showcase/showcase.norn +70 -0
  10. package/demos/nornenv-showcase/showcase.nornapi +26 -0
  11. package/demos/nornenv-showcase/showcase.nornsql +20 -0
  12. package/dist/cli.js +564 -54
  13. package/out/apiResponseIntellisenseCache.js +394 -0
  14. package/out/assertionRunner.js +567 -0
  15. package/out/cacheDir.js +136 -0
  16. package/out/chatParticipant.js +763 -0
  17. package/out/cli/colors.js +127 -0
  18. package/out/cli/formatters/assertion.js +102 -0
  19. package/out/cli/formatters/index.js +23 -0
  20. package/out/cli/formatters/response.js +106 -0
  21. package/out/cli/formatters/summary.js +246 -0
  22. package/out/cli/redaction.js +237 -0
  23. package/out/cli/reporters/html.js +689 -0
  24. package/out/cli/reporters/index.js +22 -0
  25. package/out/cli/reporters/junit.js +226 -0
  26. package/out/codeLensProvider.js +351 -0
  27. package/out/compareContentProvider.js +85 -0
  28. package/out/completionProvider.js +3739 -0
  29. package/out/contractAssertionSummary.js +225 -0
  30. package/out/contractDecorationProvider.js +243 -0
  31. package/out/coverageCalculator.js +879 -0
  32. package/out/coveragePanel.js +597 -0
  33. package/out/debug/breakpointResolver.js +84 -0
  34. package/out/debug/breakpoints.js +52 -0
  35. package/out/debug/nornDebugAdapter.js +166 -0
  36. package/out/debug/nornDebugSession.js +613 -0
  37. package/out/debug/sequenceLocationIndex.js +77 -0
  38. package/out/debug/types.js +3 -0
  39. package/out/deepClone.js +21 -0
  40. package/out/diagnosticProvider.js +2554 -0
  41. package/out/environmentParser.js +736 -0
  42. package/out/environmentProvider.js +544 -0
  43. package/out/environmentTemplates.js +146 -0
  44. package/out/errors/formatError.js +113 -0
  45. package/out/errors/nornError.js +29 -0
  46. package/out/formUrlEncoded.js +89 -0
  47. package/out/httpClient.js +348 -0
  48. package/out/httpRuntimeOptions.js +16 -0
  49. package/out/importErrors.js +31 -0
  50. package/out/inlayHintResolver.js +70 -0
  51. package/out/jsonFileReader.js +323 -0
  52. package/out/mcpClient.js +193 -0
  53. package/out/mcpConfig.js +184 -0
  54. package/out/mcpToolIntellisenseCache.js +96 -0
  55. package/out/mcpToolSchema.js +50 -0
  56. package/out/nornConfig.js +132 -0
  57. package/out/nornHoverProvider.js +124 -0
  58. package/out/nornInlayHintsProvider.js +191 -0
  59. package/out/nornPrompt.js +755 -0
  60. package/out/nornSqlParser.js +286 -0
  61. package/out/nornapiHoverProvider.js +135 -0
  62. package/out/nornapiInlayHintsProvider.js +94 -0
  63. package/out/nornapiParser.js +324 -0
  64. package/out/nornenvCodeActionProvider.js +101 -0
  65. package/out/nornenvDecorationProvider.js +239 -0
  66. package/out/nornenvFoldingProvider.js +63 -0
  67. package/out/nornenvHoverProvider.js +114 -0
  68. package/out/nornenvInlayHintsProvider.js +99 -0
  69. package/out/nornenvLanguageModel.js +187 -0
  70. package/out/nornenvRegionRefactor.js +267 -0
  71. package/out/nornsqlHoverProvider.js +95 -0
  72. package/out/nornsqlInlayHintsProvider.js +114 -0
  73. package/out/parser.js +839 -0
  74. package/out/pathAccess.js +28 -0
  75. package/out/postmanImportPanel.js +732 -0
  76. package/out/postmanImportPlanner.js +1155 -0
  77. package/out/postmanImportSidebarView.js +532 -0
  78. package/out/quotedString.js +35 -0
  79. package/out/requestPreparation.js +179 -0
  80. package/out/requestValidation.js +146 -0
  81. package/out/responsePanel.js +7754 -0
  82. package/out/schemaGenerator.js +562 -0
  83. package/out/scriptRunner.js +419 -0
  84. package/out/secrets/cliSecrets.js +415 -0
  85. package/out/secrets/crypto.js +105 -0
  86. package/out/secrets/envFileSecrets.js +177 -0
  87. package/out/secrets/keyStore.js +259 -0
  88. package/out/sequenceDeclaration.js +15 -0
  89. package/out/sequenceRunner.js +3590 -0
  90. package/out/sqlAdapterRunner.js +122 -0
  91. package/out/sqlBuiltInAdapters.js +604 -0
  92. package/out/sqlConfig.js +184 -0
  93. package/out/starterCatalog.js +554 -0
  94. package/out/stringUtils.js +25 -0
  95. package/out/swaggerBodyIntellisenseCache.js +114 -0
  96. package/out/swaggerParser.js +464 -0
  97. package/out/testProvider.js +767 -0
  98. package/out/theoryCaseLoader.js +113 -0
  99. package/out/validationCache.js +211 -0
  100. package/package.json +38 -11
  101. package/.kanbn/index.md +0 -31
  102. package/.kanbn/tasks/book-first-mentor-session.md +0 -13
  103. package/.kanbn/tasks/decide-what-success-in-a-pilot-looks-like.md +0 -9
  104. package/.kanbn/tasks/do-5-customer-conversations.md +0 -9
  105. package/.kanbn/tasks/finalise-the-one-line-pitch.md +0 -11
  106. package/.kanbn/tasks/interview-script.md +0 -49
  107. package/.kanbn/tasks/make-a-list-of-10-people-to-speak-to.md +0 -11
  108. package/.kanbn/tasks/prepare-your-customer-interview-questions.md +0 -11
  109. package/.kanbn/tasks/recruit-2/342/200/2233-pilot-users.md +0 -9
  110. package/.kanbn/tasks/refine-your-pitch.md +0 -9
  111. package/.kanbn/tasks/use-the-shiplight-website-as-a-template-to-improve-norn-website.md +0 -9
  112. package/.kanbn/tasks/write-down-repeated-wording.md +0 -9
  113. package/.kanbn/tasks/write-the-one-pager.md +0 -27
@@ -0,0 +1,3739 @@
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 = exports.McpSignatureHelpProvider = void 0;
37
+ const vscode = __importStar(require("vscode"));
38
+ const fs = __importStar(require("fs"));
39
+ const path = __importStar(require("path"));
40
+ const parser_1 = require("./parser");
41
+ const sequenceRunner_1 = require("./sequenceRunner");
42
+ const environmentProvider_1 = require("./environmentProvider");
43
+ const nornapiParser_1 = require("./nornapiParser");
44
+ const mcpToolIntellisenseCache_1 = require("./mcpToolIntellisenseCache");
45
+ const mcpToolSchema_1 = require("./mcpToolSchema");
46
+ const swaggerBodyIntellisenseCache_1 = require("./swaggerBodyIntellisenseCache");
47
+ const apiResponseIntellisenseCache_1 = require("./apiResponseIntellisenseCache");
48
+ const nornSqlParser_1 = require("./nornSqlParser");
49
+ const sqlConfig_1 = require("./sqlConfig");
50
+ const sqlBuiltInAdapters_1 = require("./sqlBuiltInAdapters");
51
+ const mcpConfig_1 = require("./mcpConfig");
52
+ const nornConfig_1 = require("./nornConfig");
53
+ const sequenceDeclaration_1 = require("./sequenceDeclaration");
54
+ const TRIGGER_SUGGEST_COMMAND = 'editor.action.triggerSuggest';
55
+ const TRIGGER_PARAMETER_HINTS_COMMAND = 'editor.action.triggerParameterHints';
56
+ class McpSignatureHelpProvider {
57
+ provideSignatureHelp(document, position, _token, _context) {
58
+ const linePrefix = document.lineAt(position).text.substring(0, position.character);
59
+ const match = linePrefix.match(/^\s*(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?run\s+mcp\s+call\s+([a-zA-Z_][a-zA-Z0-9_-]*)\s+([a-zA-Z_][a-zA-Z0-9_.:-]*)\((.*)$/i);
60
+ if (!match) {
61
+ return undefined;
62
+ }
63
+ const argsPrefix = match[3];
64
+ if (argsPrefix.includes(')')) {
65
+ return undefined;
66
+ }
67
+ const resolvedAlias = this.resolveKnownMcpAlias(document, match[1]);
68
+ if (!resolvedAlias) {
69
+ return undefined;
70
+ }
71
+ const cachedTool = (0, mcpToolIntellisenseCache_1.getCachedMcpToolForAlias)(document.uri.fsPath, resolvedAlias, match[2]);
72
+ if (!cachedTool) {
73
+ return undefined;
74
+ }
75
+ const parameterNames = (0, mcpToolSchema_1.getMcpToolInputParameterNames)(cachedTool);
76
+ const propertyMap = (0, mcpToolSchema_1.getMcpToolInputPropertyMap)(cachedTool);
77
+ const required = new Set((0, mcpToolSchema_1.getMcpToolRequiredParameterNames)(cachedTool).map(name => name.toLowerCase()));
78
+ const signatureState = this.getSignatureState(argsPrefix, parameterNames);
79
+ const signatureHelp = new vscode.SignatureHelp();
80
+ const parameterLabels = parameterNames.map(name => this.getMcpParameterLabel(name, propertyMap[name], required.has(name.toLowerCase())));
81
+ const signatureLabel = parameterNames.length > 0
82
+ ? `${cachedTool.name}(${parameterLabels.join(', ')})`
83
+ : `${cachedTool.name}()`;
84
+ const signature = new vscode.SignatureInformation(signatureLabel, this.buildSignatureDocumentation(cachedTool, parameterNames, propertyMap, required, signatureState));
85
+ signature.parameters = parameterNames.map((name, index) => {
86
+ const docs = this.buildParameterDocumentation(propertyMap[name]);
87
+ return new vscode.ParameterInformation(parameterLabels[index], docs);
88
+ });
89
+ signatureHelp.signatures = [signature];
90
+ signatureHelp.activeSignature = 0;
91
+ signatureHelp.activeParameter = signatureState.activeParameterIndex;
92
+ return signatureHelp;
93
+ }
94
+ resolveKnownMcpAlias(document, alias) {
95
+ try {
96
+ const config = (0, mcpConfig_1.loadNornMcpProjectConfig)(document.uri.fsPath);
97
+ return Object.keys(config.config.servers).find(candidate => candidate.toLowerCase() === alias.toLowerCase());
98
+ }
99
+ catch {
100
+ return undefined;
101
+ }
102
+ }
103
+ getMcpParameterTypeSummary(schema) {
104
+ if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
105
+ return undefined;
106
+ }
107
+ const candidate = schema;
108
+ if (Array.isArray(candidate.enum) && candidate.enum.length > 0) {
109
+ return 'enum';
110
+ }
111
+ if (typeof candidate.type === 'string') {
112
+ if (candidate.type === 'array') {
113
+ const itemType = this.getMcpParameterTypeSummary(candidate.items);
114
+ return itemType ? `array<${itemType}>` : 'array';
115
+ }
116
+ return candidate.type;
117
+ }
118
+ if (Array.isArray(candidate.type)) {
119
+ const types = candidate.type.filter((entry) => typeof entry === 'string');
120
+ return types.length > 0 ? types.join(' | ') : undefined;
121
+ }
122
+ if (candidate.properties && typeof candidate.properties === 'object' && !Array.isArray(candidate.properties)) {
123
+ return 'object';
124
+ }
125
+ if (candidate.items) {
126
+ return 'array';
127
+ }
128
+ return undefined;
129
+ }
130
+ getMcpParameterDescription(schema) {
131
+ if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
132
+ return undefined;
133
+ }
134
+ const candidate = schema;
135
+ if (typeof candidate.description === 'string' && candidate.description.trim()) {
136
+ return candidate.description.trim();
137
+ }
138
+ if (typeof candidate.title === 'string' && candidate.title.trim()) {
139
+ return candidate.title.trim();
140
+ }
141
+ return undefined;
142
+ }
143
+ getMcpParameterLabel(name, schema, isRequired) {
144
+ const typeSummary = this.getMcpParameterTypeSummary(schema);
145
+ const optionalMarker = isRequired ? '' : '?';
146
+ if (!typeSummary) {
147
+ return `${name}${optionalMarker}`;
148
+ }
149
+ return `${name}${optionalMarker}: ${typeSummary}`;
150
+ }
151
+ buildParameterDocumentation(schema) {
152
+ const description = this.getMcpParameterDescription(schema);
153
+ if (!description) {
154
+ return undefined;
155
+ }
156
+ return new vscode.MarkdownString(description);
157
+ }
158
+ getSignatureState(argsPrefix, parameterNames) {
159
+ const usedParameterNames = new Set();
160
+ const parts = (0, sequenceRunner_1.splitNamedArgumentList)(argsPrefix);
161
+ const endsWithComma = /,\s*$/.test(argsPrefix);
162
+ const completedParts = endsWithComma ? parts : parts.slice(0, -1);
163
+ const currentPart = endsWithComma ? '' : (parts.at(-1) ?? '');
164
+ let positionalIndex = 0;
165
+ const findParameterIndexByName = (name) => {
166
+ return parameterNames.findIndex(param => param.toLowerCase() === name.toLowerCase());
167
+ };
168
+ const advanceToNextUnboundPositionalIndex = () => {
169
+ while (positionalIndex < parameterNames.length && usedParameterNames.has(parameterNames[positionalIndex].toLowerCase())) {
170
+ positionalIndex++;
171
+ }
172
+ return positionalIndex;
173
+ };
174
+ for (const part of completedParts) {
175
+ const match = part.trim().match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:/);
176
+ if (match) {
177
+ const declaredIndex = findParameterIndexByName(match[1]);
178
+ if (declaredIndex >= 0) {
179
+ usedParameterNames.add(parameterNames[declaredIndex].toLowerCase());
180
+ }
181
+ continue;
182
+ }
183
+ const nextPositionalIndex = advanceToNextUnboundPositionalIndex();
184
+ if (nextPositionalIndex < parameterNames.length) {
185
+ usedParameterNames.add(parameterNames[nextPositionalIndex].toLowerCase());
186
+ positionalIndex = nextPositionalIndex + 1;
187
+ }
188
+ }
189
+ if (parameterNames.length === 0) {
190
+ return { activeParameterIndex: 0 };
191
+ }
192
+ const currentTrimmed = currentPart.trim();
193
+ let activeParameterIndex = advanceToNextUnboundPositionalIndex();
194
+ const namedMatch = currentTrimmed.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:/);
195
+ if (namedMatch) {
196
+ const declaredIndex = findParameterIndexByName(namedMatch[1]);
197
+ if (declaredIndex >= 0) {
198
+ activeParameterIndex = declaredIndex;
199
+ }
200
+ }
201
+ else if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(currentTrimmed)) {
202
+ const prefix = currentTrimmed.toLowerCase();
203
+ const prefixedMatchIndex = parameterNames.findIndex(name => !usedParameterNames.has(name.toLowerCase()) && name.toLowerCase().startsWith(prefix));
204
+ if (prefixedMatchIndex >= 0) {
205
+ activeParameterIndex = prefixedMatchIndex;
206
+ }
207
+ }
208
+ if (activeParameterIndex >= parameterNames.length) {
209
+ activeParameterIndex = parameterNames.length - 1;
210
+ }
211
+ const nextParameterIndex = parameterNames.findIndex((name, index) => index > activeParameterIndex &&
212
+ !usedParameterNames.has(name.toLowerCase()));
213
+ return {
214
+ activeParameterIndex,
215
+ nextParameterIndex: nextParameterIndex >= 0 ? nextParameterIndex : undefined
216
+ };
217
+ }
218
+ buildSignatureDocumentation(tool, parameterNames, propertyMap, required, signatureState) {
219
+ const lines = [];
220
+ if (tool.description) {
221
+ lines.push(tool.description);
222
+ }
223
+ if (parameterNames.length === 0) {
224
+ return lines.length > 0 ? new vscode.MarkdownString(lines.join('\n')) : undefined;
225
+ }
226
+ if (signatureState.nextParameterIndex !== undefined) {
227
+ const nextName = parameterNames[signatureState.nextParameterIndex];
228
+ const nextLabel = this.getMcpParameterLabel(nextName, propertyMap[nextName], required.has(nextName.toLowerCase()));
229
+ if (lines.length > 0) {
230
+ lines.push('');
231
+ }
232
+ lines.push(`**Next:** \`${nextLabel}\``);
233
+ }
234
+ return lines.length > 0 ? new vscode.MarkdownString(lines.join('\n')) : undefined;
235
+ }
236
+ }
237
+ exports.McpSignatureHelpProvider = McpSignatureHelpProvider;
238
+ class HttpCompletionProvider {
239
+ httpMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
240
+ keywords = ['var', 'test', 'test sequence', 'sequence', 'end sequence', 'if', 'end if', 'wait', 'run bash', 'run powershell', 'run js', 'run readJson', 'run sql', 'run mcp', 'run', 'print', 'assert', 'import', 'return', 'retry', 'backoff'];
241
+ nornapiKeywords = ['headers', 'end headers', 'endpoints', 'end endpoints'];
242
+ commonHeaders = [
243
+ 'Content-Type',
244
+ 'Authorization',
245
+ 'Accept',
246
+ 'Cache-Control',
247
+ 'User-Agent',
248
+ 'Accept-Encoding',
249
+ 'Accept-Language',
250
+ 'Connection',
251
+ 'Host',
252
+ 'Origin',
253
+ 'Referer',
254
+ 'Cookie',
255
+ 'X-Requested-With',
256
+ 'X-API-Key',
257
+ ];
258
+ contentTypes = [
259
+ 'application/json',
260
+ 'application/xml',
261
+ 'application/x-www-form-urlencoded',
262
+ 'multipart/form-data',
263
+ 'text/plain',
264
+ 'text/html',
265
+ ];
266
+ provideCompletionItems(document, position) {
267
+ const fileName = path.basename(document.uri.fsPath);
268
+ const lineText = document.lineAt(position).text;
269
+ const linePrefix = lineText.substring(0, position.character);
270
+ const lineSuffix = lineText.substring(position.character);
271
+ const trimmedPrefix = linePrefix.trim().toUpperCase();
272
+ if (fileName === nornConfig_1.NORN_CONFIG_FILENAME) {
273
+ return this.getNornConfigCompletions(document, position, linePrefix);
274
+ }
275
+ // Handle .nornapi files separately
276
+ if (document.languageId === 'nornapi') {
277
+ return this.getNornapiCompletions(document, position, linePrefix, lineSuffix);
278
+ }
279
+ if (document.languageId === 'nornsql') {
280
+ return this.getNornsqlCompletions(document, position, linePrefix);
281
+ }
282
+ // Don't provide completions inside comments
283
+ // Comments start with # but not #import
284
+ if (this.isInsideComment(linePrefix)) {
285
+ return [];
286
+ }
287
+ // File-path IntelliSense for import/run script paths.
288
+ const filePathCompletions = this.getFilePathCompletions(document, position, linePrefix);
289
+ if (filePathCompletions !== null) {
290
+ return filePathCompletions;
291
+ }
292
+ // EARLY CHECK: If typing a header value for Content-Type (works for both URL and API endpoint requests)
293
+ if (linePrefix.toLowerCase().includes('content-type:')) {
294
+ return this.getContentTypeCompletions();
295
+ }
296
+ // Check if user is typing a sequence tag (@)
297
+ if (this.isTypingSequenceTag(linePrefix)) {
298
+ return this.getSequenceTagCompletions(document, linePrefix);
299
+ }
300
+ // Check if user is typing a response capture reference ($N. or $N.body.)
301
+ if (this.isTypingResponseCapture(linePrefix)) {
302
+ return this.getResponseCaptureCompletions(document, position, linePrefix);
303
+ }
304
+ // Check if user is typing a variable property reference ({{varname.)
305
+ if (this.isTypingVariableProperty(linePrefix)) {
306
+ return this.getVariablePropertyCompletions(document, position, linePrefix, lineSuffix);
307
+ }
308
+ // Check if user is typing a variable reference (after { or {{)
309
+ if (this.isTypingVariable(linePrefix)) {
310
+ return this.getVariableCompletions(document, position, linePrefix, lineSuffix);
311
+ }
312
+ // Check if user is typing in a bare variable context (inside parentheses, after print, etc.)
313
+ // This should be checked early - before run command or endpoint checks
314
+ if (this.isTypingBareVariableContext(linePrefix, document, position)) {
315
+ return this.getBareVariableCompletions(document, position, linePrefix);
316
+ }
317
+ if (this.isTypingRunMcpCommand(linePrefix)) {
318
+ return this.getMcpCompletions(document, linePrefix);
319
+ }
320
+ if (this.isTypingRunSqlCommand(linePrefix)) {
321
+ return this.getSqlOperationCompletions(document, linePrefix);
322
+ }
323
+ // Check if user is typing after "run " - suggest named requests
324
+ if (this.isTypingRunCommand(linePrefix)) {
325
+ return this.getNamedRequestCompletions(document, linePrefix);
326
+ }
327
+ // Check if user is typing after a URL in a var request - suggest retry/backoff
328
+ // e.g., "var result = GET "https://..." " or "var data = GET https://api.com "
329
+ if (this.isTypingAfterRequestUrl(linePrefix)) {
330
+ return this.getRetryBackoffCompletions(linePrefix);
331
+ }
332
+ // Check if user is typing after HTTP method + endpoint - suggest header groups and retry/backoff
333
+ // e.g., "GET GetUser("1") " or "GET GetAllUsers " or "var x = GET GetUser(1) "
334
+ if (this.isTypingAfterApiEndpoint(linePrefix, document)) {
335
+ // Only suggest header groups in this context.
336
+ // If no header groups are defined in imported .nornapi files, show nothing.
337
+ return this.getHeaderGroupCompletions(document, linePrefix);
338
+ }
339
+ // Check if user is typing after HTTP method - suggest endpoints (in addition to URLs)
340
+ // e.g., "GET " could suggest "GetUser" endpoint
341
+ if (this.isTypingAfterHttpMethod(linePrefix)) {
342
+ const endpointCompletions = this.getEndpointCompletions(document, linePrefix);
343
+ if (endpointCompletions.length > 0) {
344
+ return endpointCompletions;
345
+ }
346
+ }
347
+ // Check if user is typing after "var x = " - suggest run commands
348
+ if (this.isTypingVarAssignment(linePrefix)) {
349
+ return this.getRunCompletionsForVarAssignment(document, linePrefix);
350
+ }
351
+ // Check if user is typing a variable name for property assignment (e.g., "config" or "config.")
352
+ if (this.isTypingPropertyAssignment(linePrefix)) {
353
+ return this.getPropertyAssignmentCompletions(document, linePrefix);
354
+ }
355
+ // Swagger-based request body IntelliSense for endpoint POST/PUT/PATCH calls.
356
+ const requestBodyContext = this.getRequestBodyCompletionContext(document, position);
357
+ if (requestBodyContext) {
358
+ if (this.isTypingJsonBodyStart(linePrefix)) {
359
+ return this.getRequestBodyTemplateCompletions(requestBodyContext, position, lineText);
360
+ }
361
+ const inlineBodyCompletions = this.getInlineRequestBodyKeyCompletions(requestBodyContext, document, position, linePrefix);
362
+ if (inlineBodyCompletions.length > 0) {
363
+ return inlineBodyCompletions;
364
+ }
365
+ }
366
+ const isAfterApiRequest = this.isAfterApiRequest(document, position);
367
+ // If a JSON body is being started right below an API request, don't show IntelliSense.
368
+ // We don't know body shape, and header/group suggestions are noisy in this context.
369
+ if (isAfterApiRequest && this.isTypingJsonBodyStart(linePrefix)) {
370
+ return [];
371
+ }
372
+ // Check if we're after an API request (GET EndpointName, etc.)
373
+ // Provide header groups and inline headers
374
+ if (isAfterApiRequest) {
375
+ // If typing a header name (no colon yet), provide both header groups and inline headers
376
+ if (!linePrefix.includes(':')) {
377
+ const headerGroupCompletions = this.getStandaloneHeaderGroupCompletions(document, linePrefix);
378
+ const headerCompletions = this.getHeaderCompletions();
379
+ return [...headerGroupCompletions, ...headerCompletions];
380
+ }
381
+ // If line has a colon but not Content-Type (which is handled earlier), return empty
382
+ return [];
383
+ }
384
+ // Check if user might be typing a JSON variable name for property assignment
385
+ const jsonVarCompletions = this.getJsonVariableCompletions(document, linePrefix);
386
+ // If we have JSON variable completions, show those (for property assignment)
387
+ if (jsonVarCompletions.length > 0) {
388
+ return jsonVarCompletions;
389
+ }
390
+ // Show HTTP methods/keywords at line start (or while typing them),
391
+ // but not after a METHOD ... context where endpoint/header logic applies.
392
+ if ((position.character === 0 || linePrefix.trim() === '' || this.couldBeMethodOrKeyword(trimmedPrefix)) &&
393
+ !this.isTypingAfterHttpMethod(linePrefix)) {
394
+ const typedPrefix = linePrefix.trim().toLowerCase();
395
+ const methodItems = this.getMethodCompletions(typedPrefix);
396
+ const keywordItems = this.getKeywordCompletions();
397
+ if (!typedPrefix) {
398
+ return [...methodItems, ...keywordItems];
399
+ }
400
+ const filteredKeywords = keywordItems.filter(item => item.label.toString().toLowerCase().startsWith(typedPrefix));
401
+ return [...methodItems, ...filteredKeywords];
402
+ }
403
+ return [];
404
+ }
405
+ /**
406
+ * Check if the cursor is inside a comment.
407
+ * Comments start with # but not #import
408
+ * This handles both line comments (# at start of line) and inline comments (# after code)
409
+ */
410
+ isInsideComment(linePrefix) {
411
+ const trimmed = linePrefix.trimStart();
412
+ // Check if line starts with # but not #import
413
+ if (trimmed.startsWith('#') && !trimmed.toLowerCase().startsWith('#import')) {
414
+ return true;
415
+ }
416
+ // Check for inline comments: find # that's not inside quotes and not part of #import
417
+ // We need to scan through the line and find if cursor is after an unquoted #
418
+ let inSingleQuote = false;
419
+ let inDoubleQuote = false;
420
+ for (let i = 0; i < linePrefix.length; i++) {
421
+ const char = linePrefix[i];
422
+ const prevChar = i > 0 ? linePrefix[i - 1] : '';
423
+ // Skip escaped quotes
424
+ if (prevChar === '\\') {
425
+ continue;
426
+ }
427
+ if (char === '"' && !inSingleQuote) {
428
+ inDoubleQuote = !inDoubleQuote;
429
+ }
430
+ else if (char === "'" && !inDoubleQuote) {
431
+ inSingleQuote = !inSingleQuote;
432
+ }
433
+ else if (char === '#' && !inSingleQuote && !inDoubleQuote) {
434
+ // Found an unquoted #, cursor is in a comment
435
+ return true;
436
+ }
437
+ }
438
+ return false;
439
+ }
440
+ /**
441
+ * Check if cursor is inside a quoted string
442
+ */
443
+ isInsideQuotes(linePrefix) {
444
+ let inSingleQuote = false;
445
+ let inDoubleQuote = false;
446
+ for (let i = 0; i < linePrefix.length; i++) {
447
+ const char = linePrefix[i];
448
+ const prevChar = i > 0 ? linePrefix[i - 1] : '';
449
+ // Skip escaped quotes
450
+ if (prevChar === '\\') {
451
+ continue;
452
+ }
453
+ if (char === '"' && !inSingleQuote) {
454
+ inDoubleQuote = !inDoubleQuote;
455
+ }
456
+ else if (char === "'" && !inDoubleQuote) {
457
+ inSingleQuote = !inSingleQuote;
458
+ }
459
+ }
460
+ return inSingleQuote || inDoubleQuote;
461
+ }
462
+ /**
463
+ * Provide filesystem-backed path completions in import/run-script contexts.
464
+ * Returns null when not in a filepath context.
465
+ */
466
+ getFilePathCompletions(document, position, linePrefix) {
467
+ const importContext = this.getImportPathCompletionContext(linePrefix);
468
+ if (importContext) {
469
+ return this.getRelativePathCompletions(document, position, importContext);
470
+ }
471
+ const runScriptContext = this.getRunScriptPathCompletionContext(linePrefix);
472
+ if (runScriptContext) {
473
+ return this.getRelativePathCompletions(document, position, runScriptContext);
474
+ }
475
+ return null;
476
+ }
477
+ /**
478
+ * Detect import path typing context:
479
+ * import "./..."
480
+ * import '../...'
481
+ * import ./...
482
+ */
483
+ getImportPathCompletionContext(linePrefix) {
484
+ const doubleQuotedMatch = linePrefix.match(/^\s*import\s+"([^"]*)$/i);
485
+ const singleQuotedMatch = linePrefix.match(/^\s*import\s+'([^']*)$/i);
486
+ const unquotedMatch = linePrefix.match(/^\s*import\s+(\S*)$/i);
487
+ const typedPath = doubleQuotedMatch?.[1] ?? singleQuotedMatch?.[1] ?? unquotedMatch?.[1];
488
+ if (!typedPath || !typedPath.startsWith('.')) {
489
+ return null;
490
+ }
491
+ return {
492
+ typedPath,
493
+ preferredExtensions: ['.norn', '.nornapi', '.nornsql']
494
+ };
495
+ }
496
+ /**
497
+ * Detect run-script path typing context:
498
+ * run bash ./...
499
+ * run powershell "./..."
500
+ * run js ../...
501
+ * var result = run bash ./...
502
+ */
503
+ getRunScriptPathCompletionContext(linePrefix) {
504
+ const runPrefixMatch = linePrefix.match(/^\s*(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?run\s+(bash|powershell|js|readjson)\s+(.*)$/i);
505
+ if (!runPrefixMatch) {
506
+ return null;
507
+ }
508
+ const runMethod = runPrefixMatch[1].toLowerCase();
509
+ const pathText = runPrefixMatch[2];
510
+ let typedPath = null;
511
+ if (pathText.startsWith('"')) {
512
+ const quotedPathMatch = pathText.match(/^"([^"]*)$/);
513
+ if (quotedPathMatch) {
514
+ typedPath = quotedPathMatch[1];
515
+ }
516
+ }
517
+ else if (pathText.startsWith('\'')) {
518
+ const quotedPathMatch = pathText.match(/^'([^']*)$/);
519
+ if (quotedPathMatch) {
520
+ typedPath = quotedPathMatch[1];
521
+ }
522
+ }
523
+ else {
524
+ const unquotedPathMatch = pathText.match(/^(\S*)$/);
525
+ if (unquotedPathMatch) {
526
+ typedPath = unquotedPathMatch[1];
527
+ }
528
+ }
529
+ if (!typedPath || !typedPath.startsWith('.')) {
530
+ return null;
531
+ }
532
+ return {
533
+ typedPath,
534
+ preferredExtensions: this.getPreferredRunScriptExtensions(runMethod)
535
+ };
536
+ }
537
+ getPreferredRunScriptExtensions(runMethod) {
538
+ switch (runMethod) {
539
+ case 'bash':
540
+ return ['.sh'];
541
+ case 'powershell':
542
+ return ['.ps1'];
543
+ case 'js':
544
+ return ['.js', '.mjs', '.cjs'];
545
+ case 'readjson':
546
+ return ['.json'];
547
+ default:
548
+ return [];
549
+ }
550
+ }
551
+ getPathEntryCompletionKind(entryName, isDirectory) {
552
+ if (isDirectory) {
553
+ return vscode.CompletionItemKind.Folder;
554
+ }
555
+ // Completion lists cannot use Explorer's file-icon-theme mapping per extension.
556
+ // Use File for all files so entries consistently show a non-empty file icon.
557
+ return vscode.CompletionItemKind.File;
558
+ }
559
+ getPathEntryDetail(entryName, isDirectory) {
560
+ if (isDirectory) {
561
+ return 'Directory';
562
+ }
563
+ const extension = path.extname(entryName).toLowerCase();
564
+ if (extension === '.norn') {
565
+ return 'Norn file';
566
+ }
567
+ if (extension === '.nornapi') {
568
+ return 'Norn API file';
569
+ }
570
+ if (extension === '.nornenv') {
571
+ return 'Norn environment file';
572
+ }
573
+ return extension ? `File (${extension})` : 'File';
574
+ }
575
+ getRelativePathCompletions(document, position, context) {
576
+ if (document.uri.scheme !== 'file') {
577
+ return [];
578
+ }
579
+ const typedPath = context.typedPath.replace(/\\/g, '/');
580
+ let directoryPart = '';
581
+ let entryPrefix = typedPath;
582
+ if (typedPath === '.') {
583
+ directoryPart = './';
584
+ entryPrefix = '';
585
+ }
586
+ else if (typedPath === '..') {
587
+ directoryPart = '../';
588
+ entryPrefix = '';
589
+ }
590
+ else if (typedPath.endsWith('/')) {
591
+ directoryPart = typedPath;
592
+ entryPrefix = '';
593
+ }
594
+ else {
595
+ const lastSlash = typedPath.lastIndexOf('/');
596
+ if (lastSlash >= 0) {
597
+ directoryPart = typedPath.substring(0, lastSlash + 1);
598
+ entryPrefix = typedPath.substring(lastSlash + 1);
599
+ }
600
+ }
601
+ const documentDir = path.dirname(document.uri.fsPath);
602
+ const lookupRelativePath = directoryPart || '.';
603
+ const lookupAbsolutePath = path.resolve(documentDir, lookupRelativePath);
604
+ let entries;
605
+ try {
606
+ const stats = fs.statSync(lookupAbsolutePath);
607
+ if (!stats.isDirectory()) {
608
+ return [];
609
+ }
610
+ entries = fs.readdirSync(lookupAbsolutePath, { withFileTypes: true });
611
+ }
612
+ catch {
613
+ return [];
614
+ }
615
+ const preferredExtensions = new Set(context.preferredExtensions.map(ext => ext.toLowerCase()));
616
+ const lowerPrefix = entryPrefix.toLowerCase();
617
+ const replaceStart = Math.max(0, position.character - context.typedPath.length);
618
+ const replaceRange = new vscode.Range(position.line, replaceStart, position.line, position.character);
619
+ const items = [];
620
+ for (const entry of entries) {
621
+ const entryName = entry.name;
622
+ if (entryName === '.' || entryName === '..') {
623
+ continue;
624
+ }
625
+ // Keep hidden entries out of the default list unless the user explicitly types a hidden prefix.
626
+ const isNornenvFile = entryName.toLowerCase() === '.nornenv';
627
+ if (entryName.startsWith('.') && !entryPrefix.startsWith('.') && !isNornenvFile) {
628
+ continue;
629
+ }
630
+ if (lowerPrefix && !entryName.toLowerCase().startsWith(lowerPrefix)) {
631
+ continue;
632
+ }
633
+ const isDirectory = entry.isDirectory();
634
+ const extension = path.extname(entryName).toLowerCase();
635
+ const insertPath = `${directoryPart}${entryName}${isDirectory ? '/' : ''}`;
636
+ const label = isDirectory ? `${entryName}/` : entryName;
637
+ const itemKind = this.getPathEntryCompletionKind(entryName, isDirectory);
638
+ // Preferred file extensions first, then folders, then all other files.
639
+ const priority = !isDirectory && preferredExtensions.has(extension)
640
+ ? '0'
641
+ : isDirectory
642
+ ? '1'
643
+ : '2';
644
+ items.push(this.createCompletionItem(label, itemKind, {
645
+ insertText: insertPath,
646
+ filterText: insertPath,
647
+ range: replaceRange,
648
+ detail: this.getPathEntryDetail(entryName, isDirectory),
649
+ command: isDirectory ? this.createTriggerSuggestCommand() : undefined,
650
+ sortText: `${priority}_${entryName.toLowerCase()}`
651
+ }));
652
+ }
653
+ return items;
654
+ }
655
+ /**
656
+ * Detect the start of a JSON body line (e.g., "{" or "[") under an API request.
657
+ */
658
+ isTypingJsonBodyStart(linePrefix) {
659
+ const trimmed = linePrefix.trimStart();
660
+ return trimmed.startsWith('{') || trimmed.startsWith('[');
661
+ }
662
+ /**
663
+ * Check if user is in a context where bare variable names should be suggested.
664
+ * This includes:
665
+ * - Inside function/endpoint parameters: GetUser(|) or GetUser(a, |)
666
+ * - After print keyword: print |
667
+ * - After operators in expressions: print "text" + |
668
+ * - In assertion expressions: assert | == something
669
+ *
670
+ * NOT triggered when:
671
+ * - Inside quoted strings
672
+ * - Inside {{ }} (handled by getVariableCompletions)
673
+ */
674
+ isTypingBareVariableContext(linePrefix, document, position) {
675
+ // Don't suggest if inside quotes
676
+ if (this.isInsideQuotes(linePrefix)) {
677
+ return false;
678
+ }
679
+ if (this.isInsideMcpCallArguments(linePrefix)) {
680
+ return false;
681
+ }
682
+ const trimmed = linePrefix.trim();
683
+ // Check if inside parentheses (endpoint parameters)
684
+ // e.g., "GET GetUser(" or "GetUser(a, "
685
+ const openParens = (linePrefix.match(/\(/g) || []).length;
686
+ const closeParens = (linePrefix.match(/\)/g) || []).length;
687
+ if (openParens > closeParens) {
688
+ // We're inside parentheses
689
+ const lastOpenParen = linePrefix.lastIndexOf('(');
690
+ const afterParen = linePrefix.substring(lastOpenParen + 1);
691
+ // Check we're not inside a string within the parens
692
+ if (!this.isInsideQuotes(afterParen)) {
693
+ return true;
694
+ }
695
+ }
696
+ // Check if after print keyword (but not in quotes)
697
+ // e.g., "print " or "print text + "
698
+ if (/^\s*print\s/i.test(linePrefix)) {
699
+ // Get everything after "print "
700
+ const printMatch = linePrefix.match(/print\s+(.*)$/i);
701
+ if (printMatch) {
702
+ const afterPrint = printMatch[1];
703
+ if (!this.isInsideQuotes(afterPrint)) {
704
+ return true;
705
+ }
706
+ }
707
+ // If just "print " with nothing after, still show variables
708
+ if (/print\s+$/i.test(linePrefix)) {
709
+ return true;
710
+ }
711
+ }
712
+ // Check if after assert keyword
713
+ if (/^\s*assert\s/i.test(linePrefix)) {
714
+ const assertMatch = linePrefix.match(/assert\s+(.*)$/i);
715
+ if (assertMatch) {
716
+ const afterAssert = assertMatch[1];
717
+ if (!this.isInsideQuotes(afterAssert)) {
718
+ return true;
719
+ }
720
+ }
721
+ if (/assert\s+$/i.test(linePrefix)) {
722
+ return true;
723
+ }
724
+ }
725
+ // Check if after return keyword
726
+ if (/^\s*return\s/i.test(linePrefix)) {
727
+ const returnMatch = linePrefix.match(/return\s+(.*)$/i);
728
+ if (returnMatch) {
729
+ const afterReturn = returnMatch[1];
730
+ if (!this.isInsideQuotes(afterReturn)) {
731
+ return true;
732
+ }
733
+ }
734
+ if (/return\s+$/i.test(linePrefix)) {
735
+ return true;
736
+ }
737
+ }
738
+ return false;
739
+ }
740
+ isInsideMcpCallArguments(linePrefix) {
741
+ const match = linePrefix.match(/^\s*(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?run\s+mcp\s+call\s+[a-zA-Z_][a-zA-Z0-9_-]*\s+[a-zA-Z_][a-zA-Z0-9_.:-]*\((.*)$/i);
742
+ if (!match) {
743
+ return false;
744
+ }
745
+ return !match[1].includes(')');
746
+ }
747
+ getSequenceLocalVariableNames(document, position, fullText = document.getText()) {
748
+ const sequences = (0, sequenceRunner_1.extractSequences)(fullText);
749
+ const containingSequence = sequences.find(seq => position.line > seq.startLine && position.line < seq.endLine);
750
+ const localVars = new Set();
751
+ if (!containingSequence) {
752
+ return localVars;
753
+ }
754
+ if (containingSequence.parameters) {
755
+ for (const param of containingSequence.parameters) {
756
+ localVars.add(param.name);
757
+ }
758
+ }
759
+ const lines = containingSequence.content.split('\n');
760
+ for (const line of lines) {
761
+ const varMatch = line.trim().match(/^var\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*=/);
762
+ if (varMatch) {
763
+ localVars.add(varMatch[1]);
764
+ }
765
+ }
766
+ return localVars;
767
+ }
768
+ getEnvironmentVariablesForCompletion(pathOrSourceFile) {
769
+ const activeEnv = (0, environmentProvider_1.getActiveEnvironment)(pathOrSourceFile);
770
+ if (activeEnv) {
771
+ return (0, environmentProvider_1.getEnvironmentVariables)(pathOrSourceFile);
772
+ }
773
+ const config = (0, environmentProvider_1.loadEnvironmentConfig)(pathOrSourceFile);
774
+ if (!config) {
775
+ return {};
776
+ }
777
+ const variables = { ...config.common };
778
+ for (const env of config.environments) {
779
+ for (const [name, value] of Object.entries(env.variables)) {
780
+ if (!(name in variables)) {
781
+ variables[name] = value;
782
+ }
783
+ }
784
+ }
785
+ return variables;
786
+ }
787
+ /**
788
+ * Get completions for bare variable names in contexts like print, parameters, etc.
789
+ */
790
+ getBareVariableCompletions(document, position, linePrefix) {
791
+ const fullText = document.getText();
792
+ const fileVariables = (0, parser_1.extractVariables)(fullText);
793
+ const envVariables = this.getEnvironmentVariablesForCompletion(document.uri.fsPath);
794
+ const envSecretNames = (0, environmentProvider_1.getEnvironmentSecretNames)(document.uri.fsPath);
795
+ const localVars = this.getSequenceLocalVariableNames(document, position, fullText);
796
+ // Merge all variables
797
+ const allVariables = new Map();
798
+ // Add environment variables
799
+ for (const [name, value] of Object.entries(envVariables)) {
800
+ allVariables.set(name, { value, source: 'env' });
801
+ }
802
+ // Add file variables
803
+ for (const [name, value] of Object.entries(fileVariables)) {
804
+ allVariables.set(name, { value, source: 'file' });
805
+ }
806
+ // Add local variables (highest priority)
807
+ for (const name of localVars) {
808
+ allVariables.set(name, { value: '(local)', source: 'local' });
809
+ }
810
+ if (allVariables.size === 0) {
811
+ return [];
812
+ }
813
+ // Determine what the user is already typing
814
+ let partialName = '';
815
+ const partialMatch = linePrefix.match(/[a-zA-Z_][a-zA-Z0-9_]*$/);
816
+ if (partialMatch) {
817
+ partialName = partialMatch[0];
818
+ }
819
+ const items = [];
820
+ for (const [name, { value, source }] of allVariables) {
821
+ // Filter by partial name if user has started typing
822
+ if (partialName && !name.toLowerCase().startsWith(partialName.toLowerCase())) {
823
+ continue;
824
+ }
825
+ const kind = source === 'env'
826
+ ? vscode.CompletionItemKind.Constant
827
+ : source === 'local'
828
+ ? vscode.CompletionItemKind.Variable
829
+ : vscode.CompletionItemKind.Field;
830
+ // Show source in detail
831
+ const shouldRedact = source === 'env' && envSecretNames.has(name) && value !== '(local)';
832
+ const detail = shouldRedact ? '***SECRET***' : value !== '(local)' ? value : undefined;
833
+ const sourceDesc = source === 'env'
834
+ ? '**Source:** Environment'
835
+ : source === 'local'
836
+ ? '**Source:** Local sequence variable'
837
+ : '**Source:** File';
838
+ items.push(this.createCompletionItem(name, kind, {
839
+ insertText: name,
840
+ detail,
841
+ documentation: new vscode.MarkdownString(`**Variable:** \`${name}\`\n\n${sourceDesc}`),
842
+ // Sort: local first, then file, then env
843
+ sortText: source === 'local' ? `0_${name}` : source === 'file' ? `1_${name}` : `2_${name}`
844
+ }));
845
+ }
846
+ return items;
847
+ }
848
+ /**
849
+ * Check if the line looks like code (variable assignment, etc.) rather than headers
850
+ */
851
+ looksLikeCode(linePrefix) {
852
+ const trimmed = linePrefix.trim();
853
+ // Looks like code if it starts with a lowercase letter followed by more text
854
+ // Headers typically start with uppercase (Content-Type, Authorization, etc.)
855
+ return /^[a-z_][a-zA-Z0-9_]*/.test(trimmed);
856
+ }
857
+ // Check if user is typing inside {{ }} for variable reference
858
+ // Only trigger when there's content before the {
859
+ isTypingVariable(linePrefix) {
860
+ const trimmed = linePrefix.trim();
861
+ // JSON body/object start should not trigger variable completions.
862
+ // Body IntelliSense handles this context separately.
863
+ if (trimmed === '{' || trimmed === '[') {
864
+ return false;
865
+ }
866
+ // Must have something before the brace (not just starting with {)
867
+ // e.g., "GET {{" or "Authorization: Bearer {{"
868
+ // Check for {{ with content before it
869
+ const doubleOpenMatch = trimmed.match(/\S+.*\{\{([a-zA-Z_][a-zA-Z0-9_]*)?$/);
870
+ if (doubleOpenMatch) {
871
+ return true;
872
+ }
873
+ // Check for single { at the end with content before it
874
+ const singleOpenMatch = trimmed.match(/\S+.*\{$/);
875
+ if (singleOpenMatch) {
876
+ return true;
877
+ }
878
+ // Keep suggestions active while typing after a single "{". Selecting a
879
+ // variable completion will expand it to the full "{{name}}" syntax.
880
+ const singleOpenPartialMatch = trimmed.match(/\S+.*\{([a-zA-Z_][a-zA-Z0-9_]*)$/);
881
+ if (singleOpenPartialMatch) {
882
+ return true;
883
+ }
884
+ return false;
885
+ }
886
+ getVariableBodyCompletionContext(linePrefix) {
887
+ const match = linePrefix.match(/(?:^|[\s({=+\-*!,]|\{\{)([a-zA-Z_][a-zA-Z0-9_]*)\.body((?:\.[a-zA-Z_][a-zA-Z0-9_-]*|\[\d+\])*(?:\.)?)$/i);
888
+ if (!match || !match[2]) {
889
+ return undefined;
890
+ }
891
+ return {
892
+ varName: match[1],
893
+ bodySuffix: match[2] || ''
894
+ };
895
+ }
896
+ getVariableHeaderCompletionContext(linePrefix) {
897
+ const match = linePrefix.match(/(?:^|[\s({=+\-*!,]|\{\{)([a-zA-Z_][a-zA-Z0-9_]*)\.headers\.([a-zA-Z_][a-zA-Z0-9_-]*)?$/i);
898
+ if (!match) {
899
+ return undefined;
900
+ }
901
+ return {
902
+ varName: match[1],
903
+ partial: match[2] || ''
904
+ };
905
+ }
906
+ getVariableTopLevelCompletionContext(linePrefix) {
907
+ const match = linePrefix.match(/(?:^|[\s({=+\-*!,]|\{\{)([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)?$/);
908
+ if (!match) {
909
+ return undefined;
910
+ }
911
+ return {
912
+ varName: match[1],
913
+ partial: match[2] || ''
914
+ };
915
+ }
916
+ /**
917
+ * Check if user is typing a property access on a variable: {{varname. OR varname. (for assertions)
918
+ * Also handles cached response-body paths like user.body.profile.
919
+ */
920
+ isTypingVariableProperty(linePrefix) {
921
+ return Boolean(this.getVariableBodyCompletionContext(linePrefix) ||
922
+ this.getVariableHeaderCompletionContext(linePrefix) ||
923
+ this.getVariableTopLevelCompletionContext(linePrefix));
924
+ }
925
+ /**
926
+ * Get completions for variable properties based on what the variable was assigned from.
927
+ * If variable came from a sequence with a return statement, show the return fields.
928
+ * If variable came from a request (var x = GET url), show response properties.
929
+ * Also handles nested properties like user.body. or user.headers.
930
+ */
931
+ getVariablePropertyCompletions(document, position, linePrefix, lineSuffix) {
932
+ const fullText = document.getText();
933
+ // For {{varName.}} style, check for closing braces
934
+ let closingBraces = '';
935
+ if (/\{\{/.test(linePrefix)) {
936
+ const closingBracesAhead = lineSuffix.match(/^\}+/);
937
+ const numClosingBraces = closingBracesAhead ? closingBracesAhead[0].length : 0;
938
+ const bracesToAdd = Math.max(0, 2 - numClosingBraces);
939
+ closingBraces = '}'.repeat(bracesToAdd);
940
+ }
941
+ const bodyContext = this.getVariableBodyCompletionContext(linePrefix);
942
+ if (bodyContext) {
943
+ const requestSource = this.findCapturedRequestVariable(document, position, bodyContext.varName) ??
944
+ this.findCapturedRunNamedRequestVariable(document, fullText, bodyContext.varName) ??
945
+ this.findCapturedRunSequenceResponseVariable(document, fullText, bodyContext.varName);
946
+ if (!requestSource) {
947
+ return [];
948
+ }
949
+ return this.getCachedApiBodyPropertyCompletions(document, requestSource.line, requestSource.request, bodyContext.bodySuffix, closingBraces, requestSource.sourceFile) ?? [];
950
+ }
951
+ const headerContext = this.getVariableHeaderCompletionContext(linePrefix);
952
+ if (headerContext) {
953
+ const requestSource = this.findCapturedRequestVariable(document, position, headerContext.varName);
954
+ const isRunNamedRequestResponse = this.isVariableAssignedFromRunNamedRequest(document, fullText, headerContext.varName);
955
+ const isRunSequenceResponse = Boolean(this.findCapturedRunSequenceResponseVariable(document, fullText, headerContext.varName));
956
+ if (requestSource || isRunNamedRequestResponse || isRunSequenceResponse) {
957
+ return this.getCommonHeaderCompletions(closingBraces);
958
+ }
959
+ return [];
960
+ }
961
+ // Extract the variable name from {{varname. or just varname.
962
+ const topLevelContext = this.getVariableTopLevelCompletionContext(linePrefix);
963
+ if (!topLevelContext) {
964
+ return [];
965
+ }
966
+ const varName = topLevelContext.varName;
967
+ // Check if this variable was assigned from a request (var x = GET url)
968
+ const varRequestSource = this.findCapturedRequestVariable(document, position, varName);
969
+ if (varRequestSource) {
970
+ // Variable is a captured response - suggest response properties
971
+ return this.getResponsePropertyCompletions(closingBraces, varName, topLevelContext.partial);
972
+ }
973
+ // Check if this variable was assigned from a named request (var x = run GetToken)
974
+ if (this.isVariableAssignedFromRunNamedRequest(document, fullText, varName)) {
975
+ return this.getResponsePropertyCompletions(closingBraces, varName, topLevelContext.partial);
976
+ }
977
+ if (this.findCapturedRunSequenceResponseVariable(document, fullText, varName)) {
978
+ return this.getResponsePropertyCompletions(closingBraces, varName, topLevelContext.partial);
979
+ }
980
+ const sequenceAssignment = this.findRunSequenceAssignment(document, fullText, varName);
981
+ if (!sequenceAssignment) {
982
+ return [];
983
+ }
984
+ const sequenceName = sequenceAssignment.sequence.name;
985
+ // Get the return fields from the sequence
986
+ const returnFields = (0, sequenceRunner_1.extractReturnVariables)(sequenceAssignment.sequence.content);
987
+ if (!returnFields || returnFields.length === 0) {
988
+ return [];
989
+ }
990
+ const lowerPartial = topLevelContext.partial.toLowerCase();
991
+ // Create completions for each return field
992
+ return returnFields
993
+ .map(field => ({
994
+ expression: field,
995
+ fieldName: field.includes('.') ? field.split('.').pop() : field
996
+ }))
997
+ .filter(field => !lowerPartial || field.fieldName.toLowerCase().startsWith(lowerPartial))
998
+ .map(field => {
999
+ return this.createCompletionItem(field.fieldName, vscode.CompletionItemKind.Property, {
1000
+ insertText: field.fieldName + closingBraces,
1001
+ detail: `from ${sequenceName}`,
1002
+ documentation: new vscode.MarkdownString(`Return field from sequence \`${sequenceName}\`\n\n**Expression:** \`${field.expression}\``),
1003
+ sortText: `0_${field.fieldName}`
1004
+ });
1005
+ });
1006
+ }
1007
+ /**
1008
+ * Checks whether a variable assignment uses "var x = run Name" where Name resolves to a named request
1009
+ * (and not to a sequence, since run resolves sequences first at runtime).
1010
+ */
1011
+ isVariableAssignedFromRunNamedRequest(document, fullText, varName) {
1012
+ return Boolean(this.findRunNamedRequestAssignment(document, fullText, varName));
1013
+ }
1014
+ findRunNamedRequestAssignment(document, fullText, varName) {
1015
+ const varRunMatch = fullText.match(new RegExp(`var\\s+${varName}\\s*=\\s*run\\s+([a-zA-Z_][a-zA-Z0-9_-]*)(?:\\s*\\([^)]*\\))?`, 'i'));
1016
+ if (!varRunMatch) {
1017
+ return undefined;
1018
+ }
1019
+ const targetName = varRunMatch[1].toLowerCase();
1020
+ const localSequences = (0, sequenceRunner_1.extractSequences)(fullText);
1021
+ if (localSequences.some(seq => seq.name.toLowerCase() === targetName)) {
1022
+ return undefined;
1023
+ }
1024
+ const localNamedRequests = (0, parser_1.extractNamedRequests)(fullText);
1025
+ const localNamedRequest = localNamedRequests.find(req => req.name.toLowerCase() === targetName);
1026
+ if (localNamedRequest) {
1027
+ return { request: localNamedRequest, sourcePath: document.uri.fsPath };
1028
+ }
1029
+ const importedDefinitions = this.getImportedRunDefinitions(document);
1030
+ if (importedDefinitions.sequences.some(entry => entry.sequence.name.toLowerCase() === targetName)) {
1031
+ return undefined;
1032
+ }
1033
+ return importedDefinitions.namedRequests.find(entry => entry.request.name.toLowerCase() === targetName);
1034
+ }
1035
+ getNamedRequestApiResponseSource(namedRequest) {
1036
+ const parsedRequest = (0, apiResponseIntellisenseCache_1.parseApiResponseRequestText)(namedRequest.request.content);
1037
+ if (!parsedRequest) {
1038
+ return undefined;
1039
+ }
1040
+ return {
1041
+ sourceFile: namedRequest.sourcePath,
1042
+ line: namedRequest.request.startLine + 1 + parsedRequest.lineOffset,
1043
+ request: parsedRequest.request
1044
+ };
1045
+ }
1046
+ findCapturedRunNamedRequestVariable(document, fullText, varName) {
1047
+ const namedRequest = this.findRunNamedRequestAssignment(document, fullText, varName);
1048
+ return namedRequest ? this.getNamedRequestApiResponseSource(namedRequest) : undefined;
1049
+ }
1050
+ findRunSequenceAssignment(document, fullText, varName) {
1051
+ const varRunMatch = fullText.match(new RegExp(`var\\s+${varName}\\s*=\\s*run\\s+([a-zA-Z_][a-zA-Z0-9_-]*)(?:\\s*\\([^)]*\\))?`, 'i'));
1052
+ if (!varRunMatch) {
1053
+ return undefined;
1054
+ }
1055
+ const targetName = varRunMatch[1].toLowerCase();
1056
+ const localSequence = (0, sequenceRunner_1.extractSequences)(fullText).find(seq => seq.name.toLowerCase() === targetName);
1057
+ if (localSequence) {
1058
+ return { sequence: localSequence, sourcePath: document.uri.fsPath };
1059
+ }
1060
+ return this.getImportedRunDefinitions(document).sequences.find(entry => entry.sequence.name.toLowerCase() === targetName);
1061
+ }
1062
+ findCapturedRunSequenceResponseVariable(document, fullText, varName) {
1063
+ const sequenceAssignment = this.findRunSequenceAssignment(document, fullText, varName);
1064
+ if (!sequenceAssignment) {
1065
+ return undefined;
1066
+ }
1067
+ const returnFields = (0, sequenceRunner_1.extractReturnVariables)(sequenceAssignment.sequence.content);
1068
+ if (!returnFields || returnFields.length !== 1) {
1069
+ return undefined;
1070
+ }
1071
+ const returnVariableName = returnFields[0].trim();
1072
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(returnVariableName)) {
1073
+ return undefined;
1074
+ }
1075
+ return this.findCapturedRequestVariableInSequence(document, sequenceAssignment, returnVariableName);
1076
+ }
1077
+ findCapturedRequestVariableInSequence(document, sequenceAssignment, varName) {
1078
+ const lowerVarName = varName.toLowerCase();
1079
+ const lines = sequenceAssignment.sequence.content.split('\n');
1080
+ const returnLine = lines.findIndex(line => /^\s*return\b/i.test(line));
1081
+ const searchStartLine = returnLine >= 0 ? returnLine : lines.length - 1;
1082
+ const contentStartLine = this.getSequenceContentStartLine(document, sequenceAssignment);
1083
+ for (let lineIndex = searchStartLine; lineIndex >= 0; lineIndex--) {
1084
+ const request = (0, apiResponseIntellisenseCache_1.parseApiResponseRequestLine)(lines[lineIndex]);
1085
+ if (request?.variableName?.toLowerCase() === lowerVarName) {
1086
+ return {
1087
+ sourceFile: sequenceAssignment.sourcePath,
1088
+ line: contentStartLine + lineIndex,
1089
+ request
1090
+ };
1091
+ }
1092
+ }
1093
+ return undefined;
1094
+ }
1095
+ getSequenceContentStartLine(document, sequenceAssignment) {
1096
+ const sourceText = this.readSourceText(document, sequenceAssignment.sourcePath);
1097
+ if (!sourceText) {
1098
+ return sequenceAssignment.sequence.startLine + 1;
1099
+ }
1100
+ const declarationLine = (0, sequenceDeclaration_1.findSequenceDeclarationLine)(sourceText.split('\n'), sequenceAssignment.sequence.startLine, sequenceAssignment.sequence.endLine);
1101
+ return declarationLine + 1;
1102
+ }
1103
+ readSourceText(document, sourcePath) {
1104
+ if (path.resolve(sourcePath) === path.resolve(document.uri.fsPath)) {
1105
+ return document.getText();
1106
+ }
1107
+ try {
1108
+ return fs.readFileSync(sourcePath, 'utf8');
1109
+ }
1110
+ catch {
1111
+ return undefined;
1112
+ }
1113
+ }
1114
+ findCapturedRequestVariable(document, position, varName) {
1115
+ const lowerVarName = varName.toLowerCase();
1116
+ for (let line = Math.min(position.line, document.lineCount - 1); line >= 0; line--) {
1117
+ const request = (0, apiResponseIntellisenseCache_1.parseApiResponseRequestLine)(document.lineAt(line).text);
1118
+ if (request?.variableName?.toLowerCase() === lowerVarName) {
1119
+ return { sourceFile: document.uri.fsPath, line, request };
1120
+ }
1121
+ }
1122
+ for (let line = position.line + 1; line < document.lineCount; line++) {
1123
+ const request = (0, apiResponseIntellisenseCache_1.parseApiResponseRequestLine)(document.lineAt(line).text);
1124
+ if (request?.variableName?.toLowerCase() === lowerVarName) {
1125
+ return { sourceFile: document.uri.fsPath, line, request };
1126
+ }
1127
+ }
1128
+ return undefined;
1129
+ }
1130
+ getCachedApiBodyPropertyCompletions(document, sourceLine, request, bodySuffix, closingBraces, sourceFile = document.uri.fsPath) {
1131
+ const bodyPath = (0, apiResponseIntellisenseCache_1.parseApiResponseBodyPathForCompletion)(bodySuffix);
1132
+ if (!bodyPath) {
1133
+ return undefined;
1134
+ }
1135
+ const cachedEntry = (0, apiResponseIntellisenseCache_1.getCachedApiResponseShapeForRequest)({
1136
+ sourceFile,
1137
+ sourceLine,
1138
+ method: request.method,
1139
+ target: request.target,
1140
+ environment: (0, environmentProvider_1.getActiveEnvironment)(sourceFile)
1141
+ });
1142
+ if (!cachedEntry) {
1143
+ return undefined;
1144
+ }
1145
+ const node = (0, apiResponseIntellisenseCache_1.getApiResponseShapeNodeAtPath)(cachedEntry.shape, bodyPath.pathSegments);
1146
+ const properties = (0, apiResponseIntellisenseCache_1.getApiResponseShapeProperties)(node);
1147
+ const lowerPartial = bodyPath.partial.toLowerCase();
1148
+ return properties
1149
+ .filter(property => !lowerPartial || property.name.toLowerCase().startsWith(lowerPartial))
1150
+ .map(property => this.createCachedApiBodyPropertyCompletion(property.name, property.shape, closingBraces, cachedEntry.cachedAt));
1151
+ }
1152
+ createCachedApiBodyPropertyCompletion(name, shape, closingBraces, cachedAt) {
1153
+ return this.createCompletionItem(name, vscode.CompletionItemKind.Property, {
1154
+ insertText: name + closingBraces,
1155
+ detail: (0, apiResponseIntellisenseCache_1.getApiResponseShapeTypeSummary)(shape),
1156
+ documentation: new vscode.MarkdownString(`Cached from the last VS Code run.\n\nCached at: \`${cachedAt}\``),
1157
+ sortText: `0_${name.toLowerCase()}`
1158
+ });
1159
+ }
1160
+ /**
1161
+ * Get completions for response properties (used for variables that captured an HTTP response).
1162
+ * Suggests: body, status, statusText, headers, duration, cookies
1163
+ */
1164
+ getResponsePropertyCompletions(closingBraces, varName, partial = '') {
1165
+ const items = [];
1166
+ const lowerPartial = partial.toLowerCase();
1167
+ const responseProperties = [
1168
+ { name: 'body', detail: 'Response body (parsed JSON or string)', doc: 'The response body. Access nested properties with `.body.property`' },
1169
+ { name: 'status', detail: 'HTTP status code (number)', doc: 'The HTTP status code, e.g., 200, 404, 500' },
1170
+ { name: 'statusText', detail: 'HTTP status text', doc: 'The HTTP status text, e.g., "OK", "Not Found"' },
1171
+ { name: 'headers', detail: 'Response headers', doc: 'Access headers with `.headers.Content-Type`' },
1172
+ { name: 'duration', detail: 'Request duration (ms)', doc: 'Time taken for the request in milliseconds' },
1173
+ { name: 'cookies', detail: 'Response cookies', doc: 'Cookies set by the response' },
1174
+ ];
1175
+ for (const prop of responseProperties) {
1176
+ if (lowerPartial && !prop.name.toLowerCase().startsWith(lowerPartial)) {
1177
+ continue;
1178
+ }
1179
+ items.push(this.createCompletionItem(prop.name, vscode.CompletionItemKind.Property, {
1180
+ insertText: prop.name + closingBraces,
1181
+ detail: prop.detail,
1182
+ documentation: new vscode.MarkdownString(`**${prop.name}**\n\n${prop.doc}\n\n**Usage:** \`{{${varName}.${prop.name}}}\``),
1183
+ sortText: `0_${prop.name}`
1184
+ }));
1185
+ }
1186
+ return items;
1187
+ }
1188
+ /**
1189
+ * Get completions for common HTTP header names (used for .headers. context)
1190
+ */
1191
+ getCommonHeaderCompletions(closingBraces) {
1192
+ const commonHeaders = [
1193
+ 'Content-Type',
1194
+ 'Content-Length',
1195
+ 'Cache-Control',
1196
+ 'Set-Cookie',
1197
+ 'Authorization',
1198
+ 'Location',
1199
+ 'X-Request-Id',
1200
+ 'X-Correlation-Id',
1201
+ 'ETag',
1202
+ 'Last-Modified',
1203
+ 'Expires',
1204
+ 'Date',
1205
+ 'Server',
1206
+ ];
1207
+ return commonHeaders.map(header => this.createCompletionItem(header, vscode.CompletionItemKind.Property, {
1208
+ insertText: header + closingBraces,
1209
+ detail: 'HTTP header'
1210
+ }));
1211
+ }
1212
+ /**
1213
+ * Search imported files for a sequence by name.
1214
+ * Returns the sequence if found, or undefined if not.
1215
+ */
1216
+ /**
1217
+ * Collect named requests and sequences from imported .norn files (including nested imports).
1218
+ */
1219
+ getImportedRunDefinitions(document) {
1220
+ const fullText = document.getText();
1221
+ const imports = (0, parser_1.extractImports)(fullText);
1222
+ const namedRequests = [];
1223
+ const sequences = [];
1224
+ if (imports.length === 0) {
1225
+ return { namedRequests, sequences };
1226
+ }
1227
+ const documentDir = path.dirname(document.uri.fsPath);
1228
+ const visitedPaths = new Set([document.uri.fsPath]);
1229
+ const collectFromFile = (absolutePath) => {
1230
+ if (visitedPaths.has(absolutePath)) {
1231
+ return;
1232
+ }
1233
+ visitedPaths.add(absolutePath);
1234
+ let importedContent;
1235
+ try {
1236
+ importedContent = fs.readFileSync(absolutePath, 'utf8');
1237
+ }
1238
+ catch {
1239
+ return;
1240
+ }
1241
+ const fileNamedRequests = (0, parser_1.extractNamedRequests)(importedContent);
1242
+ for (const request of fileNamedRequests) {
1243
+ namedRequests.push({ request, sourcePath: absolutePath });
1244
+ }
1245
+ const fileSequences = (0, sequenceRunner_1.extractSequences)(importedContent);
1246
+ for (const sequence of fileSequences) {
1247
+ sequences.push({ sequence, sourcePath: absolutePath });
1248
+ }
1249
+ const nestedImports = (0, parser_1.extractImports)(importedContent);
1250
+ const importDir = path.dirname(absolutePath);
1251
+ for (const nestedImport of nestedImports) {
1252
+ if (nestedImport.path.endsWith('.nornapi') || nestedImport.path.endsWith('.nornsql')) {
1253
+ continue;
1254
+ }
1255
+ collectFromFile(path.resolve(importDir, nestedImport.path));
1256
+ }
1257
+ };
1258
+ for (const imp of imports) {
1259
+ if (imp.path.endsWith('.nornapi') || imp.path.endsWith('.nornsql')) {
1260
+ continue;
1261
+ }
1262
+ collectFromFile(path.resolve(documentDir, imp.path));
1263
+ }
1264
+ return { namedRequests, sequences };
1265
+ }
1266
+ getImportedSqlDefinitions(document) {
1267
+ const imports = (0, parser_1.extractImports)(document.getText());
1268
+ const operations = [];
1269
+ const documentDir = path.dirname(document.uri.fsPath);
1270
+ const seenPaths = new Set();
1271
+ for (const imp of imports) {
1272
+ if (!imp.path.endsWith('.nornsql')) {
1273
+ continue;
1274
+ }
1275
+ const absolutePath = path.resolve(documentDir, imp.path);
1276
+ if (seenPaths.has(absolutePath)) {
1277
+ continue;
1278
+ }
1279
+ seenPaths.add(absolutePath);
1280
+ try {
1281
+ const content = fs.readFileSync(absolutePath, 'utf8');
1282
+ const parsed = (0, nornSqlParser_1.parseNornSqlFile)(content, absolutePath);
1283
+ for (const operation of parsed.operations) {
1284
+ operations.push({ operation, sourcePath: absolutePath });
1285
+ }
1286
+ }
1287
+ catch {
1288
+ // Ignore unreadable files in completion flow.
1289
+ }
1290
+ }
1291
+ return { operations };
1292
+ }
1293
+ getNornsqlCompletions(document, position, linePrefix) {
1294
+ const items = [];
1295
+ const trimmed = linePrefix.trim().toLowerCase();
1296
+ const line = document.lineAt(position).text;
1297
+ const beforeCursor = line.substring(0, position.character);
1298
+ const blockStarts = document.getText(new vscode.Range(new vscode.Position(0, 0), position));
1299
+ const queryStarts = (blockStarts.match(/^\s*query\b/gm) || []).length;
1300
+ const commandStarts = (blockStarts.match(/^\s*command\b/gm) || []).length;
1301
+ const queryEnds = (blockStarts.match(/^\s*end\s+query\s*$/gm) || []).length;
1302
+ const commandEnds = (blockStarts.match(/^\s*end\s+command\s*$/gm) || []).length;
1303
+ const insideQueryBody = queryStarts > queryEnds;
1304
+ const insideCommandBody = commandStarts > commandEnds;
1305
+ if (insideQueryBody || insideCommandBody) {
1306
+ return [];
1307
+ }
1308
+ const keywordCandidates = [
1309
+ { label: 'connection', insertText: 'connection ', detail: 'Declare the connection alias for this file' },
1310
+ { label: 'query', insertText: new vscode.SnippetString('query $1\n$0\nend query'), detail: 'Define a row-returning SQL operation' },
1311
+ { label: 'command', insertText: new vscode.SnippetString('command $1\n$0\nend command'), detail: 'Define a write/non-row SQL operation' },
1312
+ { label: 'end query', insertText: 'end query', detail: 'End a query block' },
1313
+ { label: 'end command', insertText: 'end command', detail: 'End a command block' }
1314
+ ];
1315
+ for (const candidate of keywordCandidates) {
1316
+ if (trimmed && !candidate.label.startsWith(trimmed)) {
1317
+ continue;
1318
+ }
1319
+ items.push(this.createCompletionItem(candidate.label, vscode.CompletionItemKind.Keyword, {
1320
+ insertText: candidate.insertText,
1321
+ detail: candidate.detail
1322
+ }));
1323
+ }
1324
+ // Parameter-name suggestions while typing query/command headers.
1325
+ const headerMatch = beforeCursor.match(/^\s*(query|command)\s+[A-Za-z_][A-Za-z0-9_-]*\(([^)]*)$/i);
1326
+ if (headerMatch) {
1327
+ const existing = new Set(headerMatch[2]
1328
+ .split(',')
1329
+ .map(part => part.trim())
1330
+ .filter(Boolean)
1331
+ .map(name => name.toLowerCase()));
1332
+ const variables = {
1333
+ ...(0, environmentProvider_1.getEnvironmentVariables)(document.uri.fsPath),
1334
+ ...(0, parser_1.extractVariables)(document.getText())
1335
+ };
1336
+ for (const name of Object.keys(variables)) {
1337
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || existing.has(name.toLowerCase())) {
1338
+ continue;
1339
+ }
1340
+ items.push(this.createCompletionItem(name, vscode.CompletionItemKind.Variable, {
1341
+ insertText: name,
1342
+ detail: 'Parameter name'
1343
+ }));
1344
+ }
1345
+ }
1346
+ return items;
1347
+ }
1348
+ getNornConfigCompletions(document, position, linePrefix) {
1349
+ const items = [];
1350
+ const line = document.lineAt(position).text;
1351
+ const beforeCursor = line.substring(0, position.character);
1352
+ if (/"adapter"\s*:\s*"[^"]*$/i.test(beforeCursor)) {
1353
+ for (const adapter of (0, sqlBuiltInAdapters_1.getBuiltInSqlAdapters)()) {
1354
+ const optionalConnectionText = adapter.optionalConnectionKeys.length > 0
1355
+ ? `Optional connection values: \`${adapter.optionalConnectionKeys.join('`, `')}\`\n\n`
1356
+ : '';
1357
+ items.push(this.createValueCompletion(adapter.id, `${adapter.label} built-in adapter`, `0_${adapter.id}`, new vscode.MarkdownString(`${adapter.description}\n\n` +
1358
+ `Connection setup: \`${adapter.connectionSetupSummary}\`\n\n` +
1359
+ optionalConnectionText +
1360
+ `Built-in adapters do not need custom entries in \`${nornConfig_1.NORN_CONFIG_FILENAME}\` \`sql.adapters\`.`)));
1361
+ }
1362
+ try {
1363
+ const adapterConfig = (0, sqlConfig_1.loadNornSqlAdaptersConfig)(document.uri.fsPath).config;
1364
+ for (const adapterId of Object.keys(adapterConfig.adapters).sort()) {
1365
+ items.push(this.createValueCompletion(adapterId, `Custom adapter from ${nornConfig_1.NORN_CONFIG_FILENAME}`, `1_${adapterId}`, new vscode.MarkdownString(`Resolved from \`${nornConfig_1.NORN_CONFIG_FILENAME}\` \`sql.adapters\`.`)));
1366
+ }
1367
+ }
1368
+ catch {
1369
+ // Ignore incomplete config files in completion flow.
1370
+ }
1371
+ return items;
1372
+ }
1373
+ if (/"profile"\s*:\s*"[^"]*$/i.test(beforeCursor)) {
1374
+ const environmentVariables = (0, environmentProvider_1.getEnvironmentVariables)(document.uri.fsPath);
1375
+ const profileNames = new Set();
1376
+ for (const key of Object.keys(environmentVariables)) {
1377
+ const separatorIndex = key.indexOf('_');
1378
+ if (separatorIndex <= 0) {
1379
+ continue;
1380
+ }
1381
+ profileNames.add(key.slice(0, separatorIndex));
1382
+ }
1383
+ for (const profileName of Array.from(profileNames).sort()) {
1384
+ items.push(this.createValueCompletion(profileName, 'Profile inferred from .nornenv keys'));
1385
+ }
1386
+ return items;
1387
+ }
1388
+ if (/"command"\s*:\s*\[[^\]]*$/i.test(linePrefix)) {
1389
+ items.push(this.createCompletionItem('custom adapter command', vscode.CompletionItemKind.Snippet, {
1390
+ insertText: new vscode.SnippetString('"node", "./tools/adapters/${1:my-adapter}.js"'),
1391
+ range: this.getJsonQuotedTokenRange(line, position),
1392
+ detail: 'Command array for a custom adapter',
1393
+ documentation: new vscode.MarkdownString(`Custom adapters live in \`${nornConfig_1.NORN_CONFIG_FILENAME}\` under \`sql.adapters\`. Built-in adapters like \`postgres\`, \`sqlserver\`, and \`sqlserver-windows\` do not need entries there.`)
1394
+ }));
1395
+ }
1396
+ const propertyContext = this.getJsonPropertyCompletionContext(line, position);
1397
+ if (propertyContext) {
1398
+ items.push(this.createJsonPropertyCompletion('version', 'version property', '"version": 1', 'Config version', propertyContext));
1399
+ items.push(this.createJsonPropertyCompletion('sql', 'SQL section definition', '"sql": {\n "connections": {\n "${1:appDb}": {\n "adapter": "${2:postgres}",\n "profile": "${3:appDb}"\n }\n },\n "adapters": {\n "${4:custom-adapter}": {\n "command": ["${5:node}", "${6:./tools/adapters/my-adapter.js}"]\n }\n }\n}', 'Define SQL connections and custom adapters', propertyContext));
1400
+ items.push(this.createJsonPropertyCompletion('mcp', 'MCP section definition', '"mcp": {\n "servers": {\n "${1:localTools}": {\n "transport": "stdio",\n "command": ["${2:node}", "${3:./tools/mcp-server.js}"]\n },\n "${4:remoteTools}": {\n "transport": "http",\n "url": "${5:https://mcp.example.com/mcp}",\n "headers": {\n "Authorization": "Bearer {{$env.${6:mcpToken}}}"\n },\n "timeoutMs": ${7:5000}\n }\n }\n}', 'Define MCP server aliases', propertyContext));
1401
+ }
1402
+ return items;
1403
+ }
1404
+ getJsonPropertyCompletionContext(line, position) {
1405
+ const beforeCursor = line.substring(0, position.character);
1406
+ const match = beforeCursor.match(/"?[A-Za-z0-9_-]*$/);
1407
+ const typedToken = match?.[0] ?? '';
1408
+ const prefixBeforeToken = beforeCursor.substring(0, position.character - typedToken.length).trim();
1409
+ if (prefixBeforeToken.length > 0 && prefixBeforeToken !== ',' && prefixBeforeToken !== '{') {
1410
+ return undefined;
1411
+ }
1412
+ const rangeEnd = typedToken.startsWith('"') && line[position.character] === '"'
1413
+ ? position.character + 1
1414
+ : position.character;
1415
+ return {
1416
+ range: new vscode.Range(position.line, position.character - typedToken.length, position.line, rangeEnd),
1417
+ typedToken
1418
+ };
1419
+ }
1420
+ getJsonQuotedTokenRange(line, position) {
1421
+ const beforeCursor = line.substring(0, position.character);
1422
+ const match = beforeCursor.match(/"?[A-Za-z0-9_-]*$/);
1423
+ const token = match?.[0] ?? '';
1424
+ if (!token.startsWith('"')) {
1425
+ return undefined;
1426
+ }
1427
+ const rangeEnd = line[position.character] === '"'
1428
+ ? position.character + 1
1429
+ : position.character;
1430
+ return new vscode.Range(position.line, position.character - token.length, position.line, rangeEnd);
1431
+ }
1432
+ createValueCompletion(label, detail, sortText, documentation) {
1433
+ return this.createCompletionItem(label, vscode.CompletionItemKind.Value, {
1434
+ insertText: label,
1435
+ detail,
1436
+ sortText,
1437
+ documentation
1438
+ });
1439
+ }
1440
+ createJsonPropertyCompletion(propertyName, label, snippetText, detail, context) {
1441
+ const isQuotedCompletion = context.typedToken.startsWith('"');
1442
+ return this.createCompletionItem(label, vscode.CompletionItemKind.Snippet, {
1443
+ insertText: new vscode.SnippetString(snippetText),
1444
+ range: context.range,
1445
+ filterText: isQuotedCompletion ? `"${propertyName}"` : propertyName,
1446
+ sortText: `000_${propertyName}`,
1447
+ detail
1448
+ });
1449
+ }
1450
+ createCompletionItem(label, kind, options = {}) {
1451
+ const item = new vscode.CompletionItem(label, kind);
1452
+ if (options.insertText !== undefined) {
1453
+ item.insertText = options.insertText;
1454
+ }
1455
+ if (options.detail !== undefined) {
1456
+ item.detail = options.detail;
1457
+ }
1458
+ if (options.documentation !== undefined) {
1459
+ item.documentation = options.documentation;
1460
+ }
1461
+ if (options.sortText !== undefined) {
1462
+ item.sortText = options.sortText;
1463
+ }
1464
+ if (options.filterText !== undefined) {
1465
+ item.filterText = options.filterText;
1466
+ }
1467
+ if (options.range !== undefined) {
1468
+ item.range = options.range;
1469
+ }
1470
+ if (options.command !== undefined) {
1471
+ item.command = options.command;
1472
+ }
1473
+ if (options.preselect !== undefined) {
1474
+ item.preselect = options.preselect;
1475
+ }
1476
+ return item;
1477
+ }
1478
+ createTriggerSuggestCommand(title = 'Trigger Suggest') {
1479
+ return {
1480
+ command: TRIGGER_SUGGEST_COMMAND,
1481
+ title
1482
+ };
1483
+ }
1484
+ /**
1485
+ * Provide completions for .nornapi files
1486
+ */
1487
+ createEndBlockCompletion(label, detail) {
1488
+ return this.createCompletionItem(label, vscode.CompletionItemKind.Keyword, {
1489
+ detail,
1490
+ sortText: '0_end'
1491
+ });
1492
+ }
1493
+ getNornapiBlockContext(textBeforeCurrentLine) {
1494
+ let currentBlock;
1495
+ for (const line of textBeforeCurrentLine.split(/\r?\n/)) {
1496
+ const trimmed = line.trim().toLowerCase();
1497
+ if (!trimmed || trimmed.startsWith('#')) {
1498
+ continue;
1499
+ }
1500
+ if (/^end\s+headers\b/.test(trimmed)) {
1501
+ if (currentBlock === 'headers') {
1502
+ currentBlock = undefined;
1503
+ }
1504
+ continue;
1505
+ }
1506
+ if (/^end\s+endpoints\b/.test(trimmed)) {
1507
+ if (currentBlock === 'endpoints') {
1508
+ currentBlock = undefined;
1509
+ }
1510
+ continue;
1511
+ }
1512
+ if (/^headers\b/.test(trimmed)) {
1513
+ currentBlock = 'headers';
1514
+ continue;
1515
+ }
1516
+ if (/^endpoints\b/.test(trimmed)) {
1517
+ currentBlock = 'endpoints';
1518
+ }
1519
+ }
1520
+ return currentBlock;
1521
+ }
1522
+ getNornapiCompletions(document, position, linePrefix, lineSuffix) {
1523
+ const trimmed = linePrefix.trim().toLowerCase();
1524
+ // Check if typing a variable reference ({{)
1525
+ if (this.isTypingVariable(linePrefix)) {
1526
+ return this.getVariableCompletions(document, position, linePrefix, lineSuffix);
1527
+ }
1528
+ // Determine context based on where we are in the file
1529
+ const textBeforeCurrentLine = document.getText(new vscode.Range(new vscode.Position(0, 0), new vscode.Position(position.line, 0)));
1530
+ const nornapiBlockContext = this.getNornapiBlockContext(textBeforeCurrentLine);
1531
+ const inHeadersBlock = nornapiBlockContext === 'headers';
1532
+ const inEndpointsBlock = nornapiBlockContext === 'endpoints';
1533
+ const items = [];
1534
+ // Inside headers block - suggest common header names or header values
1535
+ if (inHeadersBlock) {
1536
+ // Check if typing a header value for Content-Type
1537
+ if (linePrefix.toLowerCase().includes('content-type:')) {
1538
+ return this.getContentTypeCompletions();
1539
+ }
1540
+ // Check if typing "end"
1541
+ if ('end headers'.startsWith(trimmed) || trimmed === 'end' || trimmed === 'end ') {
1542
+ items.push(this.createEndBlockCompletion('end headers', 'Close the headers block'));
1543
+ }
1544
+ // Suggest common headers
1545
+ for (const header of this.commonHeaders) {
1546
+ if (!trimmed || header.toLowerCase().startsWith(trimmed)) {
1547
+ items.push(this.createCompletionItem(header, vscode.CompletionItemKind.Field, {
1548
+ insertText: header + ': ',
1549
+ detail: 'HTTP header',
1550
+ // Trigger IntelliSense again after inserting the header (for Content-Type values, etc.)
1551
+ command: this.createTriggerSuggestCommand()
1552
+ }));
1553
+ }
1554
+ }
1555
+ return items;
1556
+ }
1557
+ // Inside endpoints block - suggest HTTP methods and "end endpoints"
1558
+ if (inEndpointsBlock) {
1559
+ // Check if typing "end"
1560
+ if ('end endpoints'.startsWith(trimmed) || trimmed === 'end' || trimmed === 'end ') {
1561
+ items.push(this.createEndBlockCompletion('end endpoints', 'Close the endpoints block'));
1562
+ }
1563
+ // Suggest endpoint definition pattern
1564
+ if (!trimmed || /^[a-z]/i.test(trimmed)) {
1565
+ items.push(this.createCompletionItem('EndpointName: GET', vscode.CompletionItemKind.Snippet, {
1566
+ insertText: new vscode.SnippetString('${1:EndpointName}: ${2|GET,POST,PUT,DELETE,PATCH|} ${3:url}'),
1567
+ detail: 'Define an API endpoint',
1568
+ documentation: 'Create a new endpoint definition.\n\nExample: `GetUser: GET /users/{id}`'
1569
+ }));
1570
+ }
1571
+ return items;
1572
+ }
1573
+ // At top level - suggest "headers" and "endpoints" keywords
1574
+ if (!trimmed || 'headers'.startsWith(trimmed)) {
1575
+ items.push(this.createCompletionItem('headers', vscode.CompletionItemKind.Keyword, {
1576
+ insertText: new vscode.SnippetString('headers ${1:GroupName}\n$0\nend headers'),
1577
+ detail: 'Define a header group',
1578
+ documentation: 'Create a reusable group of headers.\n\nExample:\n```\nheaders Auth\nAuthorization: Bearer {{token}}\nend headers\n```'
1579
+ }));
1580
+ }
1581
+ if (!trimmed || 'endpoints'.startsWith(trimmed)) {
1582
+ items.push(this.createCompletionItem('endpoints', vscode.CompletionItemKind.Keyword, {
1583
+ insertText: new vscode.SnippetString('endpoints\n${1:EndpointName}: ${2|GET,POST,PUT,DELETE,PATCH|} ${3:url}\nend endpoints'),
1584
+ detail: 'Define API endpoints',
1585
+ documentation: 'Create a block of endpoint definitions.\n\nExample:\n```\nendpoints\nGetUser: GET /users/{id}\nCreateUser: POST /users\nend endpoints\n```'
1586
+ }));
1587
+ }
1588
+ if (!trimmed || 'swagger'.startsWith(trimmed)) {
1589
+ items.push(this.createCompletionItem('swagger', vscode.CompletionItemKind.Keyword, {
1590
+ insertText: new vscode.SnippetString('swagger "$0"'),
1591
+ detail: 'Import from OpenAPI/Swagger spec',
1592
+ documentation: new vscode.MarkdownString('Import endpoints from an OpenAPI/Swagger specification URL.\n\n' +
1593
+ '**Usage:**\n```norn\nswagger https://petstore.swagger.io/v2/swagger.json\n```\n\n' +
1594
+ 'Click the ▶ button to parse the spec and generate endpoints.\n\n' +
1595
+ 'Supports OpenAPI 2.0 (Swagger) and OpenAPI 3.x specifications.')
1596
+ }));
1597
+ }
1598
+ return items;
1599
+ }
1600
+ /**
1601
+ * Load API definitions (header groups and endpoints) from imported .nornapi files.
1602
+ */
1603
+ getApiDefinitionsFromImports(document) {
1604
+ const apiMetadata = this.getApiImportMetadata(document);
1605
+ return {
1606
+ headerGroups: apiMetadata.headerGroups,
1607
+ endpoints: apiMetadata.endpoints
1608
+ };
1609
+ }
1610
+ /**
1611
+ * Load imported .nornapi files with endpoint/header data and swagger URL metadata.
1612
+ */
1613
+ getApiImportMetadata(document) {
1614
+ const fullText = document.getText();
1615
+ const imports = (0, parser_1.extractImports)(fullText);
1616
+ const headerGroups = [];
1617
+ const endpoints = [];
1618
+ const apiFiles = [];
1619
+ if (imports.length === 0) {
1620
+ return { headerGroups, endpoints, apiFiles };
1621
+ }
1622
+ const documentDir = path.dirname(document.uri.fsPath);
1623
+ for (const imp of imports) {
1624
+ // Only process .nornapi files
1625
+ if (!imp.path.endsWith('.nornapi')) {
1626
+ continue;
1627
+ }
1628
+ try {
1629
+ const importPath = path.resolve(documentDir, imp.path);
1630
+ const importedContent = fs.readFileSync(importPath, 'utf8');
1631
+ const apiDef = (0, nornapiParser_1.parseNornApiFile)(importedContent);
1632
+ headerGroups.push(...apiDef.headerGroups);
1633
+ endpoints.push(...apiDef.endpoints);
1634
+ apiFiles.push({
1635
+ sourcePath: importPath,
1636
+ headerGroups: apiDef.headerGroups,
1637
+ endpoints: apiDef.endpoints,
1638
+ swaggerUrls: this.extractSwaggerUrlsFromNornapi(importedContent)
1639
+ });
1640
+ }
1641
+ catch {
1642
+ // Ignore import errors
1643
+ }
1644
+ }
1645
+ return { headerGroups, endpoints, apiFiles };
1646
+ }
1647
+ extractSwaggerUrlsFromNornapi(content) {
1648
+ const urls = new Set();
1649
+ const lines = content.split('\n');
1650
+ for (const line of lines) {
1651
+ const trimmed = line.trim();
1652
+ if (!trimmed || trimmed.startsWith('#')) {
1653
+ continue;
1654
+ }
1655
+ const quotedMatch = trimmed.match(/^swagger\s+["']([^"']+)["']\s*$/i);
1656
+ if (quotedMatch) {
1657
+ urls.add(quotedMatch[1]);
1658
+ continue;
1659
+ }
1660
+ const unquotedMatch = trimmed.match(/^swagger\s+(https?:\/\/\S+)\s*$/i);
1661
+ if (unquotedMatch) {
1662
+ urls.add(unquotedMatch[1]);
1663
+ }
1664
+ }
1665
+ return Array.from(urls);
1666
+ }
1667
+ getRequestBodyCompletionContext(document, position) {
1668
+ const apiMetadata = this.getApiImportMetadata(document);
1669
+ if (apiMetadata.endpoints.length === 0 || apiMetadata.apiFiles.length === 0) {
1670
+ return undefined;
1671
+ }
1672
+ const endpointSchemaMap = this.getEndpointRequestBodySchemaMap(apiMetadata);
1673
+ if (endpointSchemaMap.size === 0) {
1674
+ return undefined;
1675
+ }
1676
+ const headerGroupNames = new Set(apiMetadata.headerGroups.map(group => group.name));
1677
+ for (let lineNum = position.line; lineNum >= 0 && lineNum >= position.line - 80; lineNum--) {
1678
+ const trimmed = document.lineAt(lineNum).text.trim();
1679
+ if (!trimmed || trimmed.startsWith('#')) {
1680
+ continue;
1681
+ }
1682
+ const requestMatch = trimmed.match(/^(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+([a-zA-Z_][a-zA-Z0-9_]*)(?:\([^)]*\))?(?:\s+.*)?$/i);
1683
+ if (requestMatch) {
1684
+ const method = requestMatch[1].toUpperCase();
1685
+ const endpointName = requestMatch[2];
1686
+ if (!this.isRequestBodyMethod(method)) {
1687
+ return undefined;
1688
+ }
1689
+ const mapped = endpointSchemaMap.get(endpointName);
1690
+ if (!mapped) {
1691
+ return undefined;
1692
+ }
1693
+ if (mapped.endpoint.method.toUpperCase() !== method) {
1694
+ return undefined;
1695
+ }
1696
+ const bodyStartLine = this.findRequestBodyStartLine(document, lineNum, position.line, headerGroupNames);
1697
+ if (bodyStartLine === undefined || position.line < bodyStartLine) {
1698
+ return undefined;
1699
+ }
1700
+ return {
1701
+ method,
1702
+ endpointName,
1703
+ endpoint: mapped.endpoint,
1704
+ schema: mapped.schema,
1705
+ bodyStartLine
1706
+ };
1707
+ }
1708
+ if (lineNum !== position.line && this.isBoundaryCommandLine(trimmed)) {
1709
+ return undefined;
1710
+ }
1711
+ }
1712
+ return undefined;
1713
+ }
1714
+ getEndpointRequestBodySchemaMap(apiMetadata) {
1715
+ const endpointSchemaMap = new Map();
1716
+ for (const apiFile of apiMetadata.apiFiles) {
1717
+ if (apiFile.swaggerUrls.length === 0 || apiFile.endpoints.length === 0) {
1718
+ continue;
1719
+ }
1720
+ const cacheEntries = apiFile.swaggerUrls
1721
+ .map(url => (0, swaggerBodyIntellisenseCache_1.getCachedRequestBodySchemasForUrl)(url))
1722
+ .filter((entry) => !!entry);
1723
+ if (cacheEntries.length === 0) {
1724
+ continue;
1725
+ }
1726
+ for (const endpoint of apiFile.endpoints) {
1727
+ if (!this.isRequestBodyMethod(endpoint.method) || endpointSchemaMap.has(endpoint.name)) {
1728
+ continue;
1729
+ }
1730
+ const method = endpoint.method.toUpperCase();
1731
+ for (const cacheEntry of cacheEntries) {
1732
+ const normalizedPath = this.normalizeEndpointPathForSwagger(endpoint.path, cacheEntry.baseUrl);
1733
+ if (!normalizedPath) {
1734
+ continue;
1735
+ }
1736
+ const matchedSchema = cacheEntry.schemas.find(schema => schema.method.toUpperCase() === method &&
1737
+ schema.path === normalizedPath);
1738
+ if (!matchedSchema) {
1739
+ continue;
1740
+ }
1741
+ endpointSchemaMap.set(endpoint.name, {
1742
+ endpoint,
1743
+ schema: matchedSchema
1744
+ });
1745
+ break;
1746
+ }
1747
+ }
1748
+ }
1749
+ return endpointSchemaMap;
1750
+ }
1751
+ normalizeEndpointPathForSwagger(endpointPath, baseUrl) {
1752
+ const rawPath = endpointPath.trim();
1753
+ if (!rawPath) {
1754
+ return undefined;
1755
+ }
1756
+ const getBasePath = () => {
1757
+ if (!baseUrl) {
1758
+ return '';
1759
+ }
1760
+ try {
1761
+ const parsed = new URL(baseUrl);
1762
+ return parsed.pathname.replace(/\/$/, '');
1763
+ }
1764
+ catch {
1765
+ return '';
1766
+ }
1767
+ };
1768
+ const basePath = getBasePath();
1769
+ const normalizeAndStripBasePath = (candidatePath) => {
1770
+ const withoutQuery = candidatePath.split(/[?#]/)[0];
1771
+ let normalized = withoutQuery.startsWith('/') ? withoutQuery : `/${withoutQuery}`;
1772
+ if (basePath) {
1773
+ if (normalized === basePath) {
1774
+ return '/';
1775
+ }
1776
+ if (normalized.startsWith(`${basePath}/`)) {
1777
+ normalized = normalized.slice(basePath.length);
1778
+ }
1779
+ }
1780
+ return normalized || '/';
1781
+ };
1782
+ // Handle variable-prefix paths: {{baseUrl}}/users, {{apiRoot}}/v2/users, etc.
1783
+ const variablePrefixMatch = rawPath.match(/^\{\{[^}]+\}\}(.*)$/);
1784
+ if (variablePrefixMatch) {
1785
+ const suffix = variablePrefixMatch[1] || '';
1786
+ return normalizeAndStripBasePath(suffix || '/');
1787
+ }
1788
+ // Most imported endpoints are generated as full URLs, so strip baseUrl first when possible.
1789
+ if (baseUrl && rawPath.startsWith(baseUrl)) {
1790
+ const stripped = rawPath.slice(baseUrl.length);
1791
+ return normalizeAndStripBasePath(stripped || '/');
1792
+ }
1793
+ if (/^https?:\/\//i.test(rawPath)) {
1794
+ try {
1795
+ const endpointUrl = new URL(rawPath);
1796
+ if (baseUrl) {
1797
+ try {
1798
+ const parsedBaseUrl = new URL(baseUrl, endpointUrl.origin);
1799
+ const basePath = parsedBaseUrl.pathname.replace(/\/$/, '');
1800
+ // Compare by host to tolerate scheme differences (http vs https).
1801
+ if (endpointUrl.hostname === parsedBaseUrl.hostname) {
1802
+ if (basePath && endpointUrl.pathname.startsWith(`${basePath}/`)) {
1803
+ return endpointUrl.pathname.slice(basePath.length);
1804
+ }
1805
+ if (basePath && endpointUrl.pathname === basePath) {
1806
+ return '/';
1807
+ }
1808
+ }
1809
+ }
1810
+ catch {
1811
+ // Ignore malformed base URL values (e.g., templated server URLs)
1812
+ }
1813
+ }
1814
+ return normalizeAndStripBasePath(endpointUrl.pathname || '/');
1815
+ }
1816
+ catch {
1817
+ return undefined;
1818
+ }
1819
+ }
1820
+ return normalizeAndStripBasePath(rawPath);
1821
+ }
1822
+ findRequestBodyStartLine(document, requestLine, currentLine, headerGroupNames) {
1823
+ for (let lineNum = requestLine + 1; lineNum <= currentLine; lineNum++) {
1824
+ const trimmed = document.lineAt(lineNum).text.trim();
1825
+ if (!trimmed || trimmed.startsWith('#')) {
1826
+ continue;
1827
+ }
1828
+ if (headerGroupNames.has(trimmed)) {
1829
+ continue;
1830
+ }
1831
+ if (this.isInlineHeaderLine(trimmed)) {
1832
+ continue;
1833
+ }
1834
+ return lineNum;
1835
+ }
1836
+ return undefined;
1837
+ }
1838
+ isInlineHeaderLine(trimmedLine) {
1839
+ return /^[A-Za-z][A-Za-z0-9-]*:\s*.+$/.test(trimmedLine);
1840
+ }
1841
+ isBoundaryCommandLine(trimmedLine) {
1842
+ return /^(###|\[|(?:test\s+)?sequence\b|end\s+sequence\b|end\s+if\b|endif\b|var\s+|run\s+|print\s+|assert\s+|if\s+|wait\s+|import\s+|return\s+|swagger\b|(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+)/i.test(trimmedLine);
1843
+ }
1844
+ isRequestBodyMethod(method) {
1845
+ return method === 'POST' || method === 'PUT' || method === 'PATCH';
1846
+ }
1847
+ getRequestBodyTemplateCompletions(context, position, lineText) {
1848
+ const templateValue = this.buildTemplateValueFromSchema(context.schema.schema);
1849
+ const rawTemplate = JSON.stringify(templateValue, null, 4);
1850
+ if (!rawTemplate) {
1851
+ return [];
1852
+ }
1853
+ const indentation = lineText.match(/^\s*/)[0];
1854
+ const indentedTemplate = rawTemplate
1855
+ .split('\n')
1856
+ .map(line => indentation + line)
1857
+ .join('\n');
1858
+ const replaceRange = new vscode.Range(new vscode.Position(position.line, 0), new vscode.Position(position.line, lineText.length));
1859
+ const triggerChar = lineText.trimStart().startsWith('[') ? '[' : '{';
1860
+ return [this.createCompletionItem('Insert request body template', vscode.CompletionItemKind.Snippet, {
1861
+ insertText: indentedTemplate,
1862
+ range: replaceRange,
1863
+ filterText: triggerChar,
1864
+ preselect: true,
1865
+ detail: `${context.method} ${context.endpointName}`,
1866
+ documentation: new vscode.MarkdownString(`Insert a request body skeleton for \`${context.endpointName}\` from cached Swagger schema.\n\n` +
1867
+ 'Template includes required fields only.'),
1868
+ sortText: '0_request_body_template'
1869
+ })];
1870
+ }
1871
+ buildTemplateValueFromSchema(schema) {
1872
+ const normalizedSchema = this.normalizeSchemaForCompletions(schema);
1873
+ const value = this.buildDefaultSchemaValue(normalizedSchema, true);
1874
+ return value === undefined ? {} : value;
1875
+ }
1876
+ buildDefaultSchemaValue(schema, requiredOnly) {
1877
+ const normalizedSchema = this.normalizeSchemaForCompletions(schema);
1878
+ if (!normalizedSchema || typeof normalizedSchema !== 'object') {
1879
+ return null;
1880
+ }
1881
+ if (normalizedSchema.default !== undefined) {
1882
+ return normalizedSchema.default;
1883
+ }
1884
+ if (Array.isArray(normalizedSchema.enum) && normalizedSchema.enum.length > 0) {
1885
+ return normalizedSchema.enum[0];
1886
+ }
1887
+ if (normalizedSchema.type === 'object' || normalizedSchema.properties) {
1888
+ const properties = normalizedSchema.properties;
1889
+ if (!properties || Object.keys(properties).length === 0) {
1890
+ return {};
1891
+ }
1892
+ const required = Array.isArray(normalizedSchema.required)
1893
+ ? normalizedSchema.required.filter((name) => typeof name === 'string')
1894
+ : [];
1895
+ const keys = requiredOnly
1896
+ ? (required.length > 0 ? required : Object.keys(properties))
1897
+ : Object.keys(properties);
1898
+ const result = {};
1899
+ for (const key of keys) {
1900
+ result[key] = this.buildDefaultSchemaValue(properties[key], requiredOnly);
1901
+ }
1902
+ return result;
1903
+ }
1904
+ if (normalizedSchema.type === 'array' || normalizedSchema.items) {
1905
+ return [];
1906
+ }
1907
+ switch (normalizedSchema.type) {
1908
+ case 'number':
1909
+ case 'integer':
1910
+ return 0;
1911
+ case 'boolean':
1912
+ return false;
1913
+ case 'string':
1914
+ return '';
1915
+ case 'null':
1916
+ return null;
1917
+ default:
1918
+ return null;
1919
+ }
1920
+ }
1921
+ getInlineRequestBodyKeyCompletions(context, document, position, linePrefix) {
1922
+ if (!this.isTypingJsonObjectKey(linePrefix)) {
1923
+ return [];
1924
+ }
1925
+ const bodyLinesBeforeCursor = [];
1926
+ for (let lineNum = context.bodyStartLine; lineNum < position.line; lineNum++) {
1927
+ bodyLinesBeforeCursor.push(document.lineAt(lineNum).text);
1928
+ }
1929
+ const jsonContext = this.parseJsonObjectContext(bodyLinesBeforeCursor);
1930
+ const schemaNode = this.getSchemaNodeForPath(context.schema.schema, jsonContext.path);
1931
+ const normalizedSchema = this.normalizeSchemaForCompletions(schemaNode);
1932
+ const properties = normalizedSchema?.properties;
1933
+ if (!properties || Object.keys(properties).length === 0) {
1934
+ return [];
1935
+ }
1936
+ const required = new Set(Array.isArray(normalizedSchema.required)
1937
+ ? normalizedSchema.required.filter((name) => typeof name === 'string')
1938
+ : []);
1939
+ const replaceTokenMatch = linePrefix.match(/"?[a-zA-Z0-9_-]*$/);
1940
+ const replaceToken = replaceTokenMatch ? replaceTokenMatch[0] : '';
1941
+ const partialMatch = linePrefix.match(/"?([a-zA-Z_][a-zA-Z0-9_-]*)?$/);
1942
+ const partial = partialMatch?.[1]?.toLowerCase() || '';
1943
+ const replaceStart = Math.max(0, position.character - replaceToken.length);
1944
+ const replaceRange = new vscode.Range(new vscode.Position(position.line, replaceStart), position);
1945
+ const items = [];
1946
+ for (const [propertyName, propertySchema] of Object.entries(properties)) {
1947
+ if (jsonContext.existingKeys.has(propertyName)) {
1948
+ continue;
1949
+ }
1950
+ if (partial && !propertyName.toLowerCase().startsWith(partial)) {
1951
+ continue;
1952
+ }
1953
+ const detail = required.has(propertyName)
1954
+ ? `${this.getSchemaTypeDescription(propertySchema)} (required)`
1955
+ : this.getSchemaTypeDescription(propertySchema);
1956
+ items.push(this.createCompletionItem(propertyName, vscode.CompletionItemKind.Property, {
1957
+ range: replaceRange,
1958
+ insertText: `"${propertyName}": `,
1959
+ detail,
1960
+ documentation: new vscode.MarkdownString(`Schema property for \`${context.endpointName}\` request body.`),
1961
+ sortText: required.has(propertyName)
1962
+ ? `0_${propertyName}`
1963
+ : `1_${propertyName}`
1964
+ }));
1965
+ }
1966
+ return items;
1967
+ }
1968
+ isTypingJsonObjectKey(linePrefix) {
1969
+ const trimmed = linePrefix.trim();
1970
+ if (trimmed.startsWith('{') || trimmed.startsWith('[') || trimmed.startsWith('}') || trimmed.startsWith(']')) {
1971
+ return false;
1972
+ }
1973
+ if (trimmed.includes(':') || trimmed.includes(',')) {
1974
+ return false;
1975
+ }
1976
+ if (trimmed === '' || trimmed === '"') {
1977
+ return true;
1978
+ }
1979
+ return /^"?[a-zA-Z_][a-zA-Z0-9_-]*$/.test(trimmed);
1980
+ }
1981
+ parseJsonObjectContext(lines) {
1982
+ const pathStack = [];
1983
+ const keysByPath = new Map();
1984
+ const getPathKey = () => pathStack.join('.');
1985
+ const ensureKeySet = (pathKey) => {
1986
+ if (!keysByPath.has(pathKey)) {
1987
+ keysByPath.set(pathKey, new Set());
1988
+ }
1989
+ return keysByPath.get(pathKey);
1990
+ };
1991
+ for (const line of lines) {
1992
+ const trimmed = line.trim();
1993
+ if (!trimmed || trimmed.startsWith('#')) {
1994
+ continue;
1995
+ }
1996
+ const closingMatch = trimmed.match(/^[}\]]+/);
1997
+ if (closingMatch) {
1998
+ for (const _ of closingMatch[0]) {
1999
+ if (pathStack.length > 0) {
2000
+ pathStack.pop();
2001
+ }
2002
+ }
2003
+ }
2004
+ const currentPath = getPathKey();
2005
+ const addExistingKey = (key) => {
2006
+ ensureKeySet(currentPath).add(key);
2007
+ };
2008
+ const objectStartMatch = trimmed.match(/^"([^"]+)"\s*:\s*\{\s*,?\s*$/);
2009
+ if (objectStartMatch) {
2010
+ addExistingKey(objectStartMatch[1]);
2011
+ pathStack.push(objectStartMatch[1]);
2012
+ continue;
2013
+ }
2014
+ const arrayStartMatch = trimmed.match(/^"([^"]+)"\s*:\s*\[\s*,?\s*$/);
2015
+ if (arrayStartMatch) {
2016
+ addExistingKey(arrayStartMatch[1]);
2017
+ pathStack.push(arrayStartMatch[1]);
2018
+ continue;
2019
+ }
2020
+ const propertyMatch = trimmed.match(/^"([^"]+)"\s*:/);
2021
+ if (propertyMatch) {
2022
+ addExistingKey(propertyMatch[1]);
2023
+ }
2024
+ }
2025
+ const finalPath = getPathKey();
2026
+ return {
2027
+ path: [...pathStack],
2028
+ existingKeys: new Set(keysByPath.get(finalPath) || [])
2029
+ };
2030
+ }
2031
+ getSchemaNodeForPath(schema, pathSegments) {
2032
+ let currentSchema = this.normalizeSchemaForCompletions(schema);
2033
+ for (const segment of pathSegments) {
2034
+ currentSchema = this.normalizeSchemaForCompletions(currentSchema);
2035
+ if (!currentSchema || typeof currentSchema !== 'object') {
2036
+ return undefined;
2037
+ }
2038
+ const properties = currentSchema.properties;
2039
+ if (!properties || !(segment in properties)) {
2040
+ return undefined;
2041
+ }
2042
+ const propertySchema = this.normalizeSchemaForCompletions(properties[segment]);
2043
+ if (propertySchema?.type === 'array' && propertySchema.items) {
2044
+ currentSchema = this.normalizeSchemaForCompletions(propertySchema.items);
2045
+ }
2046
+ else {
2047
+ currentSchema = propertySchema;
2048
+ }
2049
+ }
2050
+ return this.normalizeSchemaForCompletions(currentSchema);
2051
+ }
2052
+ normalizeSchemaForCompletions(schema) {
2053
+ if (!schema || typeof schema !== 'object') {
2054
+ return schema;
2055
+ }
2056
+ if (Array.isArray(schema.allOf) && schema.allOf.length > 0) {
2057
+ const merged = { ...schema };
2058
+ delete merged.allOf;
2059
+ for (const part of schema.allOf) {
2060
+ const normalizedPart = this.normalizeSchemaForCompletions(part);
2061
+ if (!normalizedPart || typeof normalizedPart !== 'object') {
2062
+ continue;
2063
+ }
2064
+ if (!merged.type && normalizedPart.type) {
2065
+ merged.type = normalizedPart.type;
2066
+ }
2067
+ if (normalizedPart.properties && typeof normalizedPart.properties === 'object') {
2068
+ merged.properties = {
2069
+ ...(merged.properties || {}),
2070
+ ...normalizedPart.properties
2071
+ };
2072
+ }
2073
+ if (normalizedPart.items && !merged.items) {
2074
+ merged.items = normalizedPart.items;
2075
+ }
2076
+ if (Array.isArray(normalizedPart.required)) {
2077
+ const existing = Array.isArray(merged.required) ? merged.required : [];
2078
+ merged.required = Array.from(new Set([...existing, ...normalizedPart.required]));
2079
+ }
2080
+ }
2081
+ return merged;
2082
+ }
2083
+ if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) {
2084
+ return this.normalizeSchemaForCompletions(schema.oneOf[0]);
2085
+ }
2086
+ if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
2087
+ return this.normalizeSchemaForCompletions(schema.anyOf[0]);
2088
+ }
2089
+ return schema;
2090
+ }
2091
+ getSchemaTypeDescription(schema) {
2092
+ const normalizedSchema = this.normalizeSchemaForCompletions(schema);
2093
+ if (!normalizedSchema || typeof normalizedSchema !== 'object') {
2094
+ return 'value';
2095
+ }
2096
+ if (Array.isArray(normalizedSchema.enum) && normalizedSchema.enum.length > 0) {
2097
+ const enumPreview = normalizedSchema.enum
2098
+ .slice(0, 3)
2099
+ .map((value) => JSON.stringify(value))
2100
+ .join(', ');
2101
+ return normalizedSchema.enum.length > 3
2102
+ ? `enum (${enumPreview}, ...)`
2103
+ : `enum (${enumPreview})`;
2104
+ }
2105
+ if (typeof normalizedSchema.type === 'string') {
2106
+ return normalizedSchema.type;
2107
+ }
2108
+ if (normalizedSchema.properties) {
2109
+ return 'object';
2110
+ }
2111
+ if (normalizedSchema.items) {
2112
+ return 'array';
2113
+ }
2114
+ return 'value';
2115
+ }
2116
+ /**
2117
+ * Check if user is typing after an HTTP method (e.g., "GET " or "POST " or "var x = GET ")
2118
+ * Returns false if inside open parentheses (user is typing parameters)
2119
+ */
2120
+ isTypingAfterHttpMethod(linePrefix) {
2121
+ // Don't match if we're inside open parentheses (typing parameters)
2122
+ const openParens = (linePrefix.match(/\(/g) || []).length;
2123
+ const closeParens = (linePrefix.match(/\)/g) || []).length;
2124
+ if (openParens > closeParens) {
2125
+ return false;
2126
+ }
2127
+ // Match "METHOD " or "METHOD partial" (preserve trailing spaces)
2128
+ let match = linePrefix.match(/^\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+.*$/i);
2129
+ if (match) {
2130
+ return true;
2131
+ }
2132
+ // Also match "var x = METHOD " pattern
2133
+ match = linePrefix.match(/^\s*var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+.*$/i);
2134
+ return !!match;
2135
+ }
2136
+ /**
2137
+ * Check if user is typing after an API endpoint (for header group suggestions)
2138
+ * e.g., "GET GetUser("1") " or "GET GetAllUsers " or "var x = GET GetUser(1) "
2139
+ */
2140
+ isTypingAfterApiEndpoint(linePrefix, document) {
2141
+ // Match: METHOD EndpointName or METHOD EndpointName(params) followed by space
2142
+ // Also match: var x = METHOD EndpointName(params) followed by space
2143
+ let match = linePrefix.match(/^\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+([a-zA-Z_][a-zA-Z0-9_]*)(?:\([^)]*\))?\s+.*$/i);
2144
+ if (!match) {
2145
+ // Try var x = METHOD EndpointName(params) pattern
2146
+ 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);
2147
+ }
2148
+ if (!match) {
2149
+ return false;
2150
+ }
2151
+ const potentialEndpointName = match[2];
2152
+ // Check if this is actually an endpoint from .nornapi imports
2153
+ const apiDefs = this.getApiDefinitionsFromImports(document);
2154
+ return apiDefs.endpoints.some(ep => ep.name === potentialEndpointName);
2155
+ }
2156
+ /**
2157
+ * Check if user is typing after a URL in a var request line
2158
+ * e.g., "var result = GET "https://..." " or "var data = GET https://api.com "
2159
+ */
2160
+ isTypingAfterRequestUrl(linePrefix) {
2161
+ const trimmed = linePrefix.trim();
2162
+ // Match: var name = METHOD "url" (quoted URL with everything after closing quote)
2163
+ // or: var name = METHOD url (unquoted URL ending in space)
2164
+ 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);
2165
+ if (quotedMatch) {
2166
+ return true;
2167
+ }
2168
+ // Also match unquoted URLs: var x = GET https://url space
2169
+ 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);
2170
+ if (unquotedMatch) {
2171
+ return true;
2172
+ }
2173
+ return false;
2174
+ }
2175
+ /**
2176
+ * Get retry/backoff completions after a request URL
2177
+ */
2178
+ getRetryBackoffCompletions(linePrefix) {
2179
+ const items = [];
2180
+ const trimmed = linePrefix.trim().toLowerCase();
2181
+ // If retry is not already on the line, suggest it
2182
+ if (!trimmed.includes('retry')) {
2183
+ items.push(this.createCompletionItem('retry', vscode.CompletionItemKind.Keyword, {
2184
+ detail: 'Retry failed requests',
2185
+ documentation: 'retry N - Retry the request N times on failure (5xx, 429, network errors)',
2186
+ insertText: 'retry '
2187
+ }));
2188
+ }
2189
+ // If backoff is not already on the line, suggest it
2190
+ if (!trimmed.includes('backoff')) {
2191
+ items.push(this.createCompletionItem('backoff', vscode.CompletionItemKind.Keyword, {
2192
+ detail: 'Backoff duration between retries',
2193
+ documentation: 'backoff N ms - Wait N milliseconds between retries (linear: N * attempt)',
2194
+ insertText: 'backoff '
2195
+ }));
2196
+ }
2197
+ return items;
2198
+ }
2199
+ /**
2200
+ * Get endpoint completions after HTTP method
2201
+ */
2202
+ getEndpointCompletions(document, linePrefix) {
2203
+ const apiDefs = this.getApiDefinitionsFromImports(document);
2204
+ if (apiDefs.endpoints.length === 0) {
2205
+ return [];
2206
+ }
2207
+ // Extract what's typed after the method
2208
+ // Try "METHOD partial" first, then "var x = METHOD partial"
2209
+ let match = linePrefix.match(/^\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(.*)$/i);
2210
+ if (!match) {
2211
+ match = linePrefix.match(/^\s*var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(.*)$/i);
2212
+ }
2213
+ if (!match) {
2214
+ return [];
2215
+ }
2216
+ const methodFromLine = match[1].toUpperCase();
2217
+ const partial = (match[2] || '').trim();
2218
+ const items = [];
2219
+ for (const endpoint of apiDefs.endpoints) {
2220
+ // Only show endpoints matching the currently typed method
2221
+ if (endpoint.method !== methodFromLine) {
2222
+ continue;
2223
+ }
2224
+ // Filter by typed endpoint prefix (if any)
2225
+ if (partial && !endpoint.name.toLowerCase().startsWith(partial.toLowerCase())) {
2226
+ continue;
2227
+ }
2228
+ // Show endpoint details
2229
+ const endpointSignature = `${endpoint.method} ${endpoint.path}`;
2230
+ items.push(this.createCompletionItem(endpoint.name, vscode.CompletionItemKind.Function, {
2231
+ // Just insert the endpoint name - let user add () and arguments manually
2232
+ insertText: endpoint.name,
2233
+ detail: endpointSignature,
2234
+ documentation: new vscode.MarkdownString(`**${endpoint.name}**\n\n\`${endpointSignature}\`\n\n` +
2235
+ (endpoint.parameters.length > 0
2236
+ ? `Parameters: ${endpoint.parameters.map(p => `\`{${p}}\``).join(', ')}`
2237
+ : 'No parameters')),
2238
+ sortText: `0_${endpoint.name}`
2239
+ }));
2240
+ }
2241
+ return items;
2242
+ }
2243
+ /**
2244
+ * Get header group completions after an API endpoint
2245
+ */
2246
+ createHeaderGroupCompletion(hg, insertText) {
2247
+ const headerList = Object.entries(hg.headers)
2248
+ .map(([name, value]) => ` ${name}: ${value}`)
2249
+ .join('\n');
2250
+ return this.createCompletionItem(hg.name, vscode.CompletionItemKind.Module, {
2251
+ insertText,
2252
+ detail: `Header group (${Object.keys(hg.headers).length} headers)`,
2253
+ documentation: new vscode.MarkdownString(`**${hg.name}**\n\nHeaders:\n\`\`\`\n${headerList}\n\`\`\``),
2254
+ sortText: `0_${hg.name}`
2255
+ });
2256
+ }
2257
+ getHeaderGroupCompletions(document, linePrefix) {
2258
+ const apiDefs = this.getApiDefinitionsFromImports(document);
2259
+ if (apiDefs.headerGroups.length === 0) {
2260
+ return [];
2261
+ }
2262
+ const trimmed = linePrefix.trim();
2263
+ // Extract what's already typed after the endpoint
2264
+ // Pattern: METHOD EndpointName(params) [HeaderGroups...] partial
2265
+ // Also: var x = METHOD EndpointName(params) [HeaderGroups...] partial
2266
+ let match = trimmed.match(/^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+([a-zA-Z_][a-zA-Z0-9_]*)(?:\([^)]*\))?\s+(.*)$/i);
2267
+ if (!match) {
2268
+ // Try var x = METHOD EndpointName(params) pattern
2269
+ 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);
2270
+ }
2271
+ let alreadyUsed = [];
2272
+ let partial = '';
2273
+ if (match) {
2274
+ const afterEndpoint = match[3] || '';
2275
+ const tokens = afterEndpoint.split(/\s+/).filter(t => t); // Filter out empty tokens
2276
+ // The last token might be a partial word being typed
2277
+ if (tokens.length > 0) {
2278
+ const lastToken = tokens[tokens.length - 1];
2279
+ // Check if last token is a complete header group name
2280
+ if (apiDefs.headerGroups.some(hg => hg.name === lastToken)) {
2281
+ // It's complete, so partial is empty
2282
+ alreadyUsed = tokens;
2283
+ }
2284
+ else {
2285
+ // Last token is a partial
2286
+ partial = lastToken;
2287
+ alreadyUsed = tokens.slice(0, -1);
2288
+ }
2289
+ }
2290
+ // If no tokens, partial stays empty and we show all header groups
2291
+ }
2292
+ const items = [];
2293
+ for (const hg of apiDefs.headerGroups) {
2294
+ // Skip already used header groups
2295
+ if (alreadyUsed.includes(hg.name)) {
2296
+ continue;
2297
+ }
2298
+ // Filter by partial
2299
+ if (partial && !hg.name.toLowerCase().startsWith(partial.toLowerCase())) {
2300
+ continue;
2301
+ }
2302
+ items.push(this.createHeaderGroupCompletion(hg, hg.name + ' '));
2303
+ }
2304
+ return items;
2305
+ }
2306
+ /**
2307
+ * Check if we're on a line after an HTTP request.
2308
+ * Supports:
2309
+ * - METHOD EndpointName / METHOD URL
2310
+ * - var x = METHOD EndpointName / URL
2311
+ * Allows inline headers and header groups on following lines.
2312
+ */
2313
+ isAfterApiRequest(document, position) {
2314
+ const apiDefs = this.getApiDefinitionsFromImports(document);
2315
+ // Look at previous lines to see if we're after a request block start.
2316
+ for (let lineNum = position.line - 1; lineNum >= 0 && lineNum >= position.line - 10; lineNum--) {
2317
+ const prevLine = document.lineAt(lineNum).text.trim();
2318
+ // Skip empty lines
2319
+ if (!prevLine) {
2320
+ // Empty line means we're past the request block
2321
+ return false;
2322
+ }
2323
+ // Skip comment lines
2324
+ if (prevLine.startsWith('#')) {
2325
+ continue;
2326
+ }
2327
+ // Skip header group names on their own line
2328
+ if (apiDefs.headerGroups.some(hg => hg.name === prevLine)) {
2329
+ continue;
2330
+ }
2331
+ // Skip inline headers (lines with HeaderName: value pattern)
2332
+ if (/^[A-Za-z][A-Za-z0-9-]*:\s*.+$/.test(prevLine)) {
2333
+ continue;
2334
+ }
2335
+ // Request start: METHOD ...
2336
+ if (/^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+.+$/i.test(prevLine)) {
2337
+ return true;
2338
+ }
2339
+ // Request start: var x = METHOD ...
2340
+ if (/^var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+.+$/i.test(prevLine)) {
2341
+ return true;
2342
+ }
2343
+ // If we hit another kind of line that's not recognized, stop
2344
+ break;
2345
+ }
2346
+ return false;
2347
+ }
2348
+ /**
2349
+ * Check if user might be typing a header group name on its own line
2350
+ * This happens after an API request when header groups are on separate lines:
2351
+ * GET GetAllUsers
2352
+ * Json
2353
+ * Auth
2354
+ */
2355
+ isTypingStandaloneHeaderGroup(linePrefix, document, position) {
2356
+ const trimmed = linePrefix.trim();
2357
+ // Must be a simple identifier (no spaces, colons, or special chars except what's being typed)
2358
+ if (trimmed.includes(':') || trimmed.includes(' ') || trimmed.includes('/')) {
2359
+ return false;
2360
+ }
2361
+ // Check if there are any .nornapi imports with header groups
2362
+ const apiDefs = this.getApiDefinitionsFromImports(document);
2363
+ if (apiDefs.headerGroups.length === 0) {
2364
+ return false;
2365
+ }
2366
+ // Check if any header group name starts with what's typed
2367
+ if (trimmed && !apiDefs.headerGroups.some(hg => hg.name.toLowerCase().startsWith(trimmed.toLowerCase()))) {
2368
+ return false;
2369
+ }
2370
+ // Look at previous lines to see if we're after an API request
2371
+ for (let lineNum = position.line - 1; lineNum >= 0 && lineNum >= position.line - 10; lineNum--) {
2372
+ const prevLine = document.lineAt(lineNum).text.trim();
2373
+ // Skip empty lines and header group names
2374
+ if (!prevLine || apiDefs.headerGroups.some(hg => hg.name === prevLine)) {
2375
+ continue;
2376
+ }
2377
+ // Check if this line is an API request (METHOD EndpointName)
2378
+ const match = prevLine.match(/^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+([a-zA-Z_][a-zA-Z0-9_]*)(?:\([^)]*\))?/i);
2379
+ if (match) {
2380
+ const endpointName = match[2];
2381
+ if (apiDefs.endpoints.some(ep => ep.name === endpointName)) {
2382
+ return true;
2383
+ }
2384
+ }
2385
+ // If we hit another kind of line (not empty, not header group, not API request), stop
2386
+ break;
2387
+ }
2388
+ return false;
2389
+ }
2390
+ /**
2391
+ * Get header group completions for a standalone line (after an API request)
2392
+ */
2393
+ getStandaloneHeaderGroupCompletions(document, linePrefix) {
2394
+ const apiDefs = this.getApiDefinitionsFromImports(document);
2395
+ const trimmed = linePrefix.trim();
2396
+ const items = [];
2397
+ for (const hg of apiDefs.headerGroups) {
2398
+ if (trimmed && !hg.name.toLowerCase().startsWith(trimmed.toLowerCase())) {
2399
+ continue;
2400
+ }
2401
+ items.push(this.createHeaderGroupCompletion(hg, hg.name));
2402
+ }
2403
+ return items;
2404
+ }
2405
+ getVariableCompletions(document, position, linePrefix, lineSuffix) {
2406
+ const fullText = document.getText();
2407
+ const fileVariables = (0, parser_1.extractVariables)(fullText);
2408
+ const envVariables = this.getEnvironmentVariablesForCompletion(document.uri.fsPath);
2409
+ const envSecretNames = (0, environmentProvider_1.getEnvironmentSecretNames)(document.uri.fsPath);
2410
+ const activeEnv = (0, environmentProvider_1.getActiveEnvironment)(document.uri.fsPath);
2411
+ const localVars = this.getSequenceLocalVariableNames(document, position, fullText);
2412
+ const replacementRange = (length) => new vscode.Range(new vscode.Position(position.line, Math.max(0, position.character - length)), position);
2413
+ const allVariables = new Map();
2414
+ // Merge: env variables first, then file variables, then local variables.
2415
+ for (const [name, value] of Object.entries(envVariables)) {
2416
+ allVariables.set(name, { value, source: 'env' });
2417
+ }
2418
+ for (const [name, value] of Object.entries(fileVariables)) {
2419
+ allVariables.set(name, { value, source: 'file' });
2420
+ }
2421
+ for (const name of localVars) {
2422
+ allVariables.set(name, { value: '(local)', source: 'local' });
2423
+ }
2424
+ const endsWithDoubleBrace = linePrefix.endsWith('{{');
2425
+ const endsWithSingleBrace = linePrefix.endsWith('{') && !endsWithDoubleBrace;
2426
+ const partialVarMatch = linePrefix.match(/\{\{([a-zA-Z_][a-zA-Z0-9_]*)$/);
2427
+ const partialSingleVarMatch = !partialVarMatch
2428
+ ? linePrefix.match(/\{([a-zA-Z_][a-zA-Z0-9_]*)$/)
2429
+ : null;
2430
+ const partialVarName = partialVarMatch
2431
+ ? partialVarMatch[1]
2432
+ : partialSingleVarMatch
2433
+ ? partialSingleVarMatch[1]
2434
+ : '';
2435
+ const isSingleBracePartial = Boolean(partialSingleVarMatch);
2436
+ const partialScopeMatch = linePrefix.match(/\{\{(\$[a-zA-Z_]*)$/);
2437
+ const partialSingleScopeMatch = !partialScopeMatch
2438
+ ? linePrefix.match(/\{(\$[a-zA-Z_]*)$/)
2439
+ : null;
2440
+ const partialScopeName = partialScopeMatch ? partialScopeMatch[1] : '';
2441
+ const partialSingleScopeName = partialSingleScopeMatch ? partialSingleScopeMatch[1] : '';
2442
+ const envScopeMatch = linePrefix.match(/\{\{\$env\.([a-zA-Z_][a-zA-Z0-9_]*)?$/);
2443
+ const singleEnvScopeMatch = !envScopeMatch
2444
+ ? linePrefix.match(/\{\$env\.([a-zA-Z_][a-zA-Z0-9_]*)?$/)
2445
+ : null;
2446
+ const partialEnvVarName = envScopeMatch ? (envScopeMatch[1] || '') : '';
2447
+ const partialSingleEnvVarName = singleEnvScopeMatch ? (singleEnvScopeMatch[1] || '') : '';
2448
+ const closingBracesAhead = lineSuffix.match(/^\}+/);
2449
+ const numClosingBraces = closingBracesAhead ? closingBracesAhead[0].length : 0;
2450
+ const bracesToAdd = Math.max(0, 2 - numClosingBraces);
2451
+ const closingBraces = '}'.repeat(bracesToAdd);
2452
+ const buildEnvNamespaceItem = () => {
2453
+ let insertText;
2454
+ let range;
2455
+ if (partialScopeName) {
2456
+ insertText = new vscode.SnippetString(`\\$env.$0${closingBraces}`);
2457
+ range = replacementRange(partialScopeName.length);
2458
+ }
2459
+ else if (partialSingleScopeName) {
2460
+ insertText = new vscode.SnippetString(`{\\$env.$0${closingBraces}`);
2461
+ range = replacementRange(partialSingleScopeName.length);
2462
+ }
2463
+ else if (endsWithDoubleBrace) {
2464
+ insertText = new vscode.SnippetString(`\\$env.$0${closingBraces}`);
2465
+ }
2466
+ else if (endsWithSingleBrace) {
2467
+ insertText = new vscode.SnippetString(`{\\$env.$0${closingBraces}`);
2468
+ }
2469
+ else {
2470
+ insertText = new vscode.SnippetString(`{{\\$env.$0${closingBraces}`);
2471
+ }
2472
+ return this.createCompletionItem('$env', vscode.CompletionItemKind.Module, {
2473
+ insertText,
2474
+ range,
2475
+ detail: 'Environment namespace',
2476
+ documentation: new vscode.MarkdownString('**Environment namespace**\n\nUse `{{$env.name}}` to access an environment variable explicitly even when a file or local variable has the same name.'),
2477
+ sortText: '0_$env',
2478
+ command: this.createTriggerSuggestCommand('Suggest environment variables')
2479
+ });
2480
+ };
2481
+ if (envScopeMatch || singleEnvScopeMatch) {
2482
+ const envPartial = envScopeMatch ? partialEnvVarName : partialSingleEnvVarName;
2483
+ const isSingleEnvScope = Boolean(singleEnvScopeMatch);
2484
+ return Object.entries(envVariables)
2485
+ .filter(([name]) => !envPartial || name.toLowerCase().startsWith(envPartial.toLowerCase()))
2486
+ .map(([name, value]) => {
2487
+ let range;
2488
+ let insertText = name + closingBraces;
2489
+ if (isSingleEnvScope) {
2490
+ range = replacementRange('$env.'.length + envPartial.length);
2491
+ insertText = `{$env.${name}${closingBraces}`;
2492
+ }
2493
+ else if (envPartial) {
2494
+ range = replacementRange(envPartial.length);
2495
+ }
2496
+ const shouldRedact = envSecretNames.has(name);
2497
+ const displayValue = shouldRedact ? '***SECRET***' : value;
2498
+ return this.createCompletionItem(name, vscode.CompletionItemKind.Constant, {
2499
+ insertText,
2500
+ range,
2501
+ detail: activeEnv ? `env:${activeEnv}` : 'env',
2502
+ documentation: new vscode.MarkdownString(`**Environment variable:** \`${name}\`\n\n` +
2503
+ `**Value:** \`${displayValue}\`\n\n` +
2504
+ `**Insert:** \`{{$env.${name}}}\``),
2505
+ sortText: `0_${name}`
2506
+ });
2507
+ });
2508
+ }
2509
+ if (partialScopeMatch || partialSingleScopeMatch) {
2510
+ const scopePartial = partialScopeMatch ? partialScopeName : partialSingleScopeName;
2511
+ return '$env'.startsWith(scopePartial) ? [buildEnvNamespaceItem()] : [];
2512
+ }
2513
+ if (allVariables.size === 0) {
2514
+ return Object.keys(envVariables).length > 0 ? [buildEnvNamespaceItem()] : [];
2515
+ }
2516
+ const items = Array.from(allVariables.entries()).map(([name, { value, source }]) => {
2517
+ const kind = source === 'env'
2518
+ ? vscode.CompletionItemKind.Constant
2519
+ : vscode.CompletionItemKind.Variable;
2520
+ let insertText;
2521
+ let range;
2522
+ if (partialVarName) {
2523
+ insertText = isSingleBracePartial
2524
+ ? '{' + name + closingBraces
2525
+ : name + closingBraces;
2526
+ range = replacementRange(partialVarName.length);
2527
+ }
2528
+ else if (endsWithDoubleBrace) {
2529
+ insertText = name + closingBraces;
2530
+ }
2531
+ else if (endsWithSingleBrace) {
2532
+ insertText = '{' + name + closingBraces;
2533
+ }
2534
+ else {
2535
+ insertText = '{{' + name + closingBraces;
2536
+ }
2537
+ const shouldRedact = source === 'env' && envSecretNames.has(name);
2538
+ const displayValue = shouldRedact ? '***SECRET***' : value;
2539
+ const detail = source === 'local'
2540
+ ? 'local'
2541
+ : `${source === 'env' ? `env${activeEnv ? `:${activeEnv}` : ''}` : 'file'}: ${displayValue}`;
2542
+ const sourceDesc = source === 'env'
2543
+ ? `**Source:** Environment${activeEnv ? ` (${activeEnv})` : ''}`
2544
+ : source === 'local'
2545
+ ? '**Source:** Local sequence variable'
2546
+ : '**Source:** File';
2547
+ const valueLine = source === 'local'
2548
+ ? ''
2549
+ : shouldRedact ? '**Value:** `***SECRET***`\n\n' : `**Value:** \`${value}\`\n\n`;
2550
+ const explicitEnvLine = source === 'env'
2551
+ ? `\n\n**Explicit env access:** \`{{$env.${name}}}\``
2552
+ : '';
2553
+ return this.createCompletionItem(name, kind, {
2554
+ insertText,
2555
+ range,
2556
+ detail,
2557
+ documentation: new vscode.MarkdownString(`**Variable:** \`${name}\`\n\n${valueLine}${sourceDesc}${explicitEnvLine}`),
2558
+ sortText: source === 'local' ? `0_${name}` : source === 'file' ? `1_${name}` : `2_${name}`
2559
+ });
2560
+ });
2561
+ if (Object.keys(envVariables).length > 0) {
2562
+ items.unshift(buildEnvNamespaceItem());
2563
+ }
2564
+ return items;
2565
+ }
2566
+ // Check if the typed text could be the start of an HTTP method or keyword
2567
+ couldBeMethodOrKeyword(text) {
2568
+ if (text.length === 0) {
2569
+ return false;
2570
+ }
2571
+ const lowerText = text.toLowerCase();
2572
+ const allKeywords = [...this.httpMethods.map(m => m.toLowerCase()), ...this.keywords];
2573
+ return allKeywords.some(kw => kw.startsWith(lowerText));
2574
+ }
2575
+ getMethodCompletions(typedText) {
2576
+ const lowerTyped = typedText?.toLowerCase() || '';
2577
+ return this.httpMethods
2578
+ .filter(method => !lowerTyped || method.toLowerCase().startsWith(lowerTyped))
2579
+ .map(method => {
2580
+ return this.createCompletionItem(method, vscode.CompletionItemKind.Keyword, {
2581
+ insertText: method,
2582
+ documentation: `HTTP ${method} request`,
2583
+ sortText: '0_' + method,
2584
+ preselect: lowerTyped && method.toLowerCase().startsWith(lowerTyped) ? true : undefined
2585
+ });
2586
+ });
2587
+ }
2588
+ getKeywordCompletions() {
2589
+ const keywordItems = [
2590
+ {
2591
+ label: 'var',
2592
+ options: {
2593
+ insertText: 'var ',
2594
+ documentation: new vscode.MarkdownString('Declare a variable.\n\n`var myVar = someValue`\n\nReference with `{{myVar}}`'),
2595
+ sortText: '1_var'
2596
+ }
2597
+ },
2598
+ {
2599
+ label: 'sequence',
2600
+ options: {
2601
+ insertText: new vscode.SnippetString('sequence $0\n\nend sequence'),
2602
+ 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```'),
2603
+ sortText: '1_sequence'
2604
+ }
2605
+ },
2606
+ {
2607
+ label: 'test sequence',
2608
+ options: {
2609
+ insertText: new vscode.SnippetString('test sequence $0\n\nend sequence'),
2610
+ documentation: new vscode.MarkdownString('Define a test sequence (discoverable in Test Explorer and runnable via CLI).\n\n' +
2611
+ '```\n' +
2612
+ 'test sequence MyTest\n' +
2613
+ '\n' +
2614
+ 'GET /health\n' +
2615
+ 'assert $1.status == 200\n' +
2616
+ '\n' +
2617
+ 'end sequence\n' +
2618
+ '```'),
2619
+ sortText: '1_test_sequence'
2620
+ }
2621
+ },
2622
+ {
2623
+ label: 'end sequence',
2624
+ options: {
2625
+ insertText: 'end sequence',
2626
+ documentation: 'End a sequence block',
2627
+ sortText: '1_end_sequence'
2628
+ }
2629
+ },
2630
+ {
2631
+ label: 'run bash',
2632
+ options: {
2633
+ insertText: 'run bash ',
2634
+ 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.'),
2635
+ sortText: '1_run_bash'
2636
+ }
2637
+ },
2638
+ {
2639
+ label: 'run powershell',
2640
+ options: {
2641
+ insertText: 'run powershell ',
2642
+ 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.'),
2643
+ sortText: '1_run_powershell'
2644
+ }
2645
+ },
2646
+ {
2647
+ label: 'run js',
2648
+ options: {
2649
+ insertText: 'run js ',
2650
+ 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.'),
2651
+ sortText: '1_run_js'
2652
+ }
2653
+ },
2654
+ {
2655
+ label: 'run',
2656
+ options: {
2657
+ insertText: 'run ',
2658
+ 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```'),
2659
+ sortText: '1_run'
2660
+ }
2661
+ },
2662
+ {
2663
+ label: 'print',
2664
+ options: {
2665
+ insertText: 'print ',
2666
+ 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.'),
2667
+ sortText: '1_print'
2668
+ }
2669
+ },
2670
+ {
2671
+ label: 'assert',
2672
+ options: {
2673
+ insertText: 'assert ',
2674
+ documentation: new vscode.MarkdownString('Assert a condition on the response. Fails the sequence if the assertion is false.\n\n' +
2675
+ '**Check status code:**\n```norn\nassert $1.status == 200\nassert $1.status >= 200\nassert $1.status < 400\n```\n\n' +
2676
+ '**Check response body:**\n```norn\nassert $1.body.success == true\nassert $1.body.name == "John"\nassert $1.body.email contains "@"\n```\n\n' +
2677
+ '**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' +
2678
+ '**Validate schema:**\n```norn\nassert $1.body matchesSchema "./schemas/user.schema.json"\nassert user.body matchesSchema "./schemas/response.schema.json"\n```\n\n' +
2679
+ '**Check headers:**\n```norn\nassert $1.headers.Content-Type contains "json"\n```\n\n' +
2680
+ '**Check existence:**\n```norn\nassert $1.body.token exists\nassert $1.body.error !exists\n```\n\n' +
2681
+ '**With custom message:**\n```norn\nassert $1.status == 200 | "Login should succeed"\n```\n\n' +
2682
+ '**Operators:** `==`, `!=`, `>`, `>=`, `<`, `<=`, `contains`, `startsWith`, `endsWith`, `matches`, `matchesSchema`, `exists`, `!exists`, `isType`\n\n' +
2683
+ '**Types for isType:** `number`, `string`, `boolean`, `object`, `array`, `null`'),
2684
+ sortText: '1_assert'
2685
+ }
2686
+ },
2687
+ {
2688
+ label: 'if',
2689
+ options: {
2690
+ documentation: 'Conditional execution based on response values',
2691
+ sortText: '1_if'
2692
+ }
2693
+ },
2694
+ {
2695
+ label: 'end if',
2696
+ options: {
2697
+ documentation: 'End an if conditional block',
2698
+ sortText: '1_end_if'
2699
+ }
2700
+ },
2701
+ {
2702
+ label: 'wait',
2703
+ options: {
2704
+ documentation: 'Pause execution for a specified duration (e.g., wait 1s, wait 500ms)',
2705
+ sortText: '1_wait'
2706
+ }
2707
+ },
2708
+ {
2709
+ label: 'run readJson',
2710
+ options: {
2711
+ insertText: 'run readJson',
2712
+ documentation: new vscode.MarkdownString('Load a JSON file into a variable for use in your requests.\n\n' +
2713
+ '**Load a JSON file:**\n```norn\nvar config = run readJson ./config.json\nvar testData = run readJson "./data/users.json"\n```\n\n' +
2714
+ '**Access properties:**\n```norn\n# After loading: var data = run readJson ./test-data.json\n\n' +
2715
+ '# Use top-level properties\nGET {{data.baseUrl}}/users\n\n' +
2716
+ '# Access nested properties\nContent-Type: {{data.headers.contentType}}\n\n' +
2717
+ '# Access array elements\nprint User: {{data.users[0].name}}\n```\n\n' +
2718
+ 'Supports deep nesting with dot notation and array indexing `[n]`.'),
2719
+ sortText: '1_run_readJson'
2720
+ }
2721
+ },
2722
+ {
2723
+ label: 'import',
2724
+ options: {
2725
+ insertText: new vscode.SnippetString('import "$0"'),
2726
+ documentation: new vscode.MarkdownString('Import named requests and sequences from another .norn file.\n\n' +
2727
+ '**Import a file:**\n```norn\nimport "./common/auth.norn"\nimport "./shared/utils.norn"\n```\n\n' +
2728
+ '**Use imported requests and sequences:**\n```norn\nimport "./common.norn"\n\nsequence MyFlow\n run SharedRequest\n run SharedSequence\nend sequence\n```\n\n' +
2729
+ 'Imported definitions are available throughout the file.'),
2730
+ sortText: '1_import'
2731
+ }
2732
+ }
2733
+ ];
2734
+ return keywordItems.map(({ label, options }) => this.createCompletionItem(label, vscode.CompletionItemKind.Keyword, options));
2735
+ }
2736
+ getHeaderCompletions() {
2737
+ return this.commonHeaders.map(header => {
2738
+ return this.createCompletionItem(header, vscode.CompletionItemKind.Field, {
2739
+ insertText: `${header}: `,
2740
+ documentation: `HTTP header: ${header}`,
2741
+ // Trigger IntelliSense again after inserting the header (for Content-Type values, etc.)
2742
+ command: this.createTriggerSuggestCommand()
2743
+ });
2744
+ });
2745
+ }
2746
+ getContentTypeCompletions() {
2747
+ return this.contentTypes.map(type => {
2748
+ return this.createCompletionItem(type, vscode.CompletionItemKind.Value, {
2749
+ documentation: `Content type: ${type}`
2750
+ });
2751
+ });
2752
+ }
2753
+ startsWithMethod(text) {
2754
+ const trimmed = text.trim().toUpperCase();
2755
+ return this.httpMethods.some(method => trimmed.startsWith(method));
2756
+ }
2757
+ /**
2758
+ * Check if user is typing after "var x = " (for run command or variable completion)
2759
+ * Does NOT match if already past an endpoint (header group context)
2760
+ */
2761
+ isTypingVarAssignment(linePrefix) {
2762
+ const trimmed = linePrefix.trim();
2763
+ // Match "var name = " or "var name = r" or "var name = run" etc.
2764
+ const match = trimmed.match(/^var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*(.*)$/i);
2765
+ if (!match) {
2766
+ return false;
2767
+ }
2768
+ const afterEquals = match[1];
2769
+ // Don't trigger if inside a quoted string
2770
+ if (afterEquals.startsWith('"') || afterEquals.startsWith("'")) {
2771
+ return false;
2772
+ }
2773
+ // Don't trigger if we're past an HTTP method + endpoint (that's header group context)
2774
+ // Pattern: GET EndpointName(params) followed by space
2775
+ const httpEndpointPattern = /^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+[a-zA-Z_][a-zA-Z0-9_]*(?:\([^)]*\))?\s+/i;
2776
+ if (httpEndpointPattern.test(afterEquals)) {
2777
+ return false;
2778
+ }
2779
+ // Trigger for any text after equals (including empty to show completions)
2780
+ return true;
2781
+ }
2782
+ /**
2783
+ * Get completions when inside "var x = " assignment
2784
+ * Shows: run commands, HTTP methods (for var x = GET endpoint), defined variables, and keywords
2785
+ */
2786
+ getRunCompletionsForVarAssignment(document, linePrefix) {
2787
+ const trimmed = linePrefix.trim();
2788
+ const match = trimmed.match(/^var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*(.*)$/i);
2789
+ const afterEquals = match ? match[1] : '';
2790
+ const items = [];
2791
+ // Add HTTP methods for "var x = GET endpoint" pattern
2792
+ for (const method of this.httpMethods) {
2793
+ if (afterEquals === '' || method.toLowerCase().startsWith(afterEquals.toLowerCase())) {
2794
+ items.push(this.createCompletionItem(method, vscode.CompletionItemKind.Method, {
2795
+ insertText: method,
2796
+ detail: `Capture ${method} response into variable`,
2797
+ documentation: new vscode.MarkdownString(`Capture an HTTP response into a variable for assertions.\n\n` +
2798
+ `**Examples:**\n` +
2799
+ `- \`var user = ${method} {{baseUrl}}/users/1\`\n` +
2800
+ `- \`var user = ${method} GetUser(1) Json\`\n\n` +
2801
+ `Then use: \`assert user.body.id == 1\``),
2802
+ sortText: `0_${method}`
2803
+ }));
2804
+ }
2805
+ }
2806
+ // Handle 'run' keyword - only show script types (bash, powershell, etc.) after user types "run "
2807
+ const lowerAfter = afterEquals.toLowerCase();
2808
+ // Check if line ends with space after 'run' - the original linePrefix preserves trailing space
2809
+ const hasTrailingSpace = linePrefix.endsWith(' ');
2810
+ const typedRunWithSpace = (lowerAfter === 'run' && hasTrailingSpace) || lowerAfter.startsWith('run ');
2811
+ if (typedRunWithSpace) {
2812
+ // User typed "var x = run " - show script types and named requests
2813
+ items.push(...this.getRunCompletions(document, afterEquals, linePrefix));
2814
+ }
2815
+ else if (afterEquals === '' || 'run'.startsWith(lowerAfter)) {
2816
+ // User typed "var x = " or "var x = r" or "var x = ru" or "var x = run" (no space) - just show 'run' keyword
2817
+ items.push(this.createCompletionItem('run', vscode.CompletionItemKind.Keyword, {
2818
+ insertText: 'run ',
2819
+ detail: 'Run a script or named request',
2820
+ documentation: new vscode.MarkdownString('Run a script or named request to capture its result.\n\n' +
2821
+ '**Scripts:**\n```norn\nvar result = run bash ./script.sh\nvar data = run readJson ./data.json\n```\n\n' +
2822
+ '**Named requests:**\n```norn\nvar user = run GetUser\n```'),
2823
+ sortText: '0_run',
2824
+ command: this.createTriggerSuggestCommand()
2825
+ }));
2826
+ }
2827
+ // Also show variable completions if not typing 'run' or HTTP method
2828
+ const isTypingMethod = this.httpMethods.some(m => afterEquals.toLowerCase().startsWith(m.toLowerCase()));
2829
+ if (!afterEquals.toLowerCase().startsWith('run') && !isTypingMethod) {
2830
+ items.push(...this.getVariablePathCompletions(document, afterEquals));
2831
+ }
2832
+ // Add literal keywords
2833
+ if (afterEquals === '' || 'true'.startsWith(afterEquals.toLowerCase())) {
2834
+ items.push(this.createCompletionItem('true', vscode.CompletionItemKind.Keyword, {
2835
+ detail: 'Boolean true'
2836
+ }));
2837
+ }
2838
+ if (afterEquals === '' || 'false'.startsWith(afterEquals.toLowerCase())) {
2839
+ items.push(this.createCompletionItem('false', vscode.CompletionItemKind.Keyword, {
2840
+ detail: 'Boolean false'
2841
+ }));
2842
+ }
2843
+ if (afterEquals === '' || 'null'.startsWith(afterEquals.toLowerCase())) {
2844
+ items.push(this.createCompletionItem('null', vscode.CompletionItemKind.Keyword, {
2845
+ detail: 'Null value'
2846
+ }));
2847
+ }
2848
+ return items;
2849
+ }
2850
+ /**
2851
+ * Get variable path completions for var assignment (e.g., data, data.users, etc.)
2852
+ */
2853
+ getVariablePathCompletions(document, prefix) {
2854
+ const fullText = document.getText();
2855
+ const fileVariables = (0, parser_1.extractVariables)(fullText);
2856
+ const envVariables = (0, environmentProvider_1.getEnvironmentVariables)(document.uri.fsPath);
2857
+ const envSecretNames = (0, environmentProvider_1.getEnvironmentSecretNames)(document.uri.fsPath);
2858
+ const allVariables = { ...envVariables, ...fileVariables };
2859
+ const items = [];
2860
+ const lowerPrefix = prefix.toLowerCase();
2861
+ for (const [name, value] of Object.entries(allVariables)) {
2862
+ // Filter by prefix if user has typed something
2863
+ if (lowerPrefix && !name.toLowerCase().startsWith(lowerPrefix)) {
2864
+ continue;
2865
+ }
2866
+ const isSecret = envSecretNames.has(name) && envVariables[name] !== undefined;
2867
+ const displayValue = isSecret ? '***SECRET***' : value.length > 50 ? value.substring(0, 47) + '...' : value;
2868
+ let detail = displayValue;
2869
+ let documentation = isSecret ? `Variable: ${name} (secret)` : `Variable: ${name}`;
2870
+ // If the value is JSON, indicate it can be accessed with paths
2871
+ if (!isSecret) {
2872
+ try {
2873
+ const parsed = JSON.parse(value);
2874
+ if (typeof parsed === 'object' && parsed !== null) {
2875
+ documentation = `JSON variable - access properties with ${name}.property`;
2876
+ detail = 'JSON object';
2877
+ }
2878
+ }
2879
+ catch {
2880
+ // Not JSON, use value as detail
2881
+ }
2882
+ }
2883
+ items.push(this.createCompletionItem(name, vscode.CompletionItemKind.Variable, {
2884
+ detail,
2885
+ documentation
2886
+ }));
2887
+ }
2888
+ return items;
2889
+ }
2890
+ /**
2891
+ * Check if user is typing "run" or after "run " (for named request completion)
2892
+ */
2893
+ isTypingRunCommand(linePrefix) {
2894
+ const lowerPrefix = linePrefix.toLowerCase();
2895
+ const trimmed = lowerPrefix.trim();
2896
+ // Check if user is typing 'r', 'ru', or 'run' at the start of a line
2897
+ if (trimmed && 'run'.startsWith(trimmed)) {
2898
+ return true;
2899
+ }
2900
+ // Check if line is exactly "run" followed by space (user just typed "run ")
2901
+ // Use the original linePrefix to detect trailing space
2902
+ if (trimmed === 'run' && lowerPrefix.endsWith(' ')) {
2903
+ return true;
2904
+ }
2905
+ // Check if line starts with "run " and has more content
2906
+ if (!trimmed.startsWith('run ')) {
2907
+ return false;
2908
+ }
2909
+ const afterRun = trimmed.substring(4).trim();
2910
+ // Don't trigger for script commands that already have a path
2911
+ if (/^(bash|js|powershell|readjson)\s+\S/i.test(afterRun)) {
2912
+ return false;
2913
+ }
2914
+ return true;
2915
+ }
2916
+ isTypingRunSqlCommand(linePrefix) {
2917
+ const trimmed = linePrefix.trim();
2918
+ if (/^(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?run\s+sql\s*$/i.test(trimmed) && linePrefix.endsWith(' ')) {
2919
+ return true;
2920
+ }
2921
+ return /^(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?run\s+sql\s+[a-zA-Z_][a-zA-Z0-9_-]*$/i.test(trimmed);
2922
+ }
2923
+ isTypingRunMcpCommand(linePrefix) {
2924
+ const trimmed = linePrefix.trim();
2925
+ if (!/^(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?run\s+mcp(?:\s+.*)?$/i.test(trimmed)) {
2926
+ return false;
2927
+ }
2928
+ if (/^(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?run\s+mcp\s*$/i.test(trimmed)) {
2929
+ return true;
2930
+ }
2931
+ return true;
2932
+ }
2933
+ getMcpServerAliasCompletions(document, typedAlias, operation) {
2934
+ try {
2935
+ const config = (0, mcpConfig_1.loadNornMcpProjectConfig)(document.uri.fsPath);
2936
+ const items = [];
2937
+ const lowerTypedAlias = typedAlias.toLowerCase();
2938
+ for (const [alias, server] of Object.entries(config.config.servers)) {
2939
+ if (lowerTypedAlias && !alias.toLowerCase().startsWith(lowerTypedAlias)) {
2940
+ continue;
2941
+ }
2942
+ items.push(this.createCompletionItem(alias, vscode.CompletionItemKind.Module, {
2943
+ insertText: operation === 'call' ? `${alias} ` : alias,
2944
+ detail: `MCP ${server.transport} server`,
2945
+ documentation: new vscode.MarkdownString(`**MCP Server:** \`${alias}\`\n\nTransport: \`${server.transport}\``),
2946
+ sortText: `0_${alias.toLowerCase()}`,
2947
+ command: operation === 'call' ? this.createTriggerSuggestCommand() : undefined
2948
+ }));
2949
+ }
2950
+ return items;
2951
+ }
2952
+ catch {
2953
+ return [];
2954
+ }
2955
+ }
2956
+ resolveKnownMcpAlias(document, alias) {
2957
+ try {
2958
+ const config = (0, mcpConfig_1.loadNornMcpProjectConfig)(document.uri.fsPath);
2959
+ return Object.keys(config.config.servers).find(candidate => candidate.toLowerCase() === alias.toLowerCase());
2960
+ }
2961
+ catch {
2962
+ return undefined;
2963
+ }
2964
+ }
2965
+ buildMcpToolDocumentation(tool) {
2966
+ const parameterNames = (0, mcpToolSchema_1.getMcpToolInputParameterNames)(tool);
2967
+ const propertyMap = (0, mcpToolSchema_1.getMcpToolInputPropertyMap)(tool);
2968
+ const required = new Set((0, mcpToolSchema_1.getMcpToolRequiredParameterNames)(tool).map(name => name.toLowerCase()));
2969
+ const lines = [`**MCP Tool:** \`${tool.name}\``];
2970
+ if (tool.description) {
2971
+ lines.push('', tool.description);
2972
+ }
2973
+ if (parameterNames.length === 0) {
2974
+ lines.push('', 'Parameters: none');
2975
+ }
2976
+ else {
2977
+ lines.push('', 'Parameters:');
2978
+ for (const name of parameterNames) {
2979
+ const typeSummary = this.getMcpParameterTypeSummary(propertyMap[name]);
2980
+ lines.push(`- \`${name}\`${required.has(name.toLowerCase()) ? ' required' : ''}${typeSummary ? ` · ${typeSummary}` : ''}`);
2981
+ }
2982
+ }
2983
+ lines.push('', 'Available from a previous `run mcp list` call cached in `.norn-cache`.');
2984
+ return new vscode.MarkdownString(lines.join('\n'));
2985
+ }
2986
+ getMcpParameterTypeSummary(schema) {
2987
+ if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
2988
+ return undefined;
2989
+ }
2990
+ const candidate = schema;
2991
+ if (Array.isArray(candidate.enum) && candidate.enum.length > 0) {
2992
+ return 'enum';
2993
+ }
2994
+ if (typeof candidate.type === 'string') {
2995
+ if (candidate.type === 'array') {
2996
+ const itemType = this.getMcpParameterTypeSummary(candidate.items);
2997
+ return itemType ? `array<${itemType}>` : 'array';
2998
+ }
2999
+ return candidate.type;
3000
+ }
3001
+ if (Array.isArray(candidate.type)) {
3002
+ const types = candidate.type.filter((entry) => typeof entry === 'string');
3003
+ return types.length > 0 ? types.join(' | ') : undefined;
3004
+ }
3005
+ if (candidate.properties && typeof candidate.properties === 'object' && !Array.isArray(candidate.properties)) {
3006
+ return 'object';
3007
+ }
3008
+ if (candidate.items) {
3009
+ return 'array';
3010
+ }
3011
+ return undefined;
3012
+ }
3013
+ getMcpCallToolCompletions(document, linePrefix) {
3014
+ const trimmed = linePrefix.trim();
3015
+ const afterAliasMatch = trimmed.match(/^(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?run\s+mcp\s+call\s+([a-zA-Z_][a-zA-Z0-9_-]*)$/i);
3016
+ const typedToolMatch = trimmed.match(/^(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?run\s+mcp\s+call\s+([a-zA-Z_][a-zA-Z0-9_-]*)\s+([a-zA-Z_][a-zA-Z0-9_.:-]*)$/i);
3017
+ let alias;
3018
+ let typedToolName = '';
3019
+ if (afterAliasMatch && linePrefix.endsWith(' ')) {
3020
+ alias = afterAliasMatch[1];
3021
+ }
3022
+ else if (typedToolMatch) {
3023
+ alias = typedToolMatch[1];
3024
+ typedToolName = typedToolMatch[2];
3025
+ }
3026
+ if (!alias) {
3027
+ return [];
3028
+ }
3029
+ const resolvedAlias = this.resolveKnownMcpAlias(document, alias);
3030
+ if (!resolvedAlias) {
3031
+ return [];
3032
+ }
3033
+ const cachedTools = (0, mcpToolIntellisenseCache_1.getCachedMcpToolsForAlias)(document.uri.fsPath, resolvedAlias);
3034
+ if (!cachedTools) {
3035
+ return [];
3036
+ }
3037
+ const lowerTypedToolName = typedToolName.toLowerCase();
3038
+ return cachedTools.tools
3039
+ .filter(tool => !lowerTypedToolName || tool.name.toLowerCase().startsWith(lowerTypedToolName))
3040
+ .map(tool => {
3041
+ const parameterNames = (0, mcpToolSchema_1.getMcpToolInputParameterNames)(tool);
3042
+ return this.createCompletionItem(tool.name, vscode.CompletionItemKind.Function, {
3043
+ insertText: parameterNames.length > 0
3044
+ ? new vscode.SnippetString(`${tool.name}($0)`)
3045
+ : new vscode.SnippetString(`${tool.name}()$0`),
3046
+ detail: parameterNames.length > 0
3047
+ ? `MCP tool · ${parameterNames.length} parameter${parameterNames.length === 1 ? '' : 's'}`
3048
+ : 'MCP tool',
3049
+ documentation: this.buildMcpToolDocumentation(tool),
3050
+ sortText: `0_${tool.name.toLowerCase()}`,
3051
+ command: parameterNames.length > 0
3052
+ ? {
3053
+ command: TRIGGER_PARAMETER_HINTS_COMMAND,
3054
+ title: 'Trigger Parameter Hints'
3055
+ }
3056
+ : undefined
3057
+ });
3058
+ });
3059
+ }
3060
+ getMcpArgumentCompletionContext(argsPrefix) {
3061
+ const usedNamedArgs = new Set();
3062
+ const parts = (0, sequenceRunner_1.splitNamedArgumentList)(argsPrefix);
3063
+ const endsWithComma = /,\s*$/.test(argsPrefix);
3064
+ const completedParts = endsWithComma ? parts : parts.slice(0, -1);
3065
+ const currentPart = endsWithComma ? '' : (parts.at(-1) ?? '');
3066
+ for (const part of completedParts) {
3067
+ const match = part.trim().match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:/);
3068
+ if (match) {
3069
+ usedNamedArgs.add(match[1].toLowerCase());
3070
+ }
3071
+ }
3072
+ const currentTrimmed = currentPart.trim();
3073
+ if (!currentTrimmed) {
3074
+ return {
3075
+ canSuggest: true,
3076
+ currentNamePrefix: '',
3077
+ usedNamedArgs
3078
+ };
3079
+ }
3080
+ if (currentTrimmed.includes(':')) {
3081
+ return {
3082
+ canSuggest: false,
3083
+ currentNamePrefix: '',
3084
+ usedNamedArgs
3085
+ };
3086
+ }
3087
+ if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(currentTrimmed)) {
3088
+ return {
3089
+ canSuggest: true,
3090
+ currentNamePrefix: currentTrimmed,
3091
+ usedNamedArgs
3092
+ };
3093
+ }
3094
+ return {
3095
+ canSuggest: false,
3096
+ currentNamePrefix: '',
3097
+ usedNamedArgs
3098
+ };
3099
+ }
3100
+ getMcpCallParameterCompletions(document, linePrefix) {
3101
+ const match = linePrefix.match(/^(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?run\s+mcp\s+call\s+([a-zA-Z_][a-zA-Z0-9_-]*)\s+([a-zA-Z_][a-zA-Z0-9_.:-]*)\((.*)$/i);
3102
+ if (!match) {
3103
+ return [];
3104
+ }
3105
+ const argsPrefix = match[3];
3106
+ if (argsPrefix.includes(')')) {
3107
+ return [];
3108
+ }
3109
+ const resolvedAlias = this.resolveKnownMcpAlias(document, match[1]);
3110
+ if (!resolvedAlias) {
3111
+ return [];
3112
+ }
3113
+ const cachedTool = (0, mcpToolIntellisenseCache_1.getCachedMcpToolForAlias)(document.uri.fsPath, resolvedAlias, match[2]);
3114
+ if (!cachedTool) {
3115
+ return [];
3116
+ }
3117
+ const parameterNames = (0, mcpToolSchema_1.getMcpToolInputParameterNames)(cachedTool);
3118
+ if (parameterNames.length === 0) {
3119
+ return [];
3120
+ }
3121
+ const completionContext = this.getMcpArgumentCompletionContext(argsPrefix);
3122
+ if (!completionContext.canSuggest) {
3123
+ return [];
3124
+ }
3125
+ const propertyMap = (0, mcpToolSchema_1.getMcpToolInputPropertyMap)(cachedTool);
3126
+ const required = new Set((0, mcpToolSchema_1.getMcpToolRequiredParameterNames)(cachedTool).map(name => name.toLowerCase()));
3127
+ const lowerPrefix = completionContext.currentNamePrefix.toLowerCase();
3128
+ return parameterNames
3129
+ .filter(name => !completionContext.usedNamedArgs.has(name.toLowerCase()))
3130
+ .filter(name => !lowerPrefix || name.toLowerCase().startsWith(lowerPrefix))
3131
+ .map(name => {
3132
+ const typeSummary = this.getMcpParameterTypeSummary(propertyMap[name]);
3133
+ const isRequired = required.has(name.toLowerCase());
3134
+ return this.createCompletionItem(name, vscode.CompletionItemKind.Property, {
3135
+ insertText: `${name}: `,
3136
+ detail: `${isRequired ? 'Required' : 'Optional'} parameter${typeSummary ? ` · ${typeSummary}` : ''}`,
3137
+ documentation: new vscode.MarkdownString(`**${name}**\n\n` +
3138
+ `${isRequired ? 'Required' : 'Optional'} parameter` +
3139
+ `${typeSummary ? `\n\nType: \`${typeSummary}\`` : ''}`),
3140
+ sortText: `${isRequired ? '0' : '1'}_${name.toLowerCase()}`
3141
+ });
3142
+ });
3143
+ }
3144
+ getMcpCompletions(document, linePrefix) {
3145
+ const trimmed = linePrefix.trim();
3146
+ const match = trimmed.match(/^(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?run\s+mcp(?:\s+(.*))?$/i);
3147
+ if (!match) {
3148
+ return [];
3149
+ }
3150
+ const afterMcp = match[1] || '';
3151
+ const normalized = afterMcp.trim();
3152
+ const hasTrailingSpace = linePrefix.endsWith(' ');
3153
+ const tokens = normalized ? normalized.split(/\s+/) : [];
3154
+ if (tokens.length === 0 || (tokens.length === 1 && !hasTrailingSpace)) {
3155
+ const typedOperation = tokens[0]?.toLowerCase() || '';
3156
+ return [
3157
+ {
3158
+ name: 'list',
3159
+ detail: 'List tools from an MCP server alias',
3160
+ documentation: 'List the tools exposed by a configured MCP server.',
3161
+ sortText: '0_list'
3162
+ },
3163
+ {
3164
+ name: 'call',
3165
+ detail: 'Call a tool on an MCP server alias',
3166
+ documentation: 'Call a specific tool on a configured MCP server.',
3167
+ sortText: '0_call'
3168
+ }
3169
+ ]
3170
+ .filter(entry => !typedOperation || entry.name.startsWith(typedOperation))
3171
+ .map(entry => {
3172
+ return this.createCompletionItem(entry.name, vscode.CompletionItemKind.Method, {
3173
+ insertText: `${entry.name} `,
3174
+ detail: entry.detail,
3175
+ documentation: new vscode.MarkdownString(entry.documentation),
3176
+ sortText: entry.sortText,
3177
+ command: this.createTriggerSuggestCommand()
3178
+ });
3179
+ });
3180
+ }
3181
+ const operation = tokens[0]?.toLowerCase();
3182
+ if (operation === 'call') {
3183
+ const toolCompletions = this.getMcpCallToolCompletions(document, linePrefix);
3184
+ if (toolCompletions.length > 0) {
3185
+ return toolCompletions;
3186
+ }
3187
+ }
3188
+ if ((operation === 'list' || operation === 'call') && ((tokens.length === 1 && hasTrailingSpace) || (tokens.length === 2 && !hasTrailingSpace))) {
3189
+ return this.getMcpServerAliasCompletions(document, tokens[1] || '', operation);
3190
+ }
3191
+ return [];
3192
+ }
3193
+ getSqlOperationCompletions(document, linePrefix) {
3194
+ const trimmed = linePrefix.trim();
3195
+ const match = trimmed.match(/^(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?run\s+sql(?:\s+([a-zA-Z_][a-zA-Z0-9_-]*))?$/i);
3196
+ const typedName = (match?.[1] || '').toLowerCase();
3197
+ const importedSql = this.getImportedSqlDefinitions(document);
3198
+ const items = [];
3199
+ const seen = new Set();
3200
+ for (const entry of importedSql.operations) {
3201
+ const operation = entry.operation;
3202
+ const lowerName = operation.name.toLowerCase();
3203
+ if (seen.has(lowerName)) {
3204
+ continue;
3205
+ }
3206
+ if (typedName && !lowerName.startsWith(typedName)) {
3207
+ continue;
3208
+ }
3209
+ seen.add(lowerName);
3210
+ items.push(this.createCompletionItem(operation.name, vscode.CompletionItemKind.Function, {
3211
+ insertText: operation.parameters.length > 0
3212
+ ? new vscode.SnippetString(`${operation.name}($0)`)
3213
+ : operation.name,
3214
+ detail: `${operation.type} · ${path.basename(entry.sourcePath)}`,
3215
+ documentation: new vscode.MarkdownString(`**${operation.type}** \`${operation.name}\`\n\n` +
3216
+ `Connection: \`${operation.connectionName}\`\n\n` +
3217
+ (operation.parameters.length > 0
3218
+ ? `Parameters: ${operation.parameters.map(param => `\`${param}\``).join(', ')}\n\n`
3219
+ : 'Parameters: none\n\n') +
3220
+ '```sql\n' + operation.sql.substring(0, 300) + (operation.sql.length > 300 ? '...' : '') + '\n```'),
3221
+ sortText: `0_${operation.name}`
3222
+ }));
3223
+ }
3224
+ return items;
3225
+ }
3226
+ /**
3227
+ * Get completions for named requests and script types after "run "
3228
+ */
3229
+ getNamedRequestCompletions(document, linePrefix) {
3230
+ const trimmed = linePrefix.trim();
3231
+ // Use shared logic for run completions
3232
+ return this.getRunCompletions(document, trimmed, linePrefix);
3233
+ }
3234
+ /**
3235
+ * Shared logic for run command completions - used by both standalone "run" and "var x = run"
3236
+ */
3237
+ getRunCompletions(document, textAfterContext, originalLinePrefix) {
3238
+ const fullText = document.getText();
3239
+ const localNamedRequests = (0, parser_1.extractNamedRequests)(fullText);
3240
+ const localSequences = (0, sequenceRunner_1.extractSequences)(fullText);
3241
+ const importedDefinitions = this.getImportedRunDefinitions(document);
3242
+ const items = [];
3243
+ const lowerText = textAfterContext.toLowerCase().trim();
3244
+ const namedRequests = [];
3245
+ const seenNamedRequests = new Set();
3246
+ for (const request of localNamedRequests) {
3247
+ const lowerName = request.name.toLowerCase();
3248
+ if (seenNamedRequests.has(lowerName)) {
3249
+ continue;
3250
+ }
3251
+ seenNamedRequests.add(lowerName);
3252
+ namedRequests.push({ request, source: 'local' });
3253
+ }
3254
+ for (const imported of importedDefinitions.namedRequests) {
3255
+ const lowerName = imported.request.name.toLowerCase();
3256
+ if (seenNamedRequests.has(lowerName)) {
3257
+ continue;
3258
+ }
3259
+ seenNamedRequests.add(lowerName);
3260
+ namedRequests.push({ request: imported.request, source: 'imported', sourcePath: imported.sourcePath });
3261
+ }
3262
+ const sequences = [];
3263
+ const seenSequences = new Set();
3264
+ for (const sequence of localSequences) {
3265
+ const lowerName = sequence.name.toLowerCase();
3266
+ if (seenSequences.has(lowerName)) {
3267
+ continue;
3268
+ }
3269
+ seenSequences.add(lowerName);
3270
+ sequences.push({ sequence, source: 'local' });
3271
+ }
3272
+ for (const imported of importedDefinitions.sequences) {
3273
+ const lowerName = imported.sequence.name.toLowerCase();
3274
+ if (seenSequences.has(lowerName)) {
3275
+ continue;
3276
+ }
3277
+ seenSequences.add(lowerName);
3278
+ sequences.push({ sequence: imported.sequence, source: 'imported', sourcePath: imported.sourcePath });
3279
+ }
3280
+ // Check if user typed 'run' exactly without trailing space
3281
+ // VS Code filters completions by the word being typed, so 'bash' won't show when user typed 'run'
3282
+ // We need to show 'run' as a completion that adds a space and retriggers
3283
+ const typedRunExactly = (lowerText === 'run' || 'run'.startsWith(lowerText)) && lowerText.length > 0 && !originalLinePrefix.endsWith(' ');
3284
+ if (typedRunExactly) {
3285
+ items.push(this.createCompletionItem('run', vscode.CompletionItemKind.Method, {
3286
+ insertText: 'run ',
3287
+ documentation: new vscode.MarkdownString('Execute a script, named request, SQL operation, or MCP command\n\n**Options:** `bash`, `powershell`, `js`, `readJson`, `sql`, `mcp`, or a sequence name'),
3288
+ sortText: '0_run',
3289
+ // Trigger IntelliSense again after inserting 'run '
3290
+ command: this.createTriggerSuggestCommand()
3291
+ }));
3292
+ return items;
3293
+ }
3294
+ // Determine what's after "run " (if anything)
3295
+ let afterRun = '';
3296
+ if (lowerText === 'run' || lowerText === '') {
3297
+ afterRun = '';
3298
+ }
3299
+ else if (lowerText.startsWith('run ')) {
3300
+ afterRun = lowerText.substring(4).trim();
3301
+ }
3302
+ else {
3303
+ // Might be starting fresh (empty context)
3304
+ afterRun = '';
3305
+ }
3306
+ // Add script type completions (bash, powershell, js, readJson)
3307
+ const scriptTypes = [
3308
+ { name: 'bash', doc: 'Execute a bash script' },
3309
+ { name: 'powershell', doc: 'Execute a PowerShell script' },
3310
+ { name: 'js', doc: 'Execute a Node.js script' },
3311
+ { name: 'readJson', doc: 'Load a JSON file. Access properties with {{var.property}}' },
3312
+ { name: 'sql', doc: 'Run a named SQL query or command from an imported .nornsql file' },
3313
+ { name: 'mcp', doc: 'List tools or call a tool from a configured MCP server' },
3314
+ ];
3315
+ for (const st of scriptTypes) {
3316
+ if (!afterRun || st.name.toLowerCase().startsWith(afterRun.toLowerCase())) {
3317
+ items.push(this.createCompletionItem(st.name, vscode.CompletionItemKind.Method, {
3318
+ insertText: st.name + ' ',
3319
+ documentation: new vscode.MarkdownString(st.doc),
3320
+ sortText: `0_${st.name}`,
3321
+ command: st.name === 'sql' || st.name === 'mcp'
3322
+ ? this.createTriggerSuggestCommand()
3323
+ : undefined
3324
+ }));
3325
+ }
3326
+ }
3327
+ // Add named request completions
3328
+ for (const entry of namedRequests) {
3329
+ const req = entry.request;
3330
+ if (afterRun && !req.name.toLowerCase().startsWith(afterRun)) {
3331
+ continue;
3332
+ }
3333
+ // Show the request method and URL in detail
3334
+ const methodMatch = req.content.match(/^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(.+)/im);
3335
+ const sourceSuffix = entry.source === 'imported' && entry.sourcePath
3336
+ ? ` (imported: ${path.basename(entry.sourcePath)})`
3337
+ : '';
3338
+ let detail;
3339
+ if (methodMatch) {
3340
+ detail = `${methodMatch[1]} ${methodMatch[2].split('\n')[0]}${sourceSuffix}`;
3341
+ }
3342
+ else if (sourceSuffix) {
3343
+ detail = `Named request${sourceSuffix}`;
3344
+ }
3345
+ items.push(this.createCompletionItem(req.name, vscode.CompletionItemKind.Function, {
3346
+ insertText: req.name,
3347
+ detail,
3348
+ documentation: new vscode.MarkdownString(`**Named Request:** \`${req.name}\`\n\n` +
3349
+ '```http\n' + req.content.substring(0, 200) + (req.content.length > 200 ? '...' : '') + '\n```' +
3350
+ (entry.source === 'imported' && entry.sourcePath
3351
+ ? `\n\n**Source:** \`${entry.sourcePath}\``
3352
+ : '')),
3353
+ sortText: entry.source === 'local'
3354
+ ? `1_${req.name}`
3355
+ : `1z_${req.name}`
3356
+ }));
3357
+ }
3358
+ // Add sequence completions
3359
+ for (const entry of sequences) {
3360
+ const seq = entry.sequence;
3361
+ if (afterRun && !seq.name.toLowerCase().startsWith(afterRun)) {
3362
+ continue;
3363
+ }
3364
+ const sequenceType = seq.isTest ? 'Test Sequence' : 'Sequence';
3365
+ const sourceSuffix = entry.source === 'imported' && entry.sourcePath
3366
+ ? ` (imported: ${path.basename(entry.sourcePath)})`
3367
+ : '';
3368
+ // Count steps in the sequence for info
3369
+ const stepCount = seq.content.split('\n').filter(l => l.trim() !== '').length;
3370
+ items.push(this.createCompletionItem(seq.name, vscode.CompletionItemKind.Module, {
3371
+ insertText: seq.name,
3372
+ detail: `${sequenceType}${sourceSuffix}`,
3373
+ documentation: new vscode.MarkdownString(`**Sequence:** \`${seq.name}\`\n\n` +
3374
+ `~${stepCount} steps\n\n` +
3375
+ 'Run this sequence. Variables set in the sequence will be available after it completes.' +
3376
+ (entry.source === 'imported' && entry.sourcePath
3377
+ ? `\n\n**Source:** \`${entry.sourcePath}\``
3378
+ : '')),
3379
+ sortText: entry.source === 'local'
3380
+ ? `2_${seq.name}`
3381
+ : `2z_${seq.name}`
3382
+ }));
3383
+ }
3384
+ return items;
3385
+ }
3386
+ /**
3387
+ * Get JSON variables that can be used for property assignment.
3388
+ * These are variables declared with "var x = run readJson ..."
3389
+ */
3390
+ getJsonVariableCompletions(document, linePrefix) {
3391
+ const items = [];
3392
+ const text = document.getText();
3393
+ const trimmed = linePrefix.trim().toLowerCase();
3394
+ // Only trigger if line starts with a letter (potential variable name) or is empty/whitespace
3395
+ // Don't trigger if it looks like a keyword, method, or other construct
3396
+ if (trimmed && !/^[a-zA-Z_]/.test(trimmed)) {
3397
+ return items;
3398
+ }
3399
+ // Find all JSON variable declarations
3400
+ const jsonVarRegex = /^\s*var\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*run\s+readJson\s+(.+)$/gm;
3401
+ let match;
3402
+ while ((match = jsonVarRegex.exec(text)) !== null) {
3403
+ const varName = match[1];
3404
+ const filePath = match[2].trim();
3405
+ // Filter by what user has typed (if anything)
3406
+ if (trimmed && !varName.toLowerCase().startsWith(trimmed)) {
3407
+ continue;
3408
+ }
3409
+ items.push(this.createCompletionItem(varName, vscode.CompletionItemKind.Variable, {
3410
+ insertText: varName,
3411
+ detail: 'JSON object',
3412
+ documentation: new vscode.MarkdownString(`**JSON Variable:** \`${varName}\`\n\n` +
3413
+ `**Source:** \`${filePath}\`\n\n` +
3414
+ 'Update a property:\n```norn\n' +
3415
+ `${varName}.propertyName = newValue\n` +
3416
+ `${varName}.nested.path = value\n` +
3417
+ `${varName}[0].name = value\n` +
3418
+ '```'),
3419
+ sortText: `0_json_${varName}`
3420
+ }));
3421
+ }
3422
+ return items;
3423
+ }
3424
+ /**
3425
+ * Check if user is typing a variable name for property assignment.
3426
+ * e.g., "config." or "data[0]."
3427
+ */
3428
+ isTypingPropertyAssignment(linePrefix) {
3429
+ // Disabled - user prefers to type properties manually
3430
+ return false;
3431
+ }
3432
+ /**
3433
+ * Get property completions for a JSON variable after typing the dot.
3434
+ */
3435
+ getPropertyAssignmentCompletions(document, linePrefix) {
3436
+ // Disabled - user prefers to type properties manually
3437
+ return [];
3438
+ }
3439
+ isResponseProducingSequenceStepType(type) {
3440
+ return type === 'request' ||
3441
+ type === 'varRequest' ||
3442
+ type === 'namedRequest' ||
3443
+ type === 'varRunSequence' ||
3444
+ type === 'apiRequest';
3445
+ }
3446
+ findResponseCaptureRequestSource(document, position, requestIndex) {
3447
+ if (!Number.isInteger(requestIndex) || requestIndex < 1) {
3448
+ return undefined;
3449
+ }
3450
+ const fullText = document.getText();
3451
+ const sequence = (0, sequenceRunner_1.extractSequences)(fullText).find(seq => position.line > seq.startLine && position.line < seq.endLine);
3452
+ if (!sequence) {
3453
+ return undefined;
3454
+ }
3455
+ const relativeCursorLine = position.line - sequence.startLine - 1;
3456
+ let seenRequests = 0;
3457
+ for (const step of (0, sequenceRunner_1.extractStepsFromSequence)(sequence.content)) {
3458
+ if (step.lineNumber >= relativeCursorLine) {
3459
+ break;
3460
+ }
3461
+ if (!this.isResponseProducingSequenceStepType(step.type)) {
3462
+ continue;
3463
+ }
3464
+ seenRequests++;
3465
+ if (seenRequests !== requestIndex) {
3466
+ continue;
3467
+ }
3468
+ const absoluteLine = sequence.startLine + 1 + step.lineNumber;
3469
+ if (absoluteLine < 0 || absoluteLine >= document.lineCount) {
3470
+ return undefined;
3471
+ }
3472
+ const request = (0, apiResponseIntellisenseCache_1.parseApiResponseRequestLine)(document.lineAt(absoluteLine).text);
3473
+ if (!request) {
3474
+ return undefined;
3475
+ }
3476
+ return { line: absoluteLine, request };
3477
+ }
3478
+ return undefined;
3479
+ }
3480
+ createGenericResponseBodyPropertyHint(requestNum) {
3481
+ return this.createCompletionItem('property', vscode.CompletionItemKind.Field, {
3482
+ insertText: '',
3483
+ detail: 'Body property path',
3484
+ documentation: new vscode.MarkdownString('Navigate into the response body.\n\n' +
3485
+ 'Examples:\n' +
3486
+ `- \`$${requestNum}.body.id\` - get the id field\n` +
3487
+ `- \`$${requestNum}.body.user.name\` - nested property\n` +
3488
+ `- \`$${requestNum}.body[0].id\` - array access`)
3489
+ });
3490
+ }
3491
+ getResponseCaptureBodyCompletionContext(linePrefix) {
3492
+ const match = linePrefix.match(/\$(\d+)\.body((?:\.[a-zA-Z_][a-zA-Z0-9_-]*|\[\d+\])*(?:\.)?)$/i);
3493
+ if (!match || !match[2]) {
3494
+ return undefined;
3495
+ }
3496
+ return {
3497
+ requestNum: match[1],
3498
+ bodySuffix: match[2] || ''
3499
+ };
3500
+ }
3501
+ getResponseCaptureHeaderCompletionContext(linePrefix) {
3502
+ const match = linePrefix.match(/\$(\d+)\.headers\.([a-zA-Z_][a-zA-Z0-9_-]*)?$/i);
3503
+ if (!match) {
3504
+ return undefined;
3505
+ }
3506
+ return {
3507
+ requestNum: match[1],
3508
+ partial: match[2] || ''
3509
+ };
3510
+ }
3511
+ getResponseCaptureTopLevelCompletionContext(linePrefix) {
3512
+ const match = linePrefix.match(/\$(\d+)\.([a-zA-Z_][a-zA-Z0-9_]*)?$/);
3513
+ if (!match) {
3514
+ return undefined;
3515
+ }
3516
+ return {
3517
+ requestNum: match[1],
3518
+ partial: match[2] || ''
3519
+ };
3520
+ }
3521
+ /**
3522
+ * Check if user is typing a response capture reference ($N. or $N.property.)
3523
+ * This is used inside sequences to capture response data.
3524
+ */
3525
+ isTypingResponseCapture(linePrefix) {
3526
+ return Boolean(this.getResponseCaptureBodyCompletionContext(linePrefix) ||
3527
+ this.getResponseCaptureHeaderCompletionContext(linePrefix) ||
3528
+ this.getResponseCaptureTopLevelCompletionContext(linePrefix));
3529
+ }
3530
+ /**
3531
+ * Get completions for response capture ($N.property)
3532
+ */
3533
+ getResponseCaptureCompletions(document, position, linePrefix) {
3534
+ const items = [];
3535
+ const bodyContext = this.getResponseCaptureBodyCompletionContext(linePrefix);
3536
+ if (bodyContext) {
3537
+ const requestSource = this.findResponseCaptureRequestSource(document, position, Number(bodyContext.requestNum));
3538
+ const cachedItems = requestSource
3539
+ ? this.getCachedApiBodyPropertyCompletions(document, requestSource.line, requestSource.request, bodyContext.bodySuffix, '')
3540
+ : undefined;
3541
+ if (cachedItems !== undefined) {
3542
+ return cachedItems;
3543
+ }
3544
+ items.push(this.createGenericResponseBodyPropertyHint(bodyContext.requestNum));
3545
+ return items;
3546
+ }
3547
+ const headerContext = this.getResponseCaptureHeaderCompletionContext(linePrefix);
3548
+ if (headerContext) {
3549
+ // Suggest common header names
3550
+ const commonHeaders = [
3551
+ 'Content-Type',
3552
+ 'Content-Length',
3553
+ 'Cache-Control',
3554
+ 'Set-Cookie',
3555
+ 'Authorization',
3556
+ 'X-Request-Id',
3557
+ 'X-RateLimit-Remaining',
3558
+ 'Location',
3559
+ 'ETag',
3560
+ ];
3561
+ for (const header of commonHeaders) {
3562
+ if (!headerContext.partial || header.toLowerCase().startsWith(headerContext.partial.toLowerCase())) {
3563
+ items.push(this.createCompletionItem(header, vscode.CompletionItemKind.Field, {
3564
+ insertText: header,
3565
+ detail: 'Response header',
3566
+ documentation: new vscode.MarkdownString(`Access the \`${header}\` response header from request $${headerContext.requestNum}.`)
3567
+ }));
3568
+ }
3569
+ }
3570
+ return items;
3571
+ }
3572
+ const captureMatch = this.getResponseCaptureTopLevelCompletionContext(linePrefix);
3573
+ if (!captureMatch) {
3574
+ return items;
3575
+ }
3576
+ const requestNum = captureMatch.requestNum;
3577
+ const partial = captureMatch.partial;
3578
+ // Top-level response properties
3579
+ const responseProperties = [
3580
+ {
3581
+ name: 'status',
3582
+ detail: 'number',
3583
+ doc: 'HTTP status code (e.g., 200, 404, 500)',
3584
+ example: 'assert $1.status == 200'
3585
+ },
3586
+ {
3587
+ name: 'statusText',
3588
+ detail: 'string',
3589
+ doc: 'HTTP status message (e.g., "OK", "Not Found")',
3590
+ example: 'var message = $1.statusText'
3591
+ },
3592
+ {
3593
+ name: 'headers',
3594
+ detail: 'object',
3595
+ doc: 'Response headers. Use `headers.Name` to access specific header.',
3596
+ example: '$1.headers.Content-Type'
3597
+ },
3598
+ {
3599
+ name: 'duration',
3600
+ detail: 'number',
3601
+ doc: 'Request duration in milliseconds',
3602
+ example: 'assert $1.duration < 1000'
3603
+ },
3604
+ {
3605
+ name: 'body',
3606
+ detail: 'any',
3607
+ doc: 'Response body. Use `body.path` to access nested properties.',
3608
+ example: 'var userId = $1.body.user.id'
3609
+ },
3610
+ {
3611
+ name: 'cookies',
3612
+ detail: 'array',
3613
+ doc: 'Cookies stored in the response session.',
3614
+ example: '$1.cookies'
3615
+ },
3616
+ ];
3617
+ for (const prop of responseProperties) {
3618
+ if (!partial || prop.name.toLowerCase().startsWith(partial.toLowerCase())) {
3619
+ // Sort: commonly used first
3620
+ const sortOrder = ['status', 'body', 'headers', 'statusText', 'duration', 'cookies'];
3621
+ items.push(this.createCompletionItem(prop.name, vscode.CompletionItemKind.Property, {
3622
+ insertText: prop.name,
3623
+ detail: prop.detail,
3624
+ documentation: new vscode.MarkdownString(`**${prop.name}** (\`${prop.detail}\`)\n\n${prop.doc}\n\n**Example:**\n\`\`\`norn\n${prop.example}\n\`\`\``),
3625
+ sortText: `${sortOrder.indexOf(prop.name)}_${prop.name}`
3626
+ }));
3627
+ }
3628
+ }
3629
+ return items;
3630
+ }
3631
+ /**
3632
+ * Check if user is typing a sequence tag (starts with @ at the beginning of a line)
3633
+ */
3634
+ isTypingSequenceTag(linePrefix) {
3635
+ const trimmed = linePrefix.trim();
3636
+ // Check if the line starts with @ or has @ after other tags
3637
+ // e.g., "@" or "@smo" or "@smoke @"
3638
+ return /^\s*@[a-zA-Z0-9_-]*$/.test(linePrefix) ||
3639
+ /^\s*(?:@[a-zA-Z_][a-zA-Z0-9_-]*(?:\([^)]+\))?\s+)+@[a-zA-Z0-9_-]*$/.test(linePrefix);
3640
+ }
3641
+ /**
3642
+ * Get completion items for sequence tags.
3643
+ * Scans the workspace for existing tags and suggests them.
3644
+ */
3645
+ getSequenceTagCompletions(document, linePrefix) {
3646
+ const items = [];
3647
+ const text = document.getText();
3648
+ // Extract the partial tag being typed (after the last @)
3649
+ const lastAtPos = linePrefix.lastIndexOf('@');
3650
+ const partial = linePrefix.substring(lastAtPos + 1).toLowerCase();
3651
+ // Collect all existing tags from the document
3652
+ const existingTags = new Map(); // name -> values (empty set for simple tags)
3653
+ const tagPattern = /@([a-zA-Z_][a-zA-Z0-9_-]*)(?:\(([^)]+)\))?/g;
3654
+ let match;
3655
+ while ((match = tagPattern.exec(text)) !== null) {
3656
+ const tagName = match[1];
3657
+ const tagValue = match[2];
3658
+ if (!existingTags.has(tagName)) {
3659
+ existingTags.set(tagName, new Set());
3660
+ }
3661
+ if (tagValue) {
3662
+ existingTags.get(tagName).add(tagValue);
3663
+ }
3664
+ }
3665
+ // Also check other .norn files in the workspace
3666
+ for (const otherDoc of vscode.workspace.textDocuments) {
3667
+ if (otherDoc.languageId === 'norn' && otherDoc.uri.toString() !== document.uri.toString()) {
3668
+ const otherText = otherDoc.getText();
3669
+ let otherMatch;
3670
+ const otherPattern = /@([a-zA-Z_][a-zA-Z0-9_-]*)(?:\(([^)]+)\))?/g;
3671
+ while ((otherMatch = otherPattern.exec(otherText)) !== null) {
3672
+ const tagName = otherMatch[1];
3673
+ const tagValue = otherMatch[2];
3674
+ if (!existingTags.has(tagName)) {
3675
+ existingTags.set(tagName, new Set());
3676
+ }
3677
+ if (tagValue) {
3678
+ existingTags.get(tagName).add(tagValue);
3679
+ }
3680
+ }
3681
+ }
3682
+ }
3683
+ // Add common/suggested tag names if no tags exist yet
3684
+ const suggestedTags = ['smoke', 'regression', 'integration', 'unit', 'slow', 'fast', 'priority', 'team', 'feature', 'wip', 'skip'];
3685
+ for (const tag of suggestedTags) {
3686
+ if (!existingTags.has(tag)) {
3687
+ existingTags.set(tag, new Set());
3688
+ }
3689
+ }
3690
+ // Generate completion items
3691
+ for (const [tagName, values] of existingTags) {
3692
+ if (!partial || tagName.toLowerCase().startsWith(partial)) {
3693
+ // Simple tag completion
3694
+ items.push(this.createCompletionItem(tagName, vscode.CompletionItemKind.Constant, {
3695
+ insertText: tagName,
3696
+ detail: 'Sequence tag',
3697
+ documentation: values.size > 0
3698
+ ? new vscode.MarkdownString(`**@${tagName}**\n\nExisting values: ${Array.from(values).map(v => `\`${v}\``).join(', ')}`)
3699
+ : new vscode.MarkdownString(`**@${tagName}**\n\nSimple tag for filtering sequences.`),
3700
+ sortText: `0_${tagName}`
3701
+ }));
3702
+ // If this tag has values, also suggest the key-value form
3703
+ for (const value of values) {
3704
+ const label = `${tagName}(${value})`;
3705
+ items.push(this.createCompletionItem(label, vscode.CompletionItemKind.Constant, {
3706
+ insertText: label,
3707
+ detail: 'Sequence tag with value',
3708
+ documentation: new vscode.MarkdownString(`**@${tagName}(${value})**\n\nKey-value tag for filtering sequences.`),
3709
+ sortText: `1_${tagName}_${value}`
3710
+ }));
3711
+ }
3712
+ }
3713
+ }
3714
+ // Add @data completion for parameterized tests
3715
+ if (!partial || 'data'.startsWith(partial)) {
3716
+ items.push(this.createCompletionItem('data', vscode.CompletionItemKind.Keyword, {
3717
+ insertText: new vscode.SnippetString('data($0)'),
3718
+ detail: 'Parameterized test data',
3719
+ documentation: new vscode.MarkdownString(`**@data(...)**\n\nProvides inline test data for parameterized test sequences.\n\n` +
3720
+ `Example:\n\`\`\`norn\n@data(1, "Widget")\n@data(2, "Gadget")\ntest sequence ItemTest(id, expectedName)\n ...\nend sequence\n\`\`\``),
3721
+ sortText: '00_data'
3722
+ }));
3723
+ }
3724
+ // Add @theory completion for external data files
3725
+ if (!partial || 'theory'.startsWith(partial)) {
3726
+ items.push(this.createCompletionItem('theory', vscode.CompletionItemKind.Keyword, {
3727
+ insertText: new vscode.SnippetString('theory("${1:./testdata.json}")'),
3728
+ detail: 'External test data file',
3729
+ documentation: new vscode.MarkdownString(`**@theory("file.json")**\n\nLoads test data from an external JSON file for parameterized test sequences.\n\n` +
3730
+ `Example:\n\`\`\`norn\n@theory("./items.json")\ntest sequence BulkTest(id, name, price)\n ...\nend sequence\n\`\`\`\n\n` +
3731
+ `JSON file format:\n\`\`\`json\n[\n { "id": 1, "name": "Widget", "price": 9.99 },\n { "id": 2, "name": "Gadget", "price": 19.99 }\n]\n\`\`\``),
3732
+ sortText: '00_theory'
3733
+ }));
3734
+ }
3735
+ return items;
3736
+ }
3737
+ }
3738
+ exports.HttpCompletionProvider = HttpCompletionProvider;
3739
+ //# sourceMappingURL=completionProvider.js.map