norn-cli 2.3.0 → 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.
- package/.claude/skills/norn-social-campaign/SKILL.md +70 -0
- package/CHANGELOG.md +6 -0
- package/demos/nornenv-region-refactor/README.md +64 -0
- package/dist/cli.js +360 -1
- package/out/apiResponseIntellisenseCache.js +394 -0
- package/out/assertionRunner.js +567 -0
- package/out/cacheDir.js +136 -0
- package/out/chatParticipant.js +763 -0
- package/out/cli/colors.js +127 -0
- package/out/cli/formatters/assertion.js +102 -0
- package/out/cli/formatters/index.js +23 -0
- package/out/cli/formatters/response.js +106 -0
- package/out/cli/formatters/summary.js +246 -0
- package/out/cli/redaction.js +237 -0
- package/out/cli/reporters/html.js +689 -0
- package/out/cli/reporters/index.js +22 -0
- package/out/cli/reporters/junit.js +226 -0
- package/out/codeLensProvider.js +351 -0
- package/out/compareContentProvider.js +85 -0
- package/out/completionProvider.js +3739 -0
- package/out/contractAssertionSummary.js +225 -0
- package/out/contractDecorationProvider.js +243 -0
- package/out/coverageCalculator.js +879 -0
- package/out/coveragePanel.js +597 -0
- package/out/debug/breakpointResolver.js +84 -0
- package/out/debug/breakpoints.js +52 -0
- package/out/debug/nornDebugAdapter.js +166 -0
- package/out/debug/nornDebugSession.js +613 -0
- package/out/debug/sequenceLocationIndex.js +77 -0
- package/out/debug/types.js +3 -0
- package/out/deepClone.js +21 -0
- package/out/diagnosticProvider.js +2554 -0
- package/out/environmentParser.js +736 -0
- package/out/environmentProvider.js +544 -0
- package/out/environmentTemplates.js +146 -0
- package/out/errors/formatError.js +113 -0
- package/out/errors/nornError.js +29 -0
- package/out/formUrlEncoded.js +89 -0
- package/out/httpClient.js +348 -0
- package/out/httpRuntimeOptions.js +16 -0
- package/out/importErrors.js +31 -0
- package/out/inlayHintResolver.js +70 -0
- package/out/jsonFileReader.js +323 -0
- package/out/mcpClient.js +193 -0
- package/out/mcpConfig.js +184 -0
- package/out/mcpToolIntellisenseCache.js +96 -0
- package/out/mcpToolSchema.js +50 -0
- package/out/nornConfig.js +132 -0
- package/out/nornHoverProvider.js +124 -0
- package/out/nornInlayHintsProvider.js +191 -0
- package/out/nornPrompt.js +755 -0
- package/out/nornSqlParser.js +286 -0
- package/out/nornapiHoverProvider.js +135 -0
- package/out/nornapiInlayHintsProvider.js +94 -0
- package/out/nornapiParser.js +324 -0
- package/out/nornenvCodeActionProvider.js +101 -0
- package/out/nornenvDecorationProvider.js +239 -0
- package/out/nornenvFoldingProvider.js +63 -0
- package/out/nornenvHoverProvider.js +114 -0
- package/out/nornenvInlayHintsProvider.js +99 -0
- package/out/nornenvLanguageModel.js +187 -0
- package/out/nornenvRegionRefactor.js +267 -0
- package/out/nornsqlHoverProvider.js +95 -0
- package/out/nornsqlInlayHintsProvider.js +114 -0
- package/out/parser.js +839 -0
- package/out/pathAccess.js +28 -0
- package/out/postmanImportPanel.js +732 -0
- package/out/postmanImportPlanner.js +1155 -0
- package/out/postmanImportSidebarView.js +532 -0
- package/out/quotedString.js +35 -0
- package/out/requestPreparation.js +179 -0
- package/out/requestValidation.js +146 -0
- package/out/responsePanel.js +7754 -0
- package/out/schemaGenerator.js +562 -0
- package/out/scriptRunner.js +419 -0
- package/out/secrets/cliSecrets.js +415 -0
- package/out/secrets/crypto.js +105 -0
- package/out/secrets/envFileSecrets.js +177 -0
- package/out/secrets/keyStore.js +259 -0
- package/out/sequenceDeclaration.js +15 -0
- package/out/sequenceRunner.js +3590 -0
- package/out/sqlAdapterRunner.js +122 -0
- package/out/sqlBuiltInAdapters.js +604 -0
- package/out/sqlConfig.js +184 -0
- package/out/starterCatalog.js +554 -0
- package/out/stringUtils.js +25 -0
- package/out/swaggerBodyIntellisenseCache.js +114 -0
- package/out/swaggerParser.js +464 -0
- package/out/testProvider.js +767 -0
- package/out/theoryCaseLoader.js +113 -0
- package/out/validationCache.js +211 -0
- package/package.json +6 -1
|
@@ -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
|