norn-cli 2.2.2 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +18 -0
- package/.claude/skills/norn-social-campaign/SKILL.md +70 -0
- package/CHANGELOG.md +22 -1
- package/LICENSE +20 -29
- package/README.md +32 -1
- package/demos/nornenv-region-refactor/README.md +64 -0
- package/demos/nornenv-showcase/README.md +62 -0
- package/demos/nornenv-showcase/norn.config.json +16 -0
- package/demos/nornenv-showcase/showcase.norn +70 -0
- package/demos/nornenv-showcase/showcase.nornapi +26 -0
- package/demos/nornenv-showcase/showcase.nornsql +20 -0
- package/dist/cli.js +564 -54
- 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 +38 -11
- package/.kanbn/index.md +0 -31
- package/.kanbn/tasks/book-first-mentor-session.md +0 -13
- package/.kanbn/tasks/decide-what-success-in-a-pilot-looks-like.md +0 -9
- package/.kanbn/tasks/do-5-customer-conversations.md +0 -9
- package/.kanbn/tasks/finalise-the-one-line-pitch.md +0 -11
- package/.kanbn/tasks/interview-script.md +0 -49
- package/.kanbn/tasks/make-a-list-of-10-people-to-speak-to.md +0 -11
- package/.kanbn/tasks/prepare-your-customer-interview-questions.md +0 -11
- package/.kanbn/tasks/recruit-2/342/200/2233-pilot-users.md +0 -9
- package/.kanbn/tasks/refine-your-pitch.md +0 -9
- package/.kanbn/tasks/use-the-shiplight-website-as-a-template-to-improve-norn-website.md +0 -9
- package/.kanbn/tasks/write-down-repeated-wording.md +0 -9
- package/.kanbn/tasks/write-the-one-pager.md +0 -27
|
@@ -0,0 +1,2554 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.DiagnosticProvider = void 0;
|
|
37
|
+
const vscode = __importStar(require("vscode"));
|
|
38
|
+
const path = __importStar(require("path"));
|
|
39
|
+
const fs = __importStar(require("fs"));
|
|
40
|
+
const parser_1 = require("./parser");
|
|
41
|
+
const sequenceRunner_1 = require("./sequenceRunner");
|
|
42
|
+
const assertionRunner_1 = require("./assertionRunner");
|
|
43
|
+
const environmentProvider_1 = require("./environmentProvider");
|
|
44
|
+
const nornapiParser_1 = require("./nornapiParser");
|
|
45
|
+
const jsonFileReader_1 = require("./jsonFileReader");
|
|
46
|
+
const environmentParser_1 = require("./environmentParser");
|
|
47
|
+
const crypto_1 = require("./secrets/crypto");
|
|
48
|
+
const nornSqlParser_1 = require("./nornSqlParser");
|
|
49
|
+
const sqlConfig_1 = require("./sqlConfig");
|
|
50
|
+
const formUrlEncoded_1 = require("./formUrlEncoded");
|
|
51
|
+
const mcpConfig_1 = require("./mcpConfig");
|
|
52
|
+
const nornConfig_1 = require("./nornConfig");
|
|
53
|
+
const stringUtils_1 = require("./stringUtils");
|
|
54
|
+
const requestValidation_1 = require("./requestValidation");
|
|
55
|
+
function pushNornDiagnostic(diagnostics, range, message, severity, code) {
|
|
56
|
+
const diagnostic = new vscode.Diagnostic(range, message, severity);
|
|
57
|
+
diagnostic.source = 'Norn';
|
|
58
|
+
if (code) {
|
|
59
|
+
diagnostic.code = code;
|
|
60
|
+
}
|
|
61
|
+
diagnostics.push(diagnostic);
|
|
62
|
+
}
|
|
63
|
+
function createTokenRange(lines, lineNumber, token) {
|
|
64
|
+
const line = lines[lineNumber] ?? '';
|
|
65
|
+
const startCol = Math.max(0, line.indexOf(token));
|
|
66
|
+
return new vscode.Range(new vscode.Position(lineNumber, startCol), new vscode.Position(lineNumber, startCol + token.length));
|
|
67
|
+
}
|
|
68
|
+
function parseNornenvTemplateReference(rawReference) {
|
|
69
|
+
const match = rawReference.trim().match(/^(?:\$env\.)?([a-zA-Z_][a-zA-Z0-9_]*)$/);
|
|
70
|
+
return match?.[1];
|
|
71
|
+
}
|
|
72
|
+
function getNornenvTemplateDeclarationForDiagnostics(line, lineNumber, section) {
|
|
73
|
+
const connectionMatch = line.match(/^(\s*)(?:secret\s+)?connectionString\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.*)$/i);
|
|
74
|
+
if (connectionMatch) {
|
|
75
|
+
return {
|
|
76
|
+
name: `${connectionMatch[2]}_connectionString`,
|
|
77
|
+
sectionKind: section?.kind,
|
|
78
|
+
sectionName: section?.name,
|
|
79
|
+
lineNumber,
|
|
80
|
+
value: connectionMatch[3],
|
|
81
|
+
valueStart: connectionMatch[0].length - connectionMatch[3].length
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const variableMatch = line.match(/^(\s*)(?:secret|var)\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.*)$/i);
|
|
85
|
+
if (variableMatch) {
|
|
86
|
+
return {
|
|
87
|
+
name: variableMatch[2],
|
|
88
|
+
sectionKind: section?.kind,
|
|
89
|
+
sectionName: section?.name,
|
|
90
|
+
lineNumber,
|
|
91
|
+
value: variableMatch[3],
|
|
92
|
+
valueStart: variableMatch[0].length - variableMatch[3].length
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
function parseNornenvSectionForDiagnostics(line, lineNumber) {
|
|
98
|
+
const match = line.trim().match(/^\[(env|template):([a-zA-Z_][a-zA-Z0-9_-]*)(?:\s+extends\s+([^\]]+))?\]\s*$/i);
|
|
99
|
+
if (!match) {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
const parents = (match[3] ?? '')
|
|
103
|
+
.split(',')
|
|
104
|
+
.map(parent => parent.trim())
|
|
105
|
+
.filter(parent => /^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(parent));
|
|
106
|
+
return {
|
|
107
|
+
kind: match[1].toLowerCase(),
|
|
108
|
+
name: match[2],
|
|
109
|
+
parents,
|
|
110
|
+
lineNumber
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function getNornenvParentReferencesForDiagnostics(line, section) {
|
|
114
|
+
const extendsMatch = line.match(/\bextends\b/i);
|
|
115
|
+
const closeBracket = line.lastIndexOf(']');
|
|
116
|
+
if (!extendsMatch || closeBracket < 0 || extendsMatch.index === undefined) {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
const clauseStart = extendsMatch.index + extendsMatch[0].length;
|
|
120
|
+
const clause = line.slice(clauseStart, closeBracket);
|
|
121
|
+
const refs = [];
|
|
122
|
+
const tokenRegex = /[a-zA-Z_][a-zA-Z0-9_-]*/g;
|
|
123
|
+
for (const match of clause.matchAll(tokenRegex)) {
|
|
124
|
+
if (match.index === undefined) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
refs.push({
|
|
128
|
+
section,
|
|
129
|
+
name: match[0],
|
|
130
|
+
start: clauseStart + match.index,
|
|
131
|
+
end: clauseStart + match.index + match[0].length
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return refs;
|
|
135
|
+
}
|
|
136
|
+
function levenshteinDistance(left, right) {
|
|
137
|
+
const a = left.toLowerCase();
|
|
138
|
+
const b = right.toLowerCase();
|
|
139
|
+
const previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
140
|
+
const current = Array.from({ length: b.length + 1 }, () => 0);
|
|
141
|
+
for (let i = 1; i <= a.length; i++) {
|
|
142
|
+
current[0] = i;
|
|
143
|
+
for (let j = 1; j <= b.length; j++) {
|
|
144
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
145
|
+
current[j] = Math.min(current[j - 1] + 1, previous[j] + 1, previous[j - 1] + cost);
|
|
146
|
+
}
|
|
147
|
+
for (let j = 0; j <= b.length; j++) {
|
|
148
|
+
previous[j] = current[j];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return previous[b.length];
|
|
152
|
+
}
|
|
153
|
+
function findClosestNornenvName(name, candidates) {
|
|
154
|
+
let bestName;
|
|
155
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
156
|
+
for (const candidate of candidates) {
|
|
157
|
+
const distance = levenshteinDistance(name, candidate);
|
|
158
|
+
if (distance < bestDistance) {
|
|
159
|
+
bestDistance = distance;
|
|
160
|
+
bestName = candidate;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const maxDistance = name.length <= 5 ? 1 : 2;
|
|
164
|
+
return bestDistance <= maxDistance ? bestName : undefined;
|
|
165
|
+
}
|
|
166
|
+
function buildDeclaredNornenvNameSet(config) {
|
|
167
|
+
const names = new Set(Object.keys(config.common));
|
|
168
|
+
for (const template of config.templates) {
|
|
169
|
+
for (const name of Object.keys(template.variables)) {
|
|
170
|
+
names.add(name);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const env of config.environments) {
|
|
174
|
+
for (const name of Object.keys(env.variables)) {
|
|
175
|
+
names.add(name);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return names;
|
|
179
|
+
}
|
|
180
|
+
function buildAllowedNornenvReferenceNames(declaration, config) {
|
|
181
|
+
const names = new Set(Object.keys(config.common));
|
|
182
|
+
if (!declaration.sectionName) {
|
|
183
|
+
for (const name of buildDeclaredNornenvNameSet(config)) {
|
|
184
|
+
names.add(name);
|
|
185
|
+
}
|
|
186
|
+
return names;
|
|
187
|
+
}
|
|
188
|
+
for (const name of (0, environmentParser_1.collectAncestorVariableNames)(declaration.sectionName, config)) {
|
|
189
|
+
names.add(name);
|
|
190
|
+
}
|
|
191
|
+
return names;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Extracts variables defined within a specific sequence, including:
|
|
195
|
+
* - var statements
|
|
196
|
+
* - var x = run Sequence (capture from returned sequences)
|
|
197
|
+
* - Response captures (var x = $1.body)
|
|
198
|
+
* - Sequence parameters (from the sequence declaration)
|
|
199
|
+
*/
|
|
200
|
+
function getSequenceLocalVariables(sequenceContent, parameters) {
|
|
201
|
+
const vars = new Set();
|
|
202
|
+
// Add sequence parameters as local variables
|
|
203
|
+
if (parameters) {
|
|
204
|
+
for (const param of parameters) {
|
|
205
|
+
vars.add(param.name);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const lines = sequenceContent.split('\n');
|
|
209
|
+
for (const line of lines) {
|
|
210
|
+
const trimmed = line.trim();
|
|
211
|
+
// Match any var assignment
|
|
212
|
+
const varMatch = trimmed.match(/^var\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*=/);
|
|
213
|
+
if (varMatch) {
|
|
214
|
+
vars.add(varMatch[1]);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return vars;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Check if a line is a comment (starts with #)
|
|
221
|
+
*/
|
|
222
|
+
function isCommentLine(line) {
|
|
223
|
+
return line.trim().startsWith('#');
|
|
224
|
+
}
|
|
225
|
+
function getEnvScopedBaseName(pathPart) {
|
|
226
|
+
const trimmed = pathPart.replace(/^\./, '');
|
|
227
|
+
const match = trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_]*)/);
|
|
228
|
+
return match ? match[1] : undefined;
|
|
229
|
+
}
|
|
230
|
+
function getAllDeclaredEnvironmentVariableNames(sourceFilePath) {
|
|
231
|
+
const envFilePath = (0, environmentProvider_1.findEnvFileFromPath)(sourceFilePath);
|
|
232
|
+
if (!envFilePath || !fs.existsSync(envFilePath)) {
|
|
233
|
+
return new Set();
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
const content = fs.readFileSync(envFilePath, 'utf-8');
|
|
237
|
+
const config = (0, environmentParser_1.parseEnvFile)(content, envFilePath);
|
|
238
|
+
const resolved = (0, environmentParser_1.resolveNornenvImports)(config, path.dirname(envFilePath), envFilePath, filePath => fs.readFileSync(filePath, 'utf-8'));
|
|
239
|
+
const names = new Set(Object.keys(resolved.config.common));
|
|
240
|
+
for (const template of resolved.config.templates) {
|
|
241
|
+
for (const name of Object.keys(template.variables)) {
|
|
242
|
+
names.add(name);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
for (const env of resolved.config.environments) {
|
|
246
|
+
for (const name of (0, environmentParser_1.resolveEffectiveEnvVariableDetails)(env.name, resolved.config).keys()) {
|
|
247
|
+
names.add(name);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return names;
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
return new Set();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function getNornapiVariableDiagnosticContext(sourceFilePath) {
|
|
257
|
+
const activeEnvironment = (0, environmentProvider_1.getActiveEnvironment)(sourceFilePath);
|
|
258
|
+
const envFilePath = (0, environmentProvider_1.findEnvFileFromPath)(sourceFilePath);
|
|
259
|
+
const activeVariableNames = new Set(Object.keys((0, environmentProvider_1.getEnvironmentVariables)(sourceFilePath)));
|
|
260
|
+
const allEnvironmentVariableNames = getAllDeclaredEnvironmentVariableNames(sourceFilePath);
|
|
261
|
+
const validationVariableNames = activeEnvironment ? activeVariableNames : allEnvironmentVariableNames;
|
|
262
|
+
return {
|
|
263
|
+
activeVariableNames,
|
|
264
|
+
allEnvironmentVariableNames,
|
|
265
|
+
validationVariableNames,
|
|
266
|
+
hasEnvFile: Boolean(envFilePath),
|
|
267
|
+
activeEnvironment,
|
|
268
|
+
availableEnvironments: (0, environmentProvider_1.getAvailableEnvironments)(sourceFilePath)
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
function buildNornapiVariableDiagnosticMessage(varName, envScoped, context) {
|
|
272
|
+
if (envScoped) {
|
|
273
|
+
if (!context.hasEnvFile) {
|
|
274
|
+
return `Undefined environment variable: '${varName}'. Add a .nornenv file that defines it.`;
|
|
275
|
+
}
|
|
276
|
+
if (!context.activeEnvironment && context.availableEnvironments.length > 0) {
|
|
277
|
+
return `Undefined environment variable: '${varName}'. Select an environment that defines it or add it to the common .nornenv variables.`;
|
|
278
|
+
}
|
|
279
|
+
if (context.activeEnvironment) {
|
|
280
|
+
return `Undefined environment variable: '${varName}' in active environment '${context.activeEnvironment}'.`;
|
|
281
|
+
}
|
|
282
|
+
return `Undefined environment variable: '${varName}' in .nornenv.`;
|
|
283
|
+
}
|
|
284
|
+
if (!context.hasEnvFile) {
|
|
285
|
+
return `Undefined endpoint variable: '${varName}'. Add a .nornenv file that defines it.`;
|
|
286
|
+
}
|
|
287
|
+
if (context.activeEnvironment && context.allEnvironmentVariableNames.has(varName)) {
|
|
288
|
+
return `Endpoint variable '${varName}' is not defined for active environment '${context.activeEnvironment}'.`;
|
|
289
|
+
}
|
|
290
|
+
return `Undefined endpoint variable: '${varName}'. Define it in .nornenv or use {${varName}} if it should be an endpoint parameter.`;
|
|
291
|
+
}
|
|
292
|
+
function addNornapiPlaceholderDiagnostics(diagnostics, lineNumber, lineText, searchText, searchStartCol, context, includeUnscoped) {
|
|
293
|
+
for (const reference of (0, requestValidation_1.findPlaceholderReferences)(searchText)) {
|
|
294
|
+
if (/^\$\d+$/.test(reference.name)) {
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const envScoped = reference.name === '$env';
|
|
298
|
+
if (!envScoped && !includeUnscoped) {
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const varName = envScoped ? getEnvScopedBaseName(reference.pathPart) : reference.name;
|
|
302
|
+
if (!varName) {
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const availableNames = envScoped ? context.activeVariableNames : context.validationVariableNames;
|
|
306
|
+
if (availableNames.has(varName)) {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
const startCol = Math.max(0, searchStartCol + reference.index);
|
|
310
|
+
const endCol = Math.min(lineText.length, startCol + reference.text.length);
|
|
311
|
+
const range = new vscode.Range(new vscode.Position(lineNumber, startCol), new vscode.Position(lineNumber, endCol));
|
|
312
|
+
const code = envScoped ? 'undefined-env-variable' : 'undefined-endpoint-variable';
|
|
313
|
+
if (diagnostics.some(d => d.code === code &&
|
|
314
|
+
d.range.start.line === range.start.line &&
|
|
315
|
+
d.range.start.character === range.start.character &&
|
|
316
|
+
d.range.end.character === range.end.character)) {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
pushNornDiagnostic(diagnostics, range, buildNornapiVariableDiagnosticMessage(varName, envScoped, context), vscode.DiagnosticSeverity.Error, code);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
function isHttpRequestStart(line) {
|
|
323
|
+
return /^(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+/i.test(line.trim());
|
|
324
|
+
}
|
|
325
|
+
function isRequestBodyBoundary(line) {
|
|
326
|
+
const trimmed = line.trim();
|
|
327
|
+
if (!trimmed) {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
return trimmed.startsWith('###') ||
|
|
331
|
+
trimmed.startsWith('@') ||
|
|
332
|
+
isHttpRequestStart(trimmed) ||
|
|
333
|
+
/^\[/.test(trimmed) ||
|
|
334
|
+
/^import\s+/i.test(trimmed) ||
|
|
335
|
+
/^(?:test\s+)?sequence\s+/i.test(trimmed) ||
|
|
336
|
+
/^end\s+sequence$/i.test(trimmed) ||
|
|
337
|
+
/^(?:end\s+if|endif)\b/i.test(trimmed) ||
|
|
338
|
+
/^var\s+/i.test(trimmed) ||
|
|
339
|
+
/^(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?run\s+/i.test(trimmed) ||
|
|
340
|
+
/^assert\s+/i.test(trimmed) ||
|
|
341
|
+
/^print\s+/i.test(trimmed) ||
|
|
342
|
+
/^if\s+/i.test(trimmed) ||
|
|
343
|
+
/^wait\s+/i.test(trimmed) ||
|
|
344
|
+
/^foreach\s+/i.test(trimmed) ||
|
|
345
|
+
/^return\s+/i.test(trimmed);
|
|
346
|
+
}
|
|
347
|
+
function countChar(value, char) {
|
|
348
|
+
let count = 0;
|
|
349
|
+
for (let i = 0; i < value.length; i++) {
|
|
350
|
+
if (value[i] === char) {
|
|
351
|
+
count++;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return count;
|
|
355
|
+
}
|
|
356
|
+
function looksLikeJsonPropertyLine(line) {
|
|
357
|
+
return /^"[^"\\]*(?:\\.[^"\\]*)*"\s*:\s*.+$/.test(line.trim());
|
|
358
|
+
}
|
|
359
|
+
function isLikelyJsonBodyLine(line) {
|
|
360
|
+
const trimmed = line.trim();
|
|
361
|
+
if (!trimmed) {
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
return trimmed.startsWith('{') ||
|
|
365
|
+
trimmed.startsWith('}') ||
|
|
366
|
+
trimmed.startsWith('[') ||
|
|
367
|
+
trimmed.startsWith(']') ||
|
|
368
|
+
trimmed.startsWith('"') ||
|
|
369
|
+
looksLikeJsonPropertyLine(trimmed) ||
|
|
370
|
+
/^,/.test(trimmed);
|
|
371
|
+
}
|
|
372
|
+
function isLikelyFormBodyLine(line) {
|
|
373
|
+
const trimmed = line.trim();
|
|
374
|
+
if (!trimmed) {
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
if (/^[A-Za-z0-9_.-]+\s*=\s*.+$/.test(trimmed)) {
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
// Colon lines may be headers or form body. Treat as body-like to avoid false positives.
|
|
381
|
+
if (/^[A-Za-z0-9_.-]+\s*:\s*.+$/.test(trimmed)) {
|
|
382
|
+
return true;
|
|
383
|
+
}
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
function isLikelyRequestBodyValueLine(line) {
|
|
387
|
+
const trimmed = line.trim();
|
|
388
|
+
if (!trimmed) {
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
if (isLikelyJsonBodyLine(trimmed) || isLikelyFormBodyLine(trimmed)) {
|
|
392
|
+
return true;
|
|
393
|
+
}
|
|
394
|
+
// Primitive JSON values can be valid standalone request bodies.
|
|
395
|
+
if (/^(true|false|null)$/i.test(trimmed) || /^-?\d+(?:\.\d+)?$/.test(trimmed)) {
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
// Allow a variable/path reference used as the body (e.g. "payload" or "payload.items[0]").
|
|
399
|
+
if (/^[a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*|\[\d+\])*$/.test(trimmed)) {
|
|
400
|
+
return true;
|
|
401
|
+
}
|
|
402
|
+
// Placeholder-only line can also be a valid body.
|
|
403
|
+
if (/^\{\{[^\r\n}]+\}\}$/.test(trimmed)) {
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
406
|
+
// Header group line in a request block ("Json") should not break body-context detection.
|
|
407
|
+
if (/^[A-Z][a-zA-Z0-9_]*$/.test(trimmed)) {
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
function isInsideRequestBlock(lines, lineIndex) {
|
|
413
|
+
for (let j = lineIndex - 1; j >= 0; j--) {
|
|
414
|
+
const rawLine = lines[j];
|
|
415
|
+
if (isCommentLine(rawLine)) {
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
const trimmed = (0, stringUtils_1.stripInlineComment)(rawLine).trim();
|
|
419
|
+
if (!trimmed) {
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
if (isHttpRequestStart(trimmed)) {
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
if (isRequestBodyBoundary(trimmed)) {
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
if (isLikelyRequestBodyValueLine(trimmed)) {
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
function isKnownNornCommandOrStructureStart(line) {
|
|
436
|
+
const trimmed = line.trim();
|
|
437
|
+
if (!trimmed) {
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
if (trimmed.startsWith('###') ||
|
|
441
|
+
trimmed.startsWith('@') ||
|
|
442
|
+
/^\[.*\]$/.test(trimmed) ||
|
|
443
|
+
/^import\s+/i.test(trimmed) ||
|
|
444
|
+
/^(?:test\s+)?sequence\s+/i.test(trimmed) ||
|
|
445
|
+
/^end\s+sequence$/i.test(trimmed) ||
|
|
446
|
+
/^(?:end\s+if|endif)$/i.test(trimmed) ||
|
|
447
|
+
/^if\s+/i.test(trimmed) ||
|
|
448
|
+
/^wait\s+/i.test(trimmed) ||
|
|
449
|
+
/^assert\s+/i.test(trimmed) ||
|
|
450
|
+
/^print\s+/i.test(trimmed) ||
|
|
451
|
+
/^return(?:\s+.+)?$/i.test(trimmed) ||
|
|
452
|
+
/^foreach\s+/i.test(trimmed) ||
|
|
453
|
+
/^var\s+/i.test(trimmed) ||
|
|
454
|
+
/^(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?run\s+/i.test(trimmed) ||
|
|
455
|
+
(0, jsonFileReader_1.parsePropertyAssignment)(trimmed) !== null ||
|
|
456
|
+
isHttpRequestStart(trimmed)) {
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
// Request block continuations / body-ish lines
|
|
460
|
+
if (/^[A-Za-z0-9\-_]+\s*:\s*.+$/.test(trimmed) || // header lines
|
|
461
|
+
isLikelyJsonBodyLine(trimmed) ||
|
|
462
|
+
isLikelyFormBodyLine(trimmed) ||
|
|
463
|
+
/^[A-Z][a-zA-Z0-9_]*$/.test(trimmed)) { // possible header-group line
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
function isLikelyPartialCommand(line) {
|
|
469
|
+
const trimmed = line.trim().toLowerCase();
|
|
470
|
+
if (!trimmed) {
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
const partials = [
|
|
474
|
+
'g', 'ge', 'p', 'po', 'pos', 'pu', 'pat', 'del', 'hea', 'opt',
|
|
475
|
+
'v', 'va', 'var',
|
|
476
|
+
'a', 'as', 'ass', 'asse', 'asser',
|
|
477
|
+
'assert',
|
|
478
|
+
'pr', 'pri', 'prin', 'print',
|
|
479
|
+
'r', 'ru', 'run',
|
|
480
|
+
'i', 'im', 'imp', 'impo', 'impor', 'import',
|
|
481
|
+
'ret', 'retu', 'retur', 'return',
|
|
482
|
+
'seq', 'sequ', 'seque', 'sequen', 'sequenc', 'sequence',
|
|
483
|
+
'te', 'tes',
|
|
484
|
+
'w', 'wa', 'wai', 'wait',
|
|
485
|
+
'if',
|
|
486
|
+
'en', 'end', 'end ', 'endif', 'end if',
|
|
487
|
+
'test', 'test sequence'
|
|
488
|
+
];
|
|
489
|
+
return partials.includes(trimmed);
|
|
490
|
+
}
|
|
491
|
+
function isSingleEditOrTransposeAway(a, b) {
|
|
492
|
+
if (a === b) {
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
if (Math.abs(a.length - b.length) > 1) {
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
// Transposition check (same length)
|
|
499
|
+
if (a.length === b.length) {
|
|
500
|
+
const mismatches = [];
|
|
501
|
+
for (let i = 0; i < a.length; i++) {
|
|
502
|
+
if (a[i] !== b[i]) {
|
|
503
|
+
mismatches.push(i);
|
|
504
|
+
if (mismatches.length > 2) {
|
|
505
|
+
break;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (mismatches.length === 2) {
|
|
510
|
+
const [i, j] = mismatches;
|
|
511
|
+
if (j === i + 1 && a[i] === b[j] && a[j] === b[i]) {
|
|
512
|
+
return true;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
// One-edit (insert/delete/replace)
|
|
517
|
+
let i = 0;
|
|
518
|
+
let j = 0;
|
|
519
|
+
let edits = 0;
|
|
520
|
+
while (i < a.length && j < b.length) {
|
|
521
|
+
if (a[i] === b[j]) {
|
|
522
|
+
i++;
|
|
523
|
+
j++;
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
edits++;
|
|
527
|
+
if (edits > 1) {
|
|
528
|
+
return false;
|
|
529
|
+
}
|
|
530
|
+
if (a.length > b.length) {
|
|
531
|
+
i++;
|
|
532
|
+
}
|
|
533
|
+
else if (b.length > a.length) {
|
|
534
|
+
j++;
|
|
535
|
+
}
|
|
536
|
+
else {
|
|
537
|
+
i++;
|
|
538
|
+
j++;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
if (i < a.length || j < b.length) {
|
|
542
|
+
edits++;
|
|
543
|
+
}
|
|
544
|
+
return edits === 1;
|
|
545
|
+
}
|
|
546
|
+
function suggestClosestKeyword(token, candidates) {
|
|
547
|
+
const lower = token.toLowerCase();
|
|
548
|
+
return candidates.find(candidate => isSingleEditOrTransposeAway(lower, candidate.toLowerCase()));
|
|
549
|
+
}
|
|
550
|
+
function splitRequestUrlAndTail(afterMethod) {
|
|
551
|
+
const trimmed = afterMethod.trim();
|
|
552
|
+
if (!trimmed) {
|
|
553
|
+
return { urlToken: undefined, tail: '' };
|
|
554
|
+
}
|
|
555
|
+
if (trimmed.startsWith('"') || trimmed.startsWith("'")) {
|
|
556
|
+
const quote = trimmed[0];
|
|
557
|
+
let escaped = false;
|
|
558
|
+
for (let i = 1; i < trimmed.length; i++) {
|
|
559
|
+
const ch = trimmed[i];
|
|
560
|
+
if (escaped) {
|
|
561
|
+
escaped = false;
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
if (ch === '\\') {
|
|
565
|
+
escaped = true;
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
if (ch === quote) {
|
|
569
|
+
return {
|
|
570
|
+
urlToken: trimmed.slice(0, i + 1),
|
|
571
|
+
tail: trimmed.slice(i + 1).trim()
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
// Unclosed quote - likely still typing.
|
|
576
|
+
return { urlToken: undefined, tail: '' };
|
|
577
|
+
}
|
|
578
|
+
// Endpoint invocation syntax can include spaces/commas/colons inside args, e.g.
|
|
579
|
+
// GET MultiParam(hello, world) or GET MultiParam(param1: foo, param2: bar)
|
|
580
|
+
const endpointCallMatch = trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_-]*)\s*\(/);
|
|
581
|
+
if (endpointCallMatch) {
|
|
582
|
+
let depth = 0;
|
|
583
|
+
let inSingleQuote = false;
|
|
584
|
+
let inDoubleQuote = false;
|
|
585
|
+
let escaped = false;
|
|
586
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
587
|
+
const ch = trimmed[i];
|
|
588
|
+
if (escaped) {
|
|
589
|
+
escaped = false;
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
if (ch === '\\') {
|
|
593
|
+
escaped = true;
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
if (ch === '"' && !inSingleQuote) {
|
|
597
|
+
inDoubleQuote = !inDoubleQuote;
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
if (ch === '\'' && !inDoubleQuote) {
|
|
601
|
+
inSingleQuote = !inSingleQuote;
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
if (inSingleQuote || inDoubleQuote) {
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
if (ch === '(') {
|
|
608
|
+
depth++;
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
if (ch === ')') {
|
|
612
|
+
depth--;
|
|
613
|
+
if (depth === 0) {
|
|
614
|
+
return {
|
|
615
|
+
urlToken: trimmed.slice(0, i + 1).trim(),
|
|
616
|
+
tail: trimmed.slice(i + 1).trim()
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
// Unclosed endpoint-call args - likely still typing.
|
|
623
|
+
return { urlToken: undefined, tail: '' };
|
|
624
|
+
}
|
|
625
|
+
const firstSpace = trimmed.search(/\s/);
|
|
626
|
+
if (firstSpace === -1) {
|
|
627
|
+
return { urlToken: trimmed, tail: '' };
|
|
628
|
+
}
|
|
629
|
+
return {
|
|
630
|
+
urlToken: trimmed.slice(0, firstSpace),
|
|
631
|
+
tail: trimmed.slice(firstSpace).trim()
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
function stripRetryBackoffTokens(tail) {
|
|
635
|
+
return tail
|
|
636
|
+
.replace(/\bretry\s+\d+\b/gi, ' ')
|
|
637
|
+
.replace(/\bbackoff\s+\d+(?:\.\d+)?\s*(?:s|ms|seconds?|milliseconds?)?\b/gi, ' ')
|
|
638
|
+
.replace(/\s+/g, ' ')
|
|
639
|
+
.trim();
|
|
640
|
+
}
|
|
641
|
+
function isValidSequenceDeclarationLine(line) {
|
|
642
|
+
return /^(?:test\s+)?sequence\s+[a-zA-Z_][a-zA-Z0-9_-]*(?:\s*\([^)]*\))?\s*$/i.test(line.trim());
|
|
643
|
+
}
|
|
644
|
+
function isValidWaitCommandLine(line) {
|
|
645
|
+
return /^wait\s+\d+(?:\.\d+)?\s*(?:s|ms|seconds?|milliseconds?)?$/i.test(line.trim());
|
|
646
|
+
}
|
|
647
|
+
function isValidRunLikeCommandLine(line) {
|
|
648
|
+
const trimmed = line.trim();
|
|
649
|
+
// Script runs (including pwsh alias)
|
|
650
|
+
if (/^(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?run\s+(?:bash|powershell|pwsh|js)\s+.+$/i.test(trimmed)) {
|
|
651
|
+
return true;
|
|
652
|
+
}
|
|
653
|
+
// readJson command
|
|
654
|
+
if (/^(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?run\s+readjson\s+.+$/i.test(trimmed)) {
|
|
655
|
+
return true;
|
|
656
|
+
}
|
|
657
|
+
// SQL command
|
|
658
|
+
if (/^(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?run\s+sql\s+[a-zA-Z_][a-zA-Z0-9_-]*(?:\s*\([^)]*\))?\s*$/i.test(trimmed)) {
|
|
659
|
+
return true;
|
|
660
|
+
}
|
|
661
|
+
// MCP commands
|
|
662
|
+
if (/^(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?run\s+mcp\s+list\s+[a-zA-Z_][a-zA-Z0-9_-]*\s*$/i.test(trimmed)) {
|
|
663
|
+
return true;
|
|
664
|
+
}
|
|
665
|
+
if (/^(?: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_.:-]*(?:\s*\(.*\))?\s*$/i.test(trimmed)) {
|
|
666
|
+
return true;
|
|
667
|
+
}
|
|
668
|
+
// Named request / sequence runs with optional args + retry/backoff
|
|
669
|
+
if (/^run\s+[a-zA-Z_][a-zA-Z0-9_-]*(?:\s*\([^)]*\))?(?:\s+retry\s+\d+)?(?:\s+backoff\s+\d+(?:\.\d+)?\s*(?:s|ms|seconds?|milliseconds?)?)?$/i.test(trimmed)) {
|
|
670
|
+
return true;
|
|
671
|
+
}
|
|
672
|
+
if (/^var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*run\s+[a-zA-Z_][a-zA-Z0-9_-]*(?:\s*\([^)]*\))?(?:\s+retry\s+\d+)?(?:\s+backoff\s+\d+(?:\.\d+)?\s*(?:s|ms|seconds?|milliseconds?)?)?$/i.test(trimmed)) {
|
|
673
|
+
return true;
|
|
674
|
+
}
|
|
675
|
+
return false;
|
|
676
|
+
}
|
|
677
|
+
function getHeaderValueCaseInsensitive(headers, headerName) {
|
|
678
|
+
const target = headerName.toLowerCase();
|
|
679
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
680
|
+
if (name.toLowerCase() === target) {
|
|
681
|
+
return value;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return undefined;
|
|
685
|
+
}
|
|
686
|
+
function isJsonContentType(contentType) {
|
|
687
|
+
return typeof contentType === 'string' && contentType.toLowerCase().includes('json');
|
|
688
|
+
}
|
|
689
|
+
function isBareBodyVariableExpression(value) {
|
|
690
|
+
return /^[a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*|\[\d+\])*$/.test(value.trim());
|
|
691
|
+
}
|
|
692
|
+
function normalizeJsonBodyForDiagnostics(value) {
|
|
693
|
+
// Norn placeholders may resolve to strings, numbers, objects, or arrays at runtime.
|
|
694
|
+
// Use null as a syntactically valid stand-in so diagnostics can still catch JSON structure errors.
|
|
695
|
+
return value.replace(/\{\{[^\r\n}]+\}\}/g, 'null');
|
|
696
|
+
}
|
|
697
|
+
function jsonLineCanEndWithoutComma(line) {
|
|
698
|
+
const trimmed = line.trim();
|
|
699
|
+
return !trimmed ||
|
|
700
|
+
trimmed.endsWith(',') ||
|
|
701
|
+
trimmed.endsWith('{') ||
|
|
702
|
+
trimmed.endsWith('[') ||
|
|
703
|
+
trimmed.endsWith(':');
|
|
704
|
+
}
|
|
705
|
+
function jsonLineCanStartWithoutComma(line) {
|
|
706
|
+
const trimmed = line.trim();
|
|
707
|
+
return !trimmed ||
|
|
708
|
+
trimmed.startsWith(',') ||
|
|
709
|
+
trimmed.startsWith('}') ||
|
|
710
|
+
trimmed.startsWith(']');
|
|
711
|
+
}
|
|
712
|
+
function createMissingJsonCommaDiagnostic(diagnostics, lines, previousLineNumber) {
|
|
713
|
+
const previousLine = lines[previousLineNumber] ?? '';
|
|
714
|
+
const endCol = previousLine.trimEnd().length;
|
|
715
|
+
const range = new vscode.Range(new vscode.Position(previousLineNumber, Math.max(0, endCol - 1)), new vscode.Position(previousLineNumber, Math.max(0, endCol)));
|
|
716
|
+
pushNornDiagnostic(diagnostics, range, `Invalid JSON body: missing comma after this property or value.`, vscode.DiagnosticSeverity.Error, 'invalid-json-body-missing-comma');
|
|
717
|
+
}
|
|
718
|
+
function findJsonBodyOffsetLocation(text, offset) {
|
|
719
|
+
const safeOffset = Math.max(0, Math.min(offset, text.length));
|
|
720
|
+
const before = text.slice(0, safeOffset);
|
|
721
|
+
const lineIndex = before.split('\n').length - 1;
|
|
722
|
+
const lastNewline = before.lastIndexOf('\n');
|
|
723
|
+
const column = lastNewline === -1 ? before.length : before.length - lastNewline - 1;
|
|
724
|
+
return { lineIndex, column };
|
|
725
|
+
}
|
|
726
|
+
function getJsonErrorLocation(error, text, bodyLines) {
|
|
727
|
+
if (!(error instanceof SyntaxError)) {
|
|
728
|
+
return undefined;
|
|
729
|
+
}
|
|
730
|
+
const message = error.message;
|
|
731
|
+
const lineColumnMatch = message.match(/line\s+(\d+)\s+column\s+(\d+)/i);
|
|
732
|
+
if (lineColumnMatch) {
|
|
733
|
+
const lineIndex = Math.max(0, Number(lineColumnMatch[1]) - 1);
|
|
734
|
+
const sourceLine = bodyLines[Math.min(lineIndex, bodyLines.length - 1)];
|
|
735
|
+
if (!sourceLine) {
|
|
736
|
+
return undefined;
|
|
737
|
+
}
|
|
738
|
+
return {
|
|
739
|
+
lineNumber: sourceLine.lineNumber,
|
|
740
|
+
column: Math.max(0, Number(lineColumnMatch[2]) - 1)
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
const positionMatch = message.match(/position\s+(\d+)/i);
|
|
744
|
+
if (positionMatch) {
|
|
745
|
+
const location = findJsonBodyOffsetLocation(text, Number(positionMatch[1]));
|
|
746
|
+
const sourceLine = bodyLines[Math.min(location.lineIndex, bodyLines.length - 1)];
|
|
747
|
+
if (!sourceLine) {
|
|
748
|
+
return undefined;
|
|
749
|
+
}
|
|
750
|
+
return {
|
|
751
|
+
lineNumber: sourceLine.lineNumber,
|
|
752
|
+
column: location.column
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
return undefined;
|
|
756
|
+
}
|
|
757
|
+
function validateJsonBodyLines(diagnostics, lines, bodyLines) {
|
|
758
|
+
if (bodyLines.length === 0) {
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
const bodyText = bodyLines.map(line => line.text).join('\n').trim();
|
|
762
|
+
if (!bodyText || isBareBodyVariableExpression(bodyText)) {
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
for (let i = 1; i < bodyLines.length; i++) {
|
|
766
|
+
const previous = bodyLines[i - 1];
|
|
767
|
+
const current = bodyLines[i];
|
|
768
|
+
if (!jsonLineCanEndWithoutComma(previous.text) && !jsonLineCanStartWithoutComma(current.text)) {
|
|
769
|
+
createMissingJsonCommaDiagnostic(diagnostics, lines, previous.lineNumber);
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
const normalizedBodyText = normalizeJsonBodyForDiagnostics(bodyText);
|
|
774
|
+
try {
|
|
775
|
+
JSON.parse(normalizedBodyText);
|
|
776
|
+
}
|
|
777
|
+
catch (error) {
|
|
778
|
+
const location = getJsonErrorLocation(error, normalizedBodyText, bodyLines);
|
|
779
|
+
const lineNumber = location?.lineNumber ?? bodyLines[0].lineNumber;
|
|
780
|
+
const documentLine = lines[lineNumber] ?? '';
|
|
781
|
+
const startCol = Math.min(documentLine.length, location?.column ?? 0);
|
|
782
|
+
const range = new vscode.Range(new vscode.Position(lineNumber, startCol), new vscode.Position(lineNumber, Math.min(documentLine.length, startCol + 1)));
|
|
783
|
+
const message = error instanceof SyntaxError ? error.message : String(error);
|
|
784
|
+
pushNornDiagnostic(diagnostics, range, `Invalid JSON body: ${message}`, vscode.DiagnosticSeverity.Error, 'invalid-json-body');
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
const variablePathExpressionRegex = /^([a-zA-Z_][a-zA-Z0-9_]*)(?:\.[a-zA-Z_][a-zA-Z0-9_]*|\[\d+\])*$/;
|
|
788
|
+
const literalExpressionWords = new Set([
|
|
789
|
+
'true',
|
|
790
|
+
'false',
|
|
791
|
+
'null',
|
|
792
|
+
'string',
|
|
793
|
+
'number',
|
|
794
|
+
'boolean',
|
|
795
|
+
'object',
|
|
796
|
+
'array',
|
|
797
|
+
'undefined'
|
|
798
|
+
]);
|
|
799
|
+
function isQuotedExpression(expr) {
|
|
800
|
+
return (expr.startsWith('"') && expr.endsWith('"')) ||
|
|
801
|
+
(expr.startsWith("'") && expr.endsWith("'"));
|
|
802
|
+
}
|
|
803
|
+
function getBaseVariableRead(expression) {
|
|
804
|
+
const trimmed = expression.trim();
|
|
805
|
+
if (!trimmed ||
|
|
806
|
+
trimmed.startsWith('$') ||
|
|
807
|
+
trimmed.startsWith('{{') ||
|
|
808
|
+
isQuotedExpression(trimmed) ||
|
|
809
|
+
/^-?\d+(?:\.\d+)?$/.test(trimmed) ||
|
|
810
|
+
trimmed === '[]' ||
|
|
811
|
+
trimmed === '{}') {
|
|
812
|
+
return undefined;
|
|
813
|
+
}
|
|
814
|
+
const match = trimmed.match(variablePathExpressionRegex);
|
|
815
|
+
if (!match) {
|
|
816
|
+
return undefined;
|
|
817
|
+
}
|
|
818
|
+
const name = match[1];
|
|
819
|
+
if (literalExpressionWords.has(name.toLowerCase())) {
|
|
820
|
+
return undefined;
|
|
821
|
+
}
|
|
822
|
+
const leadingWhitespace = expression.length - expression.trimStart().length;
|
|
823
|
+
return {
|
|
824
|
+
name,
|
|
825
|
+
start: leadingWhitespace,
|
|
826
|
+
end: leadingWhitespace + name.length
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
function getBareTemplateExpressionRead(expression) {
|
|
830
|
+
const trimmed = expression.trim();
|
|
831
|
+
const match = trimmed.match(/^\{\{\s*((\$\d+|[a-zA-Z_][a-zA-Z0-9_]*)(?:\.[a-zA-Z_][a-zA-Z0-9_-]*|\[\d+\])*)\s*\}\}$/);
|
|
832
|
+
if (!match) {
|
|
833
|
+
return undefined;
|
|
834
|
+
}
|
|
835
|
+
const leadingWhitespace = expression.length - expression.trimStart().length;
|
|
836
|
+
return {
|
|
837
|
+
name: match[2],
|
|
838
|
+
replacement: match[1],
|
|
839
|
+
start: leadingWhitespace,
|
|
840
|
+
end: leadingWhitespace + trimmed.length
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
function splitCommaSeparatedExpressions(content) {
|
|
844
|
+
const parts = [];
|
|
845
|
+
let current = '';
|
|
846
|
+
let currentStart = 0;
|
|
847
|
+
let quoteChar = null;
|
|
848
|
+
let templateDepth = 0;
|
|
849
|
+
let bracketDepth = 0;
|
|
850
|
+
const pushCurrent = () => {
|
|
851
|
+
const leadingWhitespace = current.length - current.trimStart().length;
|
|
852
|
+
const text = current.trim();
|
|
853
|
+
if (text) {
|
|
854
|
+
parts.push({
|
|
855
|
+
text,
|
|
856
|
+
start: currentStart + leadingWhitespace
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
current = '';
|
|
860
|
+
};
|
|
861
|
+
for (let i = 0; i < content.length; i++) {
|
|
862
|
+
const char = content[i];
|
|
863
|
+
const next = i + 1 < content.length ? content[i + 1] : '';
|
|
864
|
+
const prev = i > 0 ? content[i - 1] : '';
|
|
865
|
+
if (quoteChar) {
|
|
866
|
+
current += char;
|
|
867
|
+
if (char === quoteChar && prev !== '\\') {
|
|
868
|
+
quoteChar = null;
|
|
869
|
+
}
|
|
870
|
+
continue;
|
|
871
|
+
}
|
|
872
|
+
if (char === '"' || char === "'") {
|
|
873
|
+
quoteChar = char;
|
|
874
|
+
current += char;
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
if (char === '{' && next === '{') {
|
|
878
|
+
templateDepth++;
|
|
879
|
+
current += '{{';
|
|
880
|
+
i++;
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
if (char === '}' && next === '}' && templateDepth > 0) {
|
|
884
|
+
templateDepth--;
|
|
885
|
+
current += '}}';
|
|
886
|
+
i++;
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
if (templateDepth === 0) {
|
|
890
|
+
if (char === '(' || char === '[' || char === '{') {
|
|
891
|
+
bracketDepth++;
|
|
892
|
+
current += char;
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
if (char === ')' || char === ']' || char === '}') {
|
|
896
|
+
bracketDepth = Math.max(0, bracketDepth - 1);
|
|
897
|
+
current += char;
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
if (char === ',' && bracketDepth === 0) {
|
|
901
|
+
pushCurrent();
|
|
902
|
+
currentStart = i + 1;
|
|
903
|
+
continue;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
current += char;
|
|
907
|
+
}
|
|
908
|
+
pushCurrent();
|
|
909
|
+
return parts;
|
|
910
|
+
}
|
|
911
|
+
function getContainingSequence(sequences, lineNumber) {
|
|
912
|
+
return sequences.find(seq => lineNumber > seq.startLine && lineNumber < seq.endLine);
|
|
913
|
+
}
|
|
914
|
+
function getAvailableVariablesForLine(sequences, globalVariables, lineNumber) {
|
|
915
|
+
const containingSeq = getContainingSequence(sequences, lineNumber);
|
|
916
|
+
if (!containingSeq) {
|
|
917
|
+
return new Set(Object.keys(globalVariables));
|
|
918
|
+
}
|
|
919
|
+
const localVars = getSequenceLocalVariables(containingSeq.content, containingSeq.parameters);
|
|
920
|
+
return new Set([...Object.keys(globalVariables), ...localVars]);
|
|
921
|
+
}
|
|
922
|
+
function getAvailableNonEnvironmentVariablesForLine(sequences, fileLevelVariables, lineNumber) {
|
|
923
|
+
const containingSeq = getContainingSequence(sequences, lineNumber);
|
|
924
|
+
if (!containingSeq) {
|
|
925
|
+
return new Set(Object.keys(fileLevelVariables));
|
|
926
|
+
}
|
|
927
|
+
const localVars = getSequenceLocalVariables(containingSeq.content, containingSeq.parameters);
|
|
928
|
+
return new Set([...Object.keys(fileLevelVariables), ...localVars]);
|
|
929
|
+
}
|
|
930
|
+
function addUndefinedBaseVariableDiagnostic(diagnostics, lineNumber, startCol, varName) {
|
|
931
|
+
const safeStartCol = Math.max(0, startCol);
|
|
932
|
+
const range = new vscode.Range(new vscode.Position(lineNumber, safeStartCol), new vscode.Position(lineNumber, safeStartCol + varName.length));
|
|
933
|
+
if (diagnostics.some(d => d.range.start.line === range.start.line &&
|
|
934
|
+
d.range.start.character === range.start.character &&
|
|
935
|
+
d.range.end.character === range.end.character &&
|
|
936
|
+
d.code === 'undefined-variable')) {
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
pushNornDiagnostic(diagnostics, range, `Undefined variable: '${varName}'`, vscode.DiagnosticSeverity.Error, 'undefined-variable');
|
|
940
|
+
}
|
|
941
|
+
function addUndefinedVariableReadDiagnostic(diagnostics, expression, expressionStartCol, lineNumber, availableVars) {
|
|
942
|
+
const read = getBaseVariableRead(expression);
|
|
943
|
+
if (!read || availableVars.has(read.name)) {
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
addUndefinedBaseVariableDiagnostic(diagnostics, lineNumber, expressionStartCol + read.start, read.name);
|
|
947
|
+
}
|
|
948
|
+
function addBareTemplateExpressionDiagnostic(diagnostics, expression, expressionStartCol, lineNumber, environmentVariableNames, nonEnvironmentVariableNames) {
|
|
949
|
+
const read = getBareTemplateExpressionRead(expression);
|
|
950
|
+
if (!read) {
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
if (environmentVariableNames.has(read.name) && !nonEnvironmentVariableNames.has(read.name)) {
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
const startCol = Math.max(0, expressionStartCol + read.start);
|
|
957
|
+
const range = new vscode.Range(new vscode.Position(lineNumber, startCol), new vscode.Position(lineNumber, startCol + read.end - read.start));
|
|
958
|
+
if (diagnostics.some(d => d.range.start.line === range.start.line &&
|
|
959
|
+
d.range.start.character === range.start.character &&
|
|
960
|
+
d.range.end.character === range.end.character &&
|
|
961
|
+
d.code === 'bare-template-expression')) {
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
pushNornDiagnostic(diagnostics, range, `Use '${read.replacement}' directly in assertions and conditions; reserve {{...}} for quoted strings and request templates.`, vscode.DiagnosticSeverity.Error, 'bare-template-expression');
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* Tokenize print statement content into individual tokens.
|
|
968
|
+
* Respects quoted strings and splits on operators/whitespace.
|
|
969
|
+
*/
|
|
970
|
+
function tokenizePrintContent(content) {
|
|
971
|
+
const tokens = [];
|
|
972
|
+
let current = '';
|
|
973
|
+
let currentStart = 0;
|
|
974
|
+
let inString = false;
|
|
975
|
+
let stringChar = '';
|
|
976
|
+
let i = 0;
|
|
977
|
+
const pushToken = () => {
|
|
978
|
+
if (current.trim()) {
|
|
979
|
+
tokens.push({ text: current.trim(), start: currentStart + (current.length - current.trimStart().length) });
|
|
980
|
+
}
|
|
981
|
+
current = '';
|
|
982
|
+
};
|
|
983
|
+
while (i < content.length) {
|
|
984
|
+
const char = content[i];
|
|
985
|
+
if (inString) {
|
|
986
|
+
current += char;
|
|
987
|
+
if (char === stringChar && content[i - 1] !== '\\') {
|
|
988
|
+
inString = false;
|
|
989
|
+
pushToken();
|
|
990
|
+
}
|
|
991
|
+
i++;
|
|
992
|
+
continue;
|
|
993
|
+
}
|
|
994
|
+
// Start of string
|
|
995
|
+
if (char === '"' || char === "'") {
|
|
996
|
+
pushToken();
|
|
997
|
+
inString = true;
|
|
998
|
+
stringChar = char;
|
|
999
|
+
current = char;
|
|
1000
|
+
currentStart = i;
|
|
1001
|
+
i++;
|
|
1002
|
+
continue;
|
|
1003
|
+
}
|
|
1004
|
+
// Operators and whitespace are delimiters
|
|
1005
|
+
if (/[\s+\-*/|&<>=!]/.test(char)) {
|
|
1006
|
+
pushToken();
|
|
1007
|
+
// If it's an operator, add it as its own token
|
|
1008
|
+
if (/[+\-*/|&<>=!]/.test(char)) {
|
|
1009
|
+
tokens.push({ text: char, start: i });
|
|
1010
|
+
}
|
|
1011
|
+
currentStart = i + 1;
|
|
1012
|
+
i++;
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
1015
|
+
// Regular character
|
|
1016
|
+
if (current === '') {
|
|
1017
|
+
currentStart = i;
|
|
1018
|
+
}
|
|
1019
|
+
current += char;
|
|
1020
|
+
i++;
|
|
1021
|
+
}
|
|
1022
|
+
// Push any remaining token
|
|
1023
|
+
pushToken();
|
|
1024
|
+
return tokens;
|
|
1025
|
+
}
|
|
1026
|
+
class DiagnosticProvider {
|
|
1027
|
+
diagnosticCollection;
|
|
1028
|
+
workspaceRefreshPromise;
|
|
1029
|
+
workspaceRefreshQueued = false;
|
|
1030
|
+
constructor() {
|
|
1031
|
+
this.diagnosticCollection = vscode.languages.createDiagnosticCollection('norn');
|
|
1032
|
+
}
|
|
1033
|
+
isSupportedLanguage(document) {
|
|
1034
|
+
return document.languageId === 'norn' ||
|
|
1035
|
+
document.languageId === 'nornsql' ||
|
|
1036
|
+
document.languageId === 'nornapi' ||
|
|
1037
|
+
document.languageId === 'nornenv';
|
|
1038
|
+
}
|
|
1039
|
+
async refreshWorkspaceDiagnostics() {
|
|
1040
|
+
if (this.workspaceRefreshPromise) {
|
|
1041
|
+
this.workspaceRefreshQueued = true;
|
|
1042
|
+
return this.workspaceRefreshPromise;
|
|
1043
|
+
}
|
|
1044
|
+
this.workspaceRefreshPromise = (async () => {
|
|
1045
|
+
do {
|
|
1046
|
+
this.workspaceRefreshQueued = false;
|
|
1047
|
+
await this.refreshWorkspaceDiagnosticsInternal();
|
|
1048
|
+
} while (this.workspaceRefreshQueued);
|
|
1049
|
+
})().finally(() => {
|
|
1050
|
+
this.workspaceRefreshPromise = undefined;
|
|
1051
|
+
});
|
|
1052
|
+
return this.workspaceRefreshPromise;
|
|
1053
|
+
}
|
|
1054
|
+
async refreshWorkspaceDiagnosticsInternal() {
|
|
1055
|
+
const openDocsByUri = new Map();
|
|
1056
|
+
for (const doc of vscode.workspace.textDocuments) {
|
|
1057
|
+
if (this.isSupportedLanguage(doc)) {
|
|
1058
|
+
openDocsByUri.set(doc.uri.toString(), doc);
|
|
1059
|
+
this.updateDiagnostics(doc);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
let uris = [];
|
|
1063
|
+
try {
|
|
1064
|
+
uris = await vscode.workspace.findFiles('**/*.{norn,nornsql,nornapi,nornenv}', '**/{node_modules,.git}/**');
|
|
1065
|
+
}
|
|
1066
|
+
catch {
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
for (const uri of uris) {
|
|
1070
|
+
if (openDocsByUri.has(uri.toString())) {
|
|
1071
|
+
continue;
|
|
1072
|
+
}
|
|
1073
|
+
try {
|
|
1074
|
+
const doc = await vscode.workspace.openTextDocument(uri);
|
|
1075
|
+
if (this.isSupportedLanguage(doc)) {
|
|
1076
|
+
this.updateDiagnostics(doc);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
catch {
|
|
1080
|
+
// Ignore files that cannot be opened/read during background refresh
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
createWholeLineRange(document, lineNumber) {
|
|
1085
|
+
const safeLine = Math.max(0, Math.min(lineNumber, Math.max(document.lineCount - 1, 0)));
|
|
1086
|
+
const text = document.lineCount > 0 ? document.lineAt(safeLine).text : '';
|
|
1087
|
+
return new vscode.Range(new vscode.Position(safeLine, 0), new vscode.Position(safeLine, text.length));
|
|
1088
|
+
}
|
|
1089
|
+
updateNornsqlDiagnostics(document) {
|
|
1090
|
+
const diagnostics = [];
|
|
1091
|
+
const parsed = (0, nornSqlParser_1.parseNornSqlFile)(document.getText(), document.uri.fsPath);
|
|
1092
|
+
const lines = document.getText().split('\n');
|
|
1093
|
+
for (const error of parsed.errors) {
|
|
1094
|
+
const range = this.createWholeLineRange(document, error.lineNumber);
|
|
1095
|
+
pushNornDiagnostic(diagnostics, range, error.message, vscode.DiagnosticSeverity.Error, 'nornsql-parse');
|
|
1096
|
+
}
|
|
1097
|
+
const connectionLine = lines.findIndex(line => /^\s*connection\b/i.test(line));
|
|
1098
|
+
if (parsed.connectionName) {
|
|
1099
|
+
const range = this.createWholeLineRange(document, connectionLine >= 0 ? connectionLine : 0);
|
|
1100
|
+
try {
|
|
1101
|
+
const projectConfig = (0, sqlConfig_1.loadNornSqlProjectConfig)(document.uri.fsPath).config;
|
|
1102
|
+
const connection = projectConfig.connections[parsed.connectionName];
|
|
1103
|
+
if (!connection) {
|
|
1104
|
+
pushNornDiagnostic(diagnostics, range, `Connection alias '${parsed.connectionName}' was not found in ${nornConfig_1.NORN_CONFIG_FILENAME} sql.connections.`, vscode.DiagnosticSeverity.Error, 'nornsql-unknown-connection');
|
|
1105
|
+
}
|
|
1106
|
+
else {
|
|
1107
|
+
const adapterValidation = (0, sqlConfig_1.validateSqlAdapterReference)(document.uri.fsPath, connection.adapter);
|
|
1108
|
+
if (!adapterValidation.ok && adapterValidation.message) {
|
|
1109
|
+
pushNornDiagnostic(diagnostics, range, adapterValidation.message, vscode.DiagnosticSeverity.Error, 'nornsql-unknown-adapter');
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
catch (error) {
|
|
1114
|
+
pushNornDiagnostic(diagnostics, range, error instanceof Error ? error.message : String(error), vscode.DiagnosticSeverity.Error, 'nornsql-invalid-config');
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
this.diagnosticCollection.set(document.uri, diagnostics);
|
|
1118
|
+
}
|
|
1119
|
+
updateDiagnostics(document) {
|
|
1120
|
+
if (document.languageId === 'nornenv') {
|
|
1121
|
+
this.updateNornenvDiagnostics(document);
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
if (document.languageId === 'nornapi') {
|
|
1125
|
+
this.updateNornapiDiagnostics(document);
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
if (document.languageId === 'nornsql') {
|
|
1129
|
+
this.updateNornsqlDiagnostics(document);
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
if (document.languageId !== 'norn') {
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
const diagnostics = [];
|
|
1136
|
+
const text = document.getText();
|
|
1137
|
+
// Extract file-level variables (outside sequences) + environment
|
|
1138
|
+
const fileLevelVariables = (0, parser_1.extractFileLevelVariables)(text);
|
|
1139
|
+
const envVariables = (0, environmentProvider_1.getEnvironmentVariables)(document.uri.fsPath);
|
|
1140
|
+
const declaredEnvironmentVariableNames = getAllDeclaredEnvironmentVariableNames(document.uri.fsPath);
|
|
1141
|
+
const environmentVariableNames = new Set([
|
|
1142
|
+
...Object.keys(envVariables),
|
|
1143
|
+
...declaredEnvironmentVariableNames
|
|
1144
|
+
]);
|
|
1145
|
+
const globalVariables = { ...envVariables, ...fileLevelVariables };
|
|
1146
|
+
// Extract sequences for scope-aware checking
|
|
1147
|
+
const sequences = (0, sequenceRunner_1.extractSequences)(text);
|
|
1148
|
+
const lines = text.split('\n');
|
|
1149
|
+
// Check for duplicate sequence declarations
|
|
1150
|
+
const declaredSequences = new Map(); // seqName -> first declaration line
|
|
1151
|
+
for (const seq of sequences) {
|
|
1152
|
+
if (declaredSequences.has(seq.name)) {
|
|
1153
|
+
const firstLine = declaredSequences.get(seq.name);
|
|
1154
|
+
const range = createTokenRange(lines, seq.startLine, seq.name);
|
|
1155
|
+
pushNornDiagnostic(diagnostics, range, `Duplicate sequence: '${seq.name}' is already declared on line ${firstLine + 1}.`, vscode.DiagnosticSeverity.Error, 'duplicate-sequence');
|
|
1156
|
+
}
|
|
1157
|
+
else {
|
|
1158
|
+
declaredSequences.set(seq.name, seq.startLine);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
// Warn when sequence parameters shadow environment variables.
|
|
1162
|
+
for (const seq of sequences) {
|
|
1163
|
+
if (!seq.parameters || seq.parameters.length === 0) {
|
|
1164
|
+
continue;
|
|
1165
|
+
}
|
|
1166
|
+
let declarationLine = seq.startLine;
|
|
1167
|
+
for (let i = seq.startLine; i <= seq.endLine; i++) {
|
|
1168
|
+
if (/^\s*(?:test\s+)?sequence\s+/.test(lines[i])) {
|
|
1169
|
+
declarationLine = i;
|
|
1170
|
+
break;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
const declarationText = lines[declarationLine];
|
|
1174
|
+
for (const param of seq.parameters) {
|
|
1175
|
+
if (!(param.name in envVariables)) {
|
|
1176
|
+
continue;
|
|
1177
|
+
}
|
|
1178
|
+
const startCol = declarationText.indexOf(param.name);
|
|
1179
|
+
if (startCol < 0) {
|
|
1180
|
+
continue;
|
|
1181
|
+
}
|
|
1182
|
+
const range = new vscode.Range(new vscode.Position(declarationLine, startCol), new vscode.Position(declarationLine, startCol + param.name.length));
|
|
1183
|
+
pushNornDiagnostic(diagnostics, range, `Sequence parameter '${param.name}' shadows environment variable '${param.name}'. Use {{$env.${param.name}}} to access the env value explicitly.`, vscode.DiagnosticSeverity.Warning, 'env-variable-shadowed');
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
// Check for duplicate variable declarations at file level (outside sequences)
|
|
1187
|
+
const varDeclRegex = /^\s*var\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*=/;
|
|
1188
|
+
const declaredVars = new Map(); // varName -> first declaration line
|
|
1189
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1190
|
+
const line = lines[i];
|
|
1191
|
+
// Skip comment lines
|
|
1192
|
+
if (isCommentLine(line)) {
|
|
1193
|
+
continue;
|
|
1194
|
+
}
|
|
1195
|
+
// Check if this line is inside a sequence (skip sequence-local vars)
|
|
1196
|
+
const isInsideSequence = sequences.some(seq => i > seq.startLine && i < seq.endLine);
|
|
1197
|
+
if (isInsideSequence) {
|
|
1198
|
+
continue;
|
|
1199
|
+
}
|
|
1200
|
+
const lineWithoutComment = (0, stringUtils_1.stripInlineComment)(line);
|
|
1201
|
+
const varMatch = lineWithoutComment.match(varDeclRegex);
|
|
1202
|
+
if (varMatch) {
|
|
1203
|
+
const varName = varMatch[1];
|
|
1204
|
+
if (declaredVars.has(varName)) {
|
|
1205
|
+
const firstLine = declaredVars.get(varName);
|
|
1206
|
+
const startCol = line.indexOf(varName);
|
|
1207
|
+
const range = new vscode.Range(new vscode.Position(i, startCol), new vscode.Position(i, startCol + varName.length));
|
|
1208
|
+
pushNornDiagnostic(diagnostics, range, `Duplicate variable declaration: '${varName}' is already declared on line ${firstLine + 1}.`, vscode.DiagnosticSeverity.Error, 'duplicate-variable');
|
|
1209
|
+
}
|
|
1210
|
+
else {
|
|
1211
|
+
declaredVars.set(varName, i);
|
|
1212
|
+
}
|
|
1213
|
+
if (varName in envVariables) {
|
|
1214
|
+
const startCol = line.indexOf(varName);
|
|
1215
|
+
const range = new vscode.Range(new vscode.Position(i, startCol), new vscode.Position(i, startCol + varName.length));
|
|
1216
|
+
pushNornDiagnostic(diagnostics, range, `File variable '${varName}' shadows environment variable '${varName}'. Use {{$env.${varName}}} to access the env value explicitly.`, vscode.DiagnosticSeverity.Warning, 'env-variable-shadowed');
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
// Check for invalid named request declarations (names with spaces) and duplicates
|
|
1221
|
+
const invalidNameRegex = /^\s*\[(?:Name:\s*)?(.+)\]\s*$/;
|
|
1222
|
+
const namedRequestRegex = /^\s*\[(?:Name:\s*)?([a-zA-Z_][a-zA-Z0-9_-]*)\]/;
|
|
1223
|
+
const validNameRegex = /^[a-zA-Z_][a-zA-Z0-9_-]*$/;
|
|
1224
|
+
const declaredNamedRequests = new Map(); // name -> first declaration line
|
|
1225
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1226
|
+
const line = lines[i];
|
|
1227
|
+
// Skip comment lines
|
|
1228
|
+
if (isCommentLine(line)) {
|
|
1229
|
+
continue;
|
|
1230
|
+
}
|
|
1231
|
+
// Strip inline comments before checking
|
|
1232
|
+
const lineWithoutComment = (0, stringUtils_1.stripInlineComment)(line);
|
|
1233
|
+
// Check for valid named request and track for duplicates
|
|
1234
|
+
const namedMatch = lineWithoutComment.match(namedRequestRegex);
|
|
1235
|
+
if (namedMatch) {
|
|
1236
|
+
const name = namedMatch[1];
|
|
1237
|
+
if (declaredNamedRequests.has(name)) {
|
|
1238
|
+
const firstLine = declaredNamedRequests.get(name);
|
|
1239
|
+
const startCol = line.indexOf('[');
|
|
1240
|
+
const endCol = line.indexOf(']') + 1;
|
|
1241
|
+
const range = new vscode.Range(new vscode.Position(i, startCol), new vscode.Position(i, endCol));
|
|
1242
|
+
pushNornDiagnostic(diagnostics, range, `Duplicate named request: '${name}' is already declared on line ${firstLine + 1}.`, vscode.DiagnosticSeverity.Error, 'duplicate-named-request');
|
|
1243
|
+
}
|
|
1244
|
+
else {
|
|
1245
|
+
declaredNamedRequests.set(name, i);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
// Check for invalid names (with spaces etc)
|
|
1249
|
+
const match = lineWithoutComment.match(invalidNameRegex);
|
|
1250
|
+
if (match) {
|
|
1251
|
+
const name = match[1].trim();
|
|
1252
|
+
if (!validNameRegex.test(name)) {
|
|
1253
|
+
const startCol = line.indexOf('[');
|
|
1254
|
+
const endCol = line.indexOf(']') + 1;
|
|
1255
|
+
const range = new vscode.Range(new vscode.Position(i, startCol), new vscode.Position(i, endCol));
|
|
1256
|
+
pushNornDiagnostic(diagnostics, range, `Invalid request name: '${name}'. Names cannot contain spaces and must start with a letter or underscore.`, vscode.DiagnosticSeverity.Error, 'invalid-request-name');
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
// Check for import statement errors
|
|
1261
|
+
const imports = (0, parser_1.extractImports)(text);
|
|
1262
|
+
const documentDir = path.dirname(document.uri.fsPath);
|
|
1263
|
+
// Track imported names with source info for duplicate detection
|
|
1264
|
+
// Maps name (lowercase) -> { sourcePath, lineNumber }
|
|
1265
|
+
const importedRequests = new Map();
|
|
1266
|
+
const importedSequences = new Map();
|
|
1267
|
+
// Track imported API definitions (endpoints and header groups from .nornapi files)
|
|
1268
|
+
// Maps name -> { sourcePath, lineNumber } to detect duplicates
|
|
1269
|
+
const importedEndpoints = new Map();
|
|
1270
|
+
const importedHeaderGroups = new Map();
|
|
1271
|
+
const importedSqlOperations = new Map();
|
|
1272
|
+
let sqlProjectConfig;
|
|
1273
|
+
let sqlProjectConfigError;
|
|
1274
|
+
let sqlProjectConfigLoadAttempted = false;
|
|
1275
|
+
const loadSqlProjectConfig = () => {
|
|
1276
|
+
if (sqlProjectConfigLoadAttempted) {
|
|
1277
|
+
return sqlProjectConfig;
|
|
1278
|
+
}
|
|
1279
|
+
sqlProjectConfigLoadAttempted = true;
|
|
1280
|
+
try {
|
|
1281
|
+
sqlProjectConfig = (0, sqlConfig_1.loadNornSqlProjectConfig)(document.uri.fsPath).config;
|
|
1282
|
+
}
|
|
1283
|
+
catch (error) {
|
|
1284
|
+
sqlProjectConfigError = error instanceof Error ? error.message : String(error);
|
|
1285
|
+
}
|
|
1286
|
+
return sqlProjectConfig;
|
|
1287
|
+
};
|
|
1288
|
+
for (const imp of imports) {
|
|
1289
|
+
const absolutePath = path.resolve(documentDir, imp.path);
|
|
1290
|
+
// Check if file exists
|
|
1291
|
+
if (!fs.existsSync(absolutePath)) {
|
|
1292
|
+
const range = createTokenRange(lines, imp.lineNumber, imp.path);
|
|
1293
|
+
pushNornDiagnostic(diagnostics, range, `Import file not found: '${imp.path}'`, vscode.DiagnosticSeverity.Error, 'import-not-found');
|
|
1294
|
+
}
|
|
1295
|
+
else {
|
|
1296
|
+
// Read the imported file and extract its named requests/sequences
|
|
1297
|
+
try {
|
|
1298
|
+
const importedContent = fs.readFileSync(absolutePath, 'utf-8');
|
|
1299
|
+
// Check if this is a .nornapi file
|
|
1300
|
+
if (imp.path.endsWith('.nornsql')) {
|
|
1301
|
+
const parsedSql = (0, nornSqlParser_1.parseNornSqlFile)(importedContent, absolutePath);
|
|
1302
|
+
for (const parseError of parsedSql.errors) {
|
|
1303
|
+
const range = createTokenRange(lines, imp.lineNumber, imp.path);
|
|
1304
|
+
pushNornDiagnostic(diagnostics, range, parseError.message, vscode.DiagnosticSeverity.Error, 'invalid-nornsql-import');
|
|
1305
|
+
}
|
|
1306
|
+
const loadedSqlConfig = loadSqlProjectConfig();
|
|
1307
|
+
if (sqlProjectConfigError) {
|
|
1308
|
+
const range = createTokenRange(lines, imp.lineNumber, imp.path);
|
|
1309
|
+
pushNornDiagnostic(diagnostics, range, sqlProjectConfigError, vscode.DiagnosticSeverity.Error, 'invalid-nornsql-config');
|
|
1310
|
+
}
|
|
1311
|
+
else if (parsedSql.connectionName && loadedSqlConfig && !loadedSqlConfig.connections[parsedSql.connectionName]) {
|
|
1312
|
+
const range = createTokenRange(lines, imp.lineNumber, imp.path);
|
|
1313
|
+
pushNornDiagnostic(diagnostics, range, `Connection alias '${parsedSql.connectionName}' was not found in ${nornConfig_1.NORN_CONFIG_FILENAME} sql.connections.`, vscode.DiagnosticSeverity.Error, 'unknown-nornsql-connection');
|
|
1314
|
+
}
|
|
1315
|
+
else if (parsedSql.connectionName && loadedSqlConfig) {
|
|
1316
|
+
const adapterValidation = (0, sqlConfig_1.validateSqlAdapterReference)(document.uri.fsPath, loadedSqlConfig.connections[parsedSql.connectionName].adapter);
|
|
1317
|
+
if (!adapterValidation.ok && adapterValidation.message) {
|
|
1318
|
+
const range = createTokenRange(lines, imp.lineNumber, imp.path);
|
|
1319
|
+
pushNornDiagnostic(diagnostics, range, adapterValidation.message, vscode.DiagnosticSeverity.Error, 'unknown-nornsql-adapter');
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
for (const operation of parsedSql.operations) {
|
|
1323
|
+
const lowerName = operation.name.toLowerCase();
|
|
1324
|
+
const existing = importedSqlOperations.get(lowerName);
|
|
1325
|
+
if (existing) {
|
|
1326
|
+
const range = createTokenRange(lines, imp.lineNumber, imp.path);
|
|
1327
|
+
pushNornDiagnostic(diagnostics, range, `Duplicate SQL operation '${operation.name}': already defined in '${existing.sourcePath}'`, vscode.DiagnosticSeverity.Error, 'duplicate-sql-operation-import');
|
|
1328
|
+
}
|
|
1329
|
+
else {
|
|
1330
|
+
importedSqlOperations.set(lowerName, { sourcePath: imp.path, operation });
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
else if (imp.path.endsWith('.nornapi')) {
|
|
1335
|
+
const apiDef = (0, nornapiParser_1.parseNornApiFile)(importedContent);
|
|
1336
|
+
for (const endpoint of apiDef.endpoints) {
|
|
1337
|
+
const existing = importedEndpoints.get(endpoint.name);
|
|
1338
|
+
if (existing) {
|
|
1339
|
+
const range = createTokenRange(lines, imp.lineNumber, imp.path);
|
|
1340
|
+
pushNornDiagnostic(diagnostics, range, `Duplicate endpoint '${endpoint.name}': already defined in '${existing.sourcePath}'`, vscode.DiagnosticSeverity.Error, 'duplicate-endpoint');
|
|
1341
|
+
}
|
|
1342
|
+
else {
|
|
1343
|
+
importedEndpoints.set(endpoint.name, { sourcePath: imp.path, lineNumber: imp.lineNumber });
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
for (const group of apiDef.headerGroups) {
|
|
1347
|
+
const existing = importedHeaderGroups.get(group.name);
|
|
1348
|
+
if (existing) {
|
|
1349
|
+
const range = createTokenRange(lines, imp.lineNumber, imp.path);
|
|
1350
|
+
pushNornDiagnostic(diagnostics, range, `Duplicate header group '${group.name}': already defined in '${existing.sourcePath}'`, vscode.DiagnosticSeverity.Error, 'duplicate-header-group');
|
|
1351
|
+
}
|
|
1352
|
+
else {
|
|
1353
|
+
importedHeaderGroups.set(group.name, {
|
|
1354
|
+
sourcePath: imp.path,
|
|
1355
|
+
lineNumber: imp.lineNumber,
|
|
1356
|
+
headerGroup: group
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
else {
|
|
1362
|
+
// Regular .norn file
|
|
1363
|
+
const fileRequests = (0, parser_1.extractNamedRequests)(importedContent);
|
|
1364
|
+
const fileSequences = (0, sequenceRunner_1.extractSequences)(importedContent);
|
|
1365
|
+
// Check for duplicate named requests
|
|
1366
|
+
for (const req of fileRequests) {
|
|
1367
|
+
const lowerName = req.name.toLowerCase();
|
|
1368
|
+
const existing = importedRequests.get(lowerName);
|
|
1369
|
+
if (existing) {
|
|
1370
|
+
const range = createTokenRange(lines, imp.lineNumber, imp.path);
|
|
1371
|
+
pushNornDiagnostic(diagnostics, range, `Duplicate named request '${req.name}': already defined in '${existing.sourcePath}'`, vscode.DiagnosticSeverity.Error, 'duplicate-named-request-import');
|
|
1372
|
+
}
|
|
1373
|
+
else {
|
|
1374
|
+
importedRequests.set(lowerName, { sourcePath: imp.path, lineNumber: imp.lineNumber });
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
// Check for duplicate sequences
|
|
1378
|
+
for (const seq of fileSequences) {
|
|
1379
|
+
const lowerName = seq.name.toLowerCase();
|
|
1380
|
+
const existing = importedSequences.get(lowerName);
|
|
1381
|
+
if (existing) {
|
|
1382
|
+
const range = createTokenRange(lines, imp.lineNumber, imp.path);
|
|
1383
|
+
pushNornDiagnostic(diagnostics, range, `Duplicate sequence '${seq.name}': already defined in '${existing.sourcePath}'`, vscode.DiagnosticSeverity.Error, 'duplicate-sequence-import');
|
|
1384
|
+
}
|
|
1385
|
+
else {
|
|
1386
|
+
importedSequences.set(lowerName, { sourcePath: imp.path, lineNumber: imp.lineNumber });
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
catch {
|
|
1392
|
+
// Ignore read errors - file exists but couldn't be read
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
// Check for misplaced tags/annotations.
|
|
1397
|
+
// Any @decorator line must be directly associated with a following sequence declaration.
|
|
1398
|
+
const tagPattern = /^\s*@([a-zA-Z_][a-zA-Z0-9_-]*)(?:\(([^)]*)\))?/;
|
|
1399
|
+
const sequencePattern = /^\s*(?:test\s+)?sequence\s+[a-zA-Z_][a-zA-Z0-9_-]*/;
|
|
1400
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1401
|
+
const line = lines[i];
|
|
1402
|
+
// Skip comment lines
|
|
1403
|
+
if (isCommentLine(line)) {
|
|
1404
|
+
continue;
|
|
1405
|
+
}
|
|
1406
|
+
// Check if this line contains a tag/annotation decorator
|
|
1407
|
+
if (tagPattern.test(line)) {
|
|
1408
|
+
// Look ahead to find what this decorator applies to
|
|
1409
|
+
let foundSequence = false;
|
|
1410
|
+
let j = i + 1;
|
|
1411
|
+
// Skip empty lines and additional decorator lines
|
|
1412
|
+
while (j < lines.length) {
|
|
1413
|
+
const nextLine = lines[j].trim();
|
|
1414
|
+
if (!nextLine) {
|
|
1415
|
+
j++;
|
|
1416
|
+
continue;
|
|
1417
|
+
}
|
|
1418
|
+
if (tagPattern.test(nextLine)) {
|
|
1419
|
+
j++;
|
|
1420
|
+
continue;
|
|
1421
|
+
}
|
|
1422
|
+
if (sequencePattern.test(nextLine)) {
|
|
1423
|
+
foundSequence = true;
|
|
1424
|
+
}
|
|
1425
|
+
break;
|
|
1426
|
+
}
|
|
1427
|
+
const tagMatch = line.match(/@([a-zA-Z_][a-zA-Z0-9_-]*)(?:\([^)]+\))?/);
|
|
1428
|
+
if (tagMatch) {
|
|
1429
|
+
const startCol = line.indexOf('@');
|
|
1430
|
+
const range = new vscode.Range(new vscode.Position(i, startCol), new vscode.Position(i, startCol + tagMatch[0].length));
|
|
1431
|
+
if (!foundSequence) {
|
|
1432
|
+
const annotationName = tagMatch[1];
|
|
1433
|
+
const isDataAnnotation = annotationName === 'data' || annotationName === 'theory';
|
|
1434
|
+
const message = isDataAnnotation
|
|
1435
|
+
? `@${annotationName} must be placed immediately before a sequence declaration.`
|
|
1436
|
+
: `Tags must be placed immediately before a sequence declaration.`;
|
|
1437
|
+
pushNornDiagnostic(diagnostics, range, message, vscode.DiagnosticSeverity.Error, 'misplaced-tag');
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
// Check for test sequences with required parameters but no @data/@theory
|
|
1443
|
+
for (const seq of sequences) {
|
|
1444
|
+
if (seq.isTest) {
|
|
1445
|
+
const requiredParams = seq.parameters.filter(p => p.defaultValue === undefined);
|
|
1446
|
+
if (requiredParams.length > 0 && !seq.theoryData) {
|
|
1447
|
+
// Find the line with the 'test sequence' declaration
|
|
1448
|
+
let declarationLine = seq.startLine;
|
|
1449
|
+
for (let i = seq.startLine; i <= seq.endLine; i++) {
|
|
1450
|
+
if (/^\s*test\s+sequence\s+/.test(lines[i])) {
|
|
1451
|
+
declarationLine = i;
|
|
1452
|
+
break;
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
const line = lines[declarationLine];
|
|
1456
|
+
const startCol = line.indexOf('test');
|
|
1457
|
+
const endCol = line.length;
|
|
1458
|
+
const range = new vscode.Range(new vscode.Position(declarationLine, startCol), new vscode.Position(declarationLine, endCol));
|
|
1459
|
+
const paramNames = requiredParams.map(p => p.name).join(', ');
|
|
1460
|
+
pushNornDiagnostic(diagnostics, range, `Test sequence '${seq.name}' has required parameters (${paramNames}) but no @data or @theory annotation. Add @data(...) or @theory("file.json") to provide test values.`, vscode.DiagnosticSeverity.Error, 'test-missing-data');
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
// Check for undefined named requests/sequences in 'run' commands
|
|
1465
|
+
const namedRequests = (0, parser_1.extractNamedRequests)(text);
|
|
1466
|
+
const namedRequestNames = new Set(namedRequests.map(r => r.name.toLowerCase()));
|
|
1467
|
+
const sequenceNames = new Set(sequences.map(s => s.name.toLowerCase()));
|
|
1468
|
+
// Build a map of sequence name -> required parameter count
|
|
1469
|
+
const sequenceParamInfo = new Map();
|
|
1470
|
+
for (const seq of sequences) {
|
|
1471
|
+
const requiredParams = seq.parameters.filter(p => p.defaultValue === undefined).length;
|
|
1472
|
+
sequenceParamInfo.set(seq.name.toLowerCase(), { required: requiredParams, total: seq.parameters.length });
|
|
1473
|
+
}
|
|
1474
|
+
// Updated regex to capture optional arguments: run Name or run Name(args)
|
|
1475
|
+
const runCommandRegex = /^\s*(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?run\s+([a-zA-Z_][a-zA-Z0-9_-]*)(?:\(([^)]*)\))?\s*$/;
|
|
1476
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1477
|
+
const line = lines[i];
|
|
1478
|
+
// Skip comment lines
|
|
1479
|
+
if (isCommentLine(line)) {
|
|
1480
|
+
continue;
|
|
1481
|
+
}
|
|
1482
|
+
const match = line.match(runCommandRegex);
|
|
1483
|
+
if (match) {
|
|
1484
|
+
const requestName = match[1];
|
|
1485
|
+
const argsStr = match[2]; // undefined if no parens, empty string if (), or "arg1, arg2"
|
|
1486
|
+
const lowerName = requestName.toLowerCase();
|
|
1487
|
+
// Skip if it's a script command
|
|
1488
|
+
if (['bash', 'js', 'powershell', 'readjson', 'sql'].includes(lowerName)) {
|
|
1489
|
+
continue;
|
|
1490
|
+
}
|
|
1491
|
+
// Check if it's a local named request, local sequence, or imported
|
|
1492
|
+
const isLocalRequest = namedRequestNames.has(lowerName);
|
|
1493
|
+
const isLocalSequence = sequenceNames.has(lowerName);
|
|
1494
|
+
const isImportedRequest = importedRequests.has(lowerName);
|
|
1495
|
+
const isImportedSequence = importedSequences.has(lowerName);
|
|
1496
|
+
if (!isLocalRequest && !isLocalSequence && !isImportedRequest && !isImportedSequence) {
|
|
1497
|
+
const startCol = line.indexOf(requestName);
|
|
1498
|
+
const range = new vscode.Range(new vscode.Position(i, startCol), new vscode.Position(i, startCol + requestName.length));
|
|
1499
|
+
pushNornDiagnostic(diagnostics, range, `Undefined request or sequence: '${requestName}'`, vscode.DiagnosticSeverity.Error, 'undefined-request');
|
|
1500
|
+
}
|
|
1501
|
+
else if (isLocalSequence) {
|
|
1502
|
+
// Check if sequence requires parameters but none were provided
|
|
1503
|
+
const paramInfo = sequenceParamInfo.get(lowerName);
|
|
1504
|
+
if (paramInfo && paramInfo.required > 0) {
|
|
1505
|
+
// Count provided arguments
|
|
1506
|
+
let providedCount = 0;
|
|
1507
|
+
if (argsStr !== undefined && argsStr.trim() !== '') {
|
|
1508
|
+
// Split by comma, but respect quoted strings
|
|
1509
|
+
const args = argsStr.split(',').map(a => a.trim()).filter(a => a !== '');
|
|
1510
|
+
providedCount = args.length;
|
|
1511
|
+
}
|
|
1512
|
+
if (providedCount < paramInfo.required) {
|
|
1513
|
+
const startCol = line.indexOf(requestName);
|
|
1514
|
+
const range = new vscode.Range(new vscode.Position(i, startCol), new vscode.Position(i, startCol + requestName.length));
|
|
1515
|
+
const seq = sequences.find(s => s.name.toLowerCase() === lowerName);
|
|
1516
|
+
const requiredParamNames = seq ?
|
|
1517
|
+
seq.parameters.filter(p => p.defaultValue === undefined).map(p => p.name).join(', ') :
|
|
1518
|
+
'';
|
|
1519
|
+
pushNornDiagnostic(diagnostics, range, `Sequence '${requestName}' requires ${paramInfo.required} parameter(s): ${requiredParamNames}`, vscode.DiagnosticSeverity.Error, 'missing-sequence-parameters');
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
const runSqlRegex = /^\s*(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?run\s+sql\b/i;
|
|
1526
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1527
|
+
const line = lines[i];
|
|
1528
|
+
if (isCommentLine(line) || !runSqlRegex.test(line)) {
|
|
1529
|
+
continue;
|
|
1530
|
+
}
|
|
1531
|
+
const parsed = (0, sequenceRunner_1.parseRunSqlCommand)(line);
|
|
1532
|
+
if (!parsed) {
|
|
1533
|
+
continue;
|
|
1534
|
+
}
|
|
1535
|
+
const sqlKeywordStart = line.toLowerCase().indexOf('sql');
|
|
1536
|
+
const baseRange = new vscode.Range(new vscode.Position(i, Math.max(0, sqlKeywordStart)), new vscode.Position(i, Math.max(0, sqlKeywordStart) + 3));
|
|
1537
|
+
if (parsed.error) {
|
|
1538
|
+
pushNornDiagnostic(diagnostics, baseRange, parsed.error, vscode.DiagnosticSeverity.Error, 'invalid-run-sql');
|
|
1539
|
+
continue;
|
|
1540
|
+
}
|
|
1541
|
+
const operationInfo = importedSqlOperations.get(parsed.operationName.toLowerCase());
|
|
1542
|
+
if (!operationInfo) {
|
|
1543
|
+
const startCol = line.indexOf(parsed.operationName);
|
|
1544
|
+
const range = new vscode.Range(new vscode.Position(i, Math.max(0, startCol)), new vscode.Position(i, Math.max(0, startCol) + parsed.operationName.length));
|
|
1545
|
+
pushNornDiagnostic(diagnostics, range, `SQL operation '${parsed.operationName}' was not found in the SQL files imported by this .norn file.`, vscode.DiagnosticSeverity.Error, 'undefined-sql-operation');
|
|
1546
|
+
continue;
|
|
1547
|
+
}
|
|
1548
|
+
const seenArgs = new Set();
|
|
1549
|
+
const declaredArgs = operationInfo.operation.parameters;
|
|
1550
|
+
let positionalIndex = 0;
|
|
1551
|
+
for (const arg of parsed.args) {
|
|
1552
|
+
if (arg.name) {
|
|
1553
|
+
const lowerName = arg.name.toLowerCase();
|
|
1554
|
+
const startCol = line.indexOf(arg.name);
|
|
1555
|
+
const range = new vscode.Range(new vscode.Position(i, Math.max(0, startCol)), new vscode.Position(i, Math.max(0, startCol) + arg.name.length));
|
|
1556
|
+
if (seenArgs.has(lowerName)) {
|
|
1557
|
+
pushNornDiagnostic(diagnostics, range, `Duplicate SQL parameter '${arg.name}' in run sql call.`, vscode.DiagnosticSeverity.Error, 'duplicate-sql-arg');
|
|
1558
|
+
continue;
|
|
1559
|
+
}
|
|
1560
|
+
const declaredParam = declaredArgs.find(param => param.toLowerCase() === lowerName);
|
|
1561
|
+
if (!declaredParam) {
|
|
1562
|
+
pushNornDiagnostic(diagnostics, range, `Unknown SQL parameter '${arg.name}'.`, vscode.DiagnosticSeverity.Error, 'unknown-sql-arg');
|
|
1563
|
+
continue;
|
|
1564
|
+
}
|
|
1565
|
+
seenArgs.add(declaredParam.toLowerCase());
|
|
1566
|
+
continue;
|
|
1567
|
+
}
|
|
1568
|
+
if (positionalIndex >= declaredArgs.length) {
|
|
1569
|
+
const startCol = line.indexOf(parsed.operationName);
|
|
1570
|
+
const range = new vscode.Range(new vscode.Position(i, Math.max(0, startCol)), new vscode.Position(i, Math.max(0, startCol) + parsed.operationName.length));
|
|
1571
|
+
pushNornDiagnostic(diagnostics, range, `Too many SQL arguments: expected ${declaredArgs.length}.`, vscode.DiagnosticSeverity.Error, 'too-many-sql-args');
|
|
1572
|
+
break;
|
|
1573
|
+
}
|
|
1574
|
+
seenArgs.add(declaredArgs[positionalIndex].toLowerCase());
|
|
1575
|
+
positionalIndex++;
|
|
1576
|
+
}
|
|
1577
|
+
for (const param of operationInfo.operation.parameters) {
|
|
1578
|
+
if (seenArgs.has(param.toLowerCase())) {
|
|
1579
|
+
continue;
|
|
1580
|
+
}
|
|
1581
|
+
const startCol = line.indexOf(parsed.operationName);
|
|
1582
|
+
const range = new vscode.Range(new vscode.Position(i, Math.max(0, startCol)), new vscode.Position(i, Math.max(0, startCol) + parsed.operationName.length));
|
|
1583
|
+
pushNornDiagnostic(diagnostics, range, `Missing required SQL parameter '${param}'.`, vscode.DiagnosticSeverity.Error, 'missing-sql-arg');
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
const runMcpRegex = /^\s*(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?run\s+mcp\b/i;
|
|
1587
|
+
let mcpConfigLoadAttempted = false;
|
|
1588
|
+
let mcpConfigError;
|
|
1589
|
+
let mcpServerAliases;
|
|
1590
|
+
const loadMcpAliases = () => {
|
|
1591
|
+
if (mcpConfigLoadAttempted) {
|
|
1592
|
+
return mcpServerAliases;
|
|
1593
|
+
}
|
|
1594
|
+
mcpConfigLoadAttempted = true;
|
|
1595
|
+
try {
|
|
1596
|
+
const config = (0, mcpConfig_1.loadNornMcpProjectConfig)(document.uri.fsPath);
|
|
1597
|
+
mcpServerAliases = new Set(Object.keys(config.config.servers).map(alias => alias.toLowerCase()));
|
|
1598
|
+
}
|
|
1599
|
+
catch (error) {
|
|
1600
|
+
mcpConfigError = error instanceof Error ? error.message : String(error);
|
|
1601
|
+
}
|
|
1602
|
+
return mcpServerAliases;
|
|
1603
|
+
};
|
|
1604
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1605
|
+
const line = lines[i];
|
|
1606
|
+
if (isCommentLine(line) || !runMcpRegex.test(line)) {
|
|
1607
|
+
continue;
|
|
1608
|
+
}
|
|
1609
|
+
const parsedList = (0, sequenceRunner_1.parseRunMcpListCommand)(line);
|
|
1610
|
+
const parsedCall = parsedList ? null : (0, sequenceRunner_1.parseRunMcpCallCommand)(line);
|
|
1611
|
+
if (!parsedList && !parsedCall) {
|
|
1612
|
+
continue;
|
|
1613
|
+
}
|
|
1614
|
+
const serverAlias = parsedList?.serverAlias ?? parsedCall.serverAlias;
|
|
1615
|
+
const aliasStart = line.indexOf(serverAlias);
|
|
1616
|
+
const aliasRange = new vscode.Range(new vscode.Position(i, Math.max(0, aliasStart)), new vscode.Position(i, Math.max(0, aliasStart) + serverAlias.length));
|
|
1617
|
+
if (parsedCall?.error) {
|
|
1618
|
+
const mcpStart = line.toLowerCase().indexOf('mcp');
|
|
1619
|
+
const range = new vscode.Range(new vscode.Position(i, Math.max(0, mcpStart)), new vscode.Position(i, line.length));
|
|
1620
|
+
pushNornDiagnostic(diagnostics, range, parsedCall.error, vscode.DiagnosticSeverity.Error, 'invalid-run-mcp');
|
|
1621
|
+
continue;
|
|
1622
|
+
}
|
|
1623
|
+
const aliases = loadMcpAliases();
|
|
1624
|
+
if (!aliases) {
|
|
1625
|
+
pushNornDiagnostic(diagnostics, aliasRange, mcpConfigError || `Invalid ${nornConfig_1.NORN_CONFIG_FILENAME} mcp section.`, vscode.DiagnosticSeverity.Error, 'invalid-mcp-config');
|
|
1626
|
+
continue;
|
|
1627
|
+
}
|
|
1628
|
+
if (!aliases.has(serverAlias.toLowerCase())) {
|
|
1629
|
+
pushNornDiagnostic(diagnostics, aliasRange, `Server alias '${serverAlias}' was not found in ${nornConfig_1.NORN_CONFIG_FILENAME} mcp.servers.`, vscode.DiagnosticSeverity.Error, 'unknown-mcp-server');
|
|
1630
|
+
continue;
|
|
1631
|
+
}
|
|
1632
|
+
if (parsedCall) {
|
|
1633
|
+
const seenArgs = new Set();
|
|
1634
|
+
for (const arg of parsedCall.args) {
|
|
1635
|
+
if (!arg.name) {
|
|
1636
|
+
continue;
|
|
1637
|
+
}
|
|
1638
|
+
const lowerName = arg.name.toLowerCase();
|
|
1639
|
+
const startCol = line.indexOf(arg.name);
|
|
1640
|
+
if (seenArgs.has(lowerName)) {
|
|
1641
|
+
const range = new vscode.Range(new vscode.Position(i, Math.max(0, startCol)), new vscode.Position(i, Math.max(0, startCol) + arg.name.length));
|
|
1642
|
+
pushNornDiagnostic(diagnostics, range, `Duplicate MCP parameter '${arg.name}' in tool call.`, vscode.DiagnosticSeverity.Error, 'duplicate-mcp-arg');
|
|
1643
|
+
}
|
|
1644
|
+
seenArgs.add(lowerName);
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
// Check for undefined API endpoints and header groups
|
|
1649
|
+
// Match patterns like: GET EndpointName("arg") HeaderGroup
|
|
1650
|
+
const apiRequestRegex = /^\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+([A-Z][a-zA-Z0-9_]*)(?:\([^)]*\))?(?:\s+([A-Z][a-zA-Z0-9_]*))?/;
|
|
1651
|
+
// Match standalone header group on its own line inside a request block.
|
|
1652
|
+
const standaloneHeaderGroupRegex = /^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*$/;
|
|
1653
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1654
|
+
const line = lines[i];
|
|
1655
|
+
// Skip comment lines
|
|
1656
|
+
if (isCommentLine(line)) {
|
|
1657
|
+
continue;
|
|
1658
|
+
}
|
|
1659
|
+
// Strip inline comments before checking API request syntax
|
|
1660
|
+
const lineWithoutComment = (0, stringUtils_1.stripInlineComment)(line);
|
|
1661
|
+
// Check API request syntax
|
|
1662
|
+
const apiMatch = lineWithoutComment.match(apiRequestRegex);
|
|
1663
|
+
if (apiMatch) {
|
|
1664
|
+
const endpointName = apiMatch[2];
|
|
1665
|
+
const headerGroupName = apiMatch[3];
|
|
1666
|
+
// Check if endpoint exists
|
|
1667
|
+
if (!importedEndpoints.has(endpointName)) {
|
|
1668
|
+
const startCol = line.indexOf(endpointName);
|
|
1669
|
+
const range = new vscode.Range(new vscode.Position(i, startCol), new vscode.Position(i, startCol + endpointName.length));
|
|
1670
|
+
pushNornDiagnostic(diagnostics, range, `Undefined endpoint: '${endpointName}'. Import a .nornapi file that defines this endpoint.`, vscode.DiagnosticSeverity.Error, 'undefined-endpoint');
|
|
1671
|
+
}
|
|
1672
|
+
// Check if header group exists (if specified)
|
|
1673
|
+
if (headerGroupName && !importedHeaderGroups.has(headerGroupName)) {
|
|
1674
|
+
const startCol = line.lastIndexOf(headerGroupName);
|
|
1675
|
+
const range = new vscode.Range(new vscode.Position(i, startCol), new vscode.Position(i, startCol + headerGroupName.length));
|
|
1676
|
+
pushNornDiagnostic(diagnostics, range, `Undefined header group: '${headerGroupName}'. Import a .nornapi file that defines this header group.`, vscode.DiagnosticSeverity.Error, 'undefined-header-group');
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
// Check standalone header group lines.
|
|
1680
|
+
const standaloneMatch = lineWithoutComment.match(standaloneHeaderGroupRegex);
|
|
1681
|
+
if (standaloneMatch && isInsideRequestBlock(lines, i)) {
|
|
1682
|
+
const headerGroupName = standaloneMatch[1];
|
|
1683
|
+
const shouldValidateAsHeaderGroup = importedHeaderGroups.has(headerGroupName) || /^[A-Z]/.test(headerGroupName);
|
|
1684
|
+
// Only flag as undefined if we have some .nornapi imports (otherwise it might just be a typo or URL)
|
|
1685
|
+
if (shouldValidateAsHeaderGroup && (importedEndpoints.size > 0 || importedHeaderGroups.size > 0)) {
|
|
1686
|
+
if (!importedHeaderGroups.has(headerGroupName)) {
|
|
1687
|
+
const startCol = line.indexOf(headerGroupName);
|
|
1688
|
+
const range = new vscode.Range(new vscode.Position(i, startCol), new vscode.Position(i, startCol + headerGroupName.length));
|
|
1689
|
+
pushNornDiagnostic(diagnostics, range, `Undefined header group: '${headerGroupName}'. Import a .nornapi file that defines this header group.`, vscode.DiagnosticSeverity.Error, 'undefined-header-group');
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
// Check for JSON-like request body properties without surrounding { } object
|
|
1695
|
+
const bodyMethods = new Set(['POST', 'PUT', 'PATCH']);
|
|
1696
|
+
const requestStartRegex = /^\s*(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+/i;
|
|
1697
|
+
const inlineHeaderRegex = /^([A-Za-z][A-Za-z0-9\-]*)\s*:\s*(.+)$/;
|
|
1698
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1699
|
+
const line = lines[i];
|
|
1700
|
+
if (isCommentLine(line)) {
|
|
1701
|
+
continue;
|
|
1702
|
+
}
|
|
1703
|
+
const lineWithoutComment = (0, stringUtils_1.stripInlineComment)(line).trim();
|
|
1704
|
+
const requestMatch = lineWithoutComment.match(requestStartRegex);
|
|
1705
|
+
if (!requestMatch) {
|
|
1706
|
+
continue;
|
|
1707
|
+
}
|
|
1708
|
+
const method = requestMatch[1].toUpperCase();
|
|
1709
|
+
if (!bodyMethods.has(method)) {
|
|
1710
|
+
continue;
|
|
1711
|
+
}
|
|
1712
|
+
let requestContentType;
|
|
1713
|
+
const afterMethod = lineWithoutComment.replace(requestStartRegex, '').trim();
|
|
1714
|
+
const methodLineTokens = afterMethod.split(/\s+/).filter(t => t.length > 0);
|
|
1715
|
+
for (const token of methodLineTokens) {
|
|
1716
|
+
const cleanToken = token.replace(/[,)]+$/g, '');
|
|
1717
|
+
const group = importedHeaderGroups.get(cleanToken)?.headerGroup;
|
|
1718
|
+
if (group) {
|
|
1719
|
+
const headerValue = getHeaderValueCaseInsensitive(group.headers, 'Content-Type');
|
|
1720
|
+
if (headerValue) {
|
|
1721
|
+
requestContentType = headerValue.toLowerCase();
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
let objectDepth = 0;
|
|
1726
|
+
let hasReported = false;
|
|
1727
|
+
let reachedBody = false;
|
|
1728
|
+
const formBodyLines = [];
|
|
1729
|
+
const jsonBodyLines = [];
|
|
1730
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
1731
|
+
const bodyLine = lines[j];
|
|
1732
|
+
if (isCommentLine(bodyLine)) {
|
|
1733
|
+
continue;
|
|
1734
|
+
}
|
|
1735
|
+
const bodyLineWithoutComment = (0, stringUtils_1.stripInlineComment)(bodyLine);
|
|
1736
|
+
const trimmedBodyLine = bodyLineWithoutComment.trim();
|
|
1737
|
+
if (!trimmedBodyLine) {
|
|
1738
|
+
continue;
|
|
1739
|
+
}
|
|
1740
|
+
if (isRequestBodyBoundary(trimmedBodyLine)) {
|
|
1741
|
+
break;
|
|
1742
|
+
}
|
|
1743
|
+
if (!reachedBody) {
|
|
1744
|
+
const headerMatch = trimmedBodyLine.match(inlineHeaderRegex);
|
|
1745
|
+
if (headerMatch) {
|
|
1746
|
+
if (headerMatch[1].toLowerCase() === 'content-type') {
|
|
1747
|
+
requestContentType = headerMatch[2].trim().toLowerCase();
|
|
1748
|
+
}
|
|
1749
|
+
continue;
|
|
1750
|
+
}
|
|
1751
|
+
const headerGroup = importedHeaderGroups.get(trimmedBodyLine)?.headerGroup;
|
|
1752
|
+
if (headerGroup) {
|
|
1753
|
+
const headerValue = getHeaderValueCaseInsensitive(headerGroup.headers, 'Content-Type');
|
|
1754
|
+
if (headerValue) {
|
|
1755
|
+
requestContentType = headerValue.toLowerCase();
|
|
1756
|
+
}
|
|
1757
|
+
continue;
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
reachedBody = true;
|
|
1761
|
+
const isFormBody = (0, formUrlEncoded_1.isFormUrlEncodedContentType)(requestContentType);
|
|
1762
|
+
if (isFormBody) {
|
|
1763
|
+
formBodyLines.push({ lineNumber: j, text: trimmedBodyLine });
|
|
1764
|
+
continue;
|
|
1765
|
+
}
|
|
1766
|
+
const startsJsonObject = trimmedBodyLine.startsWith('{');
|
|
1767
|
+
if (!hasReported && looksLikeJsonPropertyLine(trimmedBodyLine) && objectDepth === 0 && !startsJsonObject) {
|
|
1768
|
+
const startCol = bodyLine.indexOf('"');
|
|
1769
|
+
const range = new vscode.Range(new vscode.Position(j, startCol >= 0 ? startCol : 0), new vscode.Position(j, bodyLine.length));
|
|
1770
|
+
pushNornDiagnostic(diagnostics, range, `Invalid JSON body: properties must be wrapped in '{' and '}'.`, vscode.DiagnosticSeverity.Error, 'invalid-json-body-missing-braces');
|
|
1771
|
+
hasReported = true;
|
|
1772
|
+
}
|
|
1773
|
+
if (isJsonContentType(requestContentType) ||
|
|
1774
|
+
jsonBodyLines.length > 0 ||
|
|
1775
|
+
trimmedBodyLine.startsWith('{') ||
|
|
1776
|
+
trimmedBodyLine.startsWith('[')) {
|
|
1777
|
+
jsonBodyLines.push({ lineNumber: j, text: trimmedBodyLine });
|
|
1778
|
+
}
|
|
1779
|
+
objectDepth += countChar(trimmedBodyLine, '{');
|
|
1780
|
+
objectDepth -= countChar(trimmedBodyLine, '}');
|
|
1781
|
+
if (objectDepth < 0) {
|
|
1782
|
+
objectDepth = 0;
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
if (formBodyLines.length > 0) {
|
|
1786
|
+
const { errors } = (0, formUrlEncoded_1.parseFormUrlEncodedLines)(formBodyLines.map(line => line.text));
|
|
1787
|
+
for (const error of errors) {
|
|
1788
|
+
const sourceLine = formBodyLines[error.lineIndex];
|
|
1789
|
+
if (!sourceLine) {
|
|
1790
|
+
continue;
|
|
1791
|
+
}
|
|
1792
|
+
const documentLine = lines[sourceLine.lineNumber];
|
|
1793
|
+
const startCol = documentLine.indexOf(error.line);
|
|
1794
|
+
const range = new vscode.Range(new vscode.Position(sourceLine.lineNumber, startCol >= 0 ? startCol : 0), new vscode.Position(sourceLine.lineNumber, documentLine.length));
|
|
1795
|
+
pushNornDiagnostic(diagnostics, range, `Invalid form body: ${error.message}`, vscode.DiagnosticSeverity.Error, 'invalid-form-urlencoded-body');
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
else if (!hasReported) {
|
|
1799
|
+
validateJsonBodyLines(diagnostics, lines, jsonBodyLines);
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
// Find empty variable references {{}} or {{ }}
|
|
1803
|
+
const emptyVarRegex = /\{\{\s*\}\}/g;
|
|
1804
|
+
let emptyMatch;
|
|
1805
|
+
while ((emptyMatch = emptyVarRegex.exec(text)) !== null) {
|
|
1806
|
+
const startPos = document.positionAt(emptyMatch.index);
|
|
1807
|
+
const lineNumber = startPos.line;
|
|
1808
|
+
// Ignore empty variable references inside full-line comments
|
|
1809
|
+
if (isCommentLine(lines[lineNumber])) {
|
|
1810
|
+
continue;
|
|
1811
|
+
}
|
|
1812
|
+
const endPos = document.positionAt(emptyMatch.index + emptyMatch[0].length);
|
|
1813
|
+
const range = new vscode.Range(startPos, endPos);
|
|
1814
|
+
pushNornDiagnostic(diagnostics, range, `Empty variable reference`, vscode.DiagnosticSeverity.Error, 'empty-variable');
|
|
1815
|
+
}
|
|
1816
|
+
// Check for invalid variable assignments inside sequences
|
|
1817
|
+
// Valid patterns:
|
|
1818
|
+
// - var x = "string" (quoted string)
|
|
1819
|
+
// - var x = 123 (number)
|
|
1820
|
+
// - var x = true/false/null (literals)
|
|
1821
|
+
// - var x = varName.path (existing variable path)
|
|
1822
|
+
// - var x = $1.body.id (response capture)
|
|
1823
|
+
// - var x = run ... (script/json command)
|
|
1824
|
+
// - var x = https://... (URL - for backward compat outside sequences)
|
|
1825
|
+
const varAssignRegex = /^\s*var\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.+)$/;
|
|
1826
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1827
|
+
const line = lines[i];
|
|
1828
|
+
// Skip comment lines
|
|
1829
|
+
if (isCommentLine(line)) {
|
|
1830
|
+
continue;
|
|
1831
|
+
}
|
|
1832
|
+
// Strip inline comments before processing
|
|
1833
|
+
const lineWithoutComment = (0, stringUtils_1.stripInlineComment)(line);
|
|
1834
|
+
const match = lineWithoutComment.match(varAssignRegex);
|
|
1835
|
+
if (match) {
|
|
1836
|
+
const varName = match[1];
|
|
1837
|
+
const valueExpr = match[2].trim();
|
|
1838
|
+
const containingSeq = sequences.find(seq => i > seq.startLine && i < seq.endLine);
|
|
1839
|
+
if (containingSeq && varName in envVariables) {
|
|
1840
|
+
const startCol = line.indexOf(varName);
|
|
1841
|
+
const range = new vscode.Range(new vscode.Position(i, startCol), new vscode.Position(i, startCol + varName.length));
|
|
1842
|
+
pushNornDiagnostic(diagnostics, range, `Local variable '${varName}' shadows environment variable '${varName}'. Use {{$env.${varName}}} to access the env value explicitly.`, vscode.DiagnosticSeverity.Warning, 'env-variable-shadowed');
|
|
1843
|
+
}
|
|
1844
|
+
// Skip valid patterns
|
|
1845
|
+
// 1. Response capture: $1.path
|
|
1846
|
+
if (/^\$\d+/.test(valueExpr)) {
|
|
1847
|
+
continue;
|
|
1848
|
+
}
|
|
1849
|
+
// 2. Run command: run bash, run js, run powershell, run readJson
|
|
1850
|
+
if (/^run\s+/i.test(valueExpr)) {
|
|
1851
|
+
continue;
|
|
1852
|
+
}
|
|
1853
|
+
// 3. Quoted string: "..." or '...'
|
|
1854
|
+
if ((valueExpr.startsWith('"') && valueExpr.endsWith('"')) ||
|
|
1855
|
+
(valueExpr.startsWith("'") && valueExpr.endsWith("'"))) {
|
|
1856
|
+
continue;
|
|
1857
|
+
}
|
|
1858
|
+
// 4. Number
|
|
1859
|
+
if (/^-?\d+(\.\d+)?$/.test(valueExpr)) {
|
|
1860
|
+
continue;
|
|
1861
|
+
}
|
|
1862
|
+
// 5. Boolean/null
|
|
1863
|
+
if (/^(true|false|null)$/i.test(valueExpr)) {
|
|
1864
|
+
continue;
|
|
1865
|
+
}
|
|
1866
|
+
// 6. URL (common case outside sequences)
|
|
1867
|
+
if (/^https?:\/\//.test(valueExpr)) {
|
|
1868
|
+
continue;
|
|
1869
|
+
}
|
|
1870
|
+
// 7. HTTP request: GET/POST/etc followed by URL or endpoint
|
|
1871
|
+
const httpRequestMatch = valueExpr.match(/^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(.+)$/i);
|
|
1872
|
+
if (httpRequestMatch) {
|
|
1873
|
+
const afterMethod = httpRequestMatch[2].trim();
|
|
1874
|
+
// Check if it's a URL (starts with http, https, or {{ - also handle quoted URLs)
|
|
1875
|
+
if (/^["']?(https?:\/\/|\{\{)/.test(afterMethod)) {
|
|
1876
|
+
// It's a URL - skip validation
|
|
1877
|
+
continue;
|
|
1878
|
+
}
|
|
1879
|
+
// It might be an endpoint name: EndpointName(params) HeaderGroup1 HeaderGroup2
|
|
1880
|
+
const endpointMatch = afterMethod.match(/^([a-zA-Z_][a-zA-Z0-9_]*)(?:\([^)]*\))?(.*)$/);
|
|
1881
|
+
if (endpointMatch) {
|
|
1882
|
+
const endpointName = endpointMatch[1];
|
|
1883
|
+
const afterEndpoint = endpointMatch[2]?.trim() || '';
|
|
1884
|
+
// Validate endpoint exists
|
|
1885
|
+
if (importedEndpoints.size > 0 && !importedEndpoints.has(endpointName)) {
|
|
1886
|
+
const endpointStart = line.indexOf(endpointName, line.indexOf('='));
|
|
1887
|
+
const range = new vscode.Range(new vscode.Position(i, endpointStart), new vscode.Position(i, endpointStart + endpointName.length));
|
|
1888
|
+
pushNornDiagnostic(diagnostics, range, `Undefined endpoint: '${endpointName}'. Import a .nornapi file that defines this endpoint.`, vscode.DiagnosticSeverity.Error, 'undefined-endpoint');
|
|
1889
|
+
}
|
|
1890
|
+
// Validate header groups
|
|
1891
|
+
if (afterEndpoint) {
|
|
1892
|
+
// Strip retry and backoff options before checking header groups
|
|
1893
|
+
// Use \b word boundaries instead of \s+ since afterEndpoint is already trimmed
|
|
1894
|
+
const afterEndpointClean = afterEndpoint
|
|
1895
|
+
.replace(/\bretry\s+\d+/gi, '')
|
|
1896
|
+
.replace(/\bbackoff\s+\d+(?:\.\d+)?\s*(?:s|ms|seconds?|milliseconds?)?/gi, '');
|
|
1897
|
+
const headerGroupNames = afterEndpointClean.split(/\s+/).filter(n => n);
|
|
1898
|
+
for (const hgName of headerGroupNames) {
|
|
1899
|
+
if (!importedHeaderGroups.has(hgName)) {
|
|
1900
|
+
const hgStart = line.lastIndexOf(hgName);
|
|
1901
|
+
const range = new vscode.Range(new vscode.Position(i, hgStart), new vscode.Position(i, hgStart + hgName.length));
|
|
1902
|
+
pushNornDiagnostic(diagnostics, range, `Undefined header group: '${hgName}'. Import a .nornapi file that defines this header group.`, vscode.DiagnosticSeverity.Error, 'undefined-header-group');
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
continue;
|
|
1908
|
+
}
|
|
1909
|
+
// 8. Variable path: must be an existing variable
|
|
1910
|
+
const varPathMatch = valueExpr.match(/^([a-zA-Z_][a-zA-Z0-9_]*)((?:\.[a-zA-Z_][a-zA-Z0-9_]*|\[\d+\])*)$/);
|
|
1911
|
+
if (varPathMatch) {
|
|
1912
|
+
const refVarName = varPathMatch[1];
|
|
1913
|
+
// Check if this assignment is inside a sequence
|
|
1914
|
+
let isValid = false;
|
|
1915
|
+
if (containingSeq) {
|
|
1916
|
+
// Inside sequence: check global vars + local sequence vars + params
|
|
1917
|
+
const localVars = getSequenceLocalVariables(containingSeq.content, containingSeq.parameters);
|
|
1918
|
+
isValid = refVarName in globalVariables || localVars.has(refVarName);
|
|
1919
|
+
}
|
|
1920
|
+
else {
|
|
1921
|
+
// Outside sequences: only global variables
|
|
1922
|
+
isValid = refVarName in globalVariables;
|
|
1923
|
+
}
|
|
1924
|
+
if (isValid) {
|
|
1925
|
+
continue;
|
|
1926
|
+
}
|
|
1927
|
+
// Variable not defined - show error
|
|
1928
|
+
const valueStart = line.indexOf('=') + 1;
|
|
1929
|
+
const valueStartTrimmed = valueStart + (line.substring(valueStart).length - line.substring(valueStart).trimStart().length);
|
|
1930
|
+
const range = new vscode.Range(new vscode.Position(i, valueStartTrimmed), new vscode.Position(i, valueStartTrimmed + refVarName.length));
|
|
1931
|
+
pushNornDiagnostic(diagnostics, range, `Undefined variable: '${refVarName}'. Use quotes for string literals: var ${varName} = "${valueExpr}"`, vscode.DiagnosticSeverity.Error, 'undefined-var-reference');
|
|
1932
|
+
continue;
|
|
1933
|
+
}
|
|
1934
|
+
// If we get here, it's an invalid expression (has spaces, not quoted, not valid pattern)
|
|
1935
|
+
const valueStart = line.indexOf('=') + 1;
|
|
1936
|
+
const valueStartTrimmed = valueStart + (line.substring(valueStart).length - line.substring(valueStart).trimStart().length);
|
|
1937
|
+
const range = new vscode.Range(new vscode.Position(i, valueStartTrimmed), new vscode.Position(i, line.length));
|
|
1938
|
+
pushNornDiagnostic(diagnostics, range, `Invalid variable value. Use quotes for strings: var ${varName} = "${valueExpr}"`, vscode.DiagnosticSeverity.Error, 'invalid-var-value');
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
// Find all variable references {{variableName}}, {{variableName.path}}, or {{$env.variableName}}
|
|
1942
|
+
const variableRefRegex = /\{\{(\$env|[a-zA-Z_][a-zA-Z0-9_]*)((?:\.[a-zA-Z_][a-zA-Z0-9_]*|\[\d+\])*)\}\}/g;
|
|
1943
|
+
let match;
|
|
1944
|
+
while ((match = variableRefRegex.exec(text)) !== null) {
|
|
1945
|
+
const varName = match[1];
|
|
1946
|
+
const pathPart = match[2] || '';
|
|
1947
|
+
const startPos = document.positionAt(match.index);
|
|
1948
|
+
const lineNumber = startPos.line;
|
|
1949
|
+
// Skip if this line is a comment
|
|
1950
|
+
if (isCommentLine(lines[lineNumber])) {
|
|
1951
|
+
continue;
|
|
1952
|
+
}
|
|
1953
|
+
// Check if this reference is inside a sequence
|
|
1954
|
+
const containingSequence = sequences.find(seq => lineNumber > seq.startLine && lineNumber < seq.endLine);
|
|
1955
|
+
let isValid = false;
|
|
1956
|
+
let diagnosticMessage;
|
|
1957
|
+
if (varName === '$env') {
|
|
1958
|
+
const envVarName = getEnvScopedBaseName(pathPart);
|
|
1959
|
+
if (!envVarName) {
|
|
1960
|
+
diagnosticMessage = `Environment variable references must use {{$env.name}} syntax.`;
|
|
1961
|
+
}
|
|
1962
|
+
else if (!environmentVariableNames.has(envVarName)) {
|
|
1963
|
+
diagnosticMessage = `Undefined environment variable: '${envVarName}'`;
|
|
1964
|
+
}
|
|
1965
|
+
else {
|
|
1966
|
+
isValid = true;
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
if (!isValid && !diagnosticMessage && containingSequence) {
|
|
1970
|
+
// Inside a sequence: check global vars + local sequence vars + params
|
|
1971
|
+
const localVars = getSequenceLocalVariables(containingSequence.content, containingSequence.parameters);
|
|
1972
|
+
isValid = varName in globalVariables || environmentVariableNames.has(varName) || localVars.has(varName);
|
|
1973
|
+
}
|
|
1974
|
+
else if (!isValid && !diagnosticMessage) {
|
|
1975
|
+
// Outside sequences: only global variables are available
|
|
1976
|
+
isValid = varName in globalVariables || environmentVariableNames.has(varName);
|
|
1977
|
+
}
|
|
1978
|
+
if (!isValid) {
|
|
1979
|
+
const endPos = document.positionAt(match.index + match[0].length);
|
|
1980
|
+
const range = new vscode.Range(startPos, endPos);
|
|
1981
|
+
pushNornDiagnostic(diagnostics, range, diagnosticMessage || `Undefined variable: '${varName}'`, vscode.DiagnosticSeverity.Error, diagnosticMessage ? 'invalid-env-variable-reference' : 'undefined-variable');
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
// Check bare/path variable reads that are not wrapped in {{...}}.
|
|
1985
|
+
// Only validate the base identifier so dynamic JSON paths remain valid.
|
|
1986
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1987
|
+
const line = lines[i];
|
|
1988
|
+
if (isCommentLine(line)) {
|
|
1989
|
+
continue;
|
|
1990
|
+
}
|
|
1991
|
+
const lineWithoutComment = (0, stringUtils_1.stripInlineComment)(line);
|
|
1992
|
+
const availableVars = getAvailableVariablesForLine(sequences, globalVariables, i);
|
|
1993
|
+
const nonEnvironmentVariableNames = getAvailableNonEnvironmentVariablesForLine(sequences, fileLevelVariables, i);
|
|
1994
|
+
const propertyAssignment = (0, jsonFileReader_1.parsePropertyAssignment)(lineWithoutComment);
|
|
1995
|
+
if (propertyAssignment && !availableVars.has(propertyAssignment.varName)) {
|
|
1996
|
+
const startCol = line.indexOf(propertyAssignment.varName);
|
|
1997
|
+
addUndefinedBaseVariableDiagnostic(diagnostics, i, startCol, propertyAssignment.varName);
|
|
1998
|
+
}
|
|
1999
|
+
const assertMatch = lineWithoutComment.match(/^\s*assert\s+/i);
|
|
2000
|
+
if (assertMatch) {
|
|
2001
|
+
const parsed = (0, assertionRunner_1.parseAssertCommand)(lineWithoutComment);
|
|
2002
|
+
if (parsed) {
|
|
2003
|
+
const expressions = [parsed.leftExpr];
|
|
2004
|
+
if (parsed.rightExpr && parsed.operator !== 'isType') {
|
|
2005
|
+
expressions.push(parsed.rightExpr);
|
|
2006
|
+
}
|
|
2007
|
+
let searchFrom = assertMatch[0].length;
|
|
2008
|
+
for (const expression of expressions) {
|
|
2009
|
+
const expressionStart = lineWithoutComment.indexOf(expression, searchFrom);
|
|
2010
|
+
if (expressionStart < 0) {
|
|
2011
|
+
continue;
|
|
2012
|
+
}
|
|
2013
|
+
addBareTemplateExpressionDiagnostic(diagnostics, expression, expressionStart, i, environmentVariableNames, nonEnvironmentVariableNames);
|
|
2014
|
+
addUndefinedVariableReadDiagnostic(diagnostics, expression, expressionStart, i, availableVars);
|
|
2015
|
+
searchFrom = expressionStart + expression.length;
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
continue;
|
|
2019
|
+
}
|
|
2020
|
+
const ifMatch = lineWithoutComment.match(/^\s*if\s+(.+)$/i);
|
|
2021
|
+
if (ifMatch) {
|
|
2022
|
+
const parsed = (0, assertionRunner_1.parseAssertCommand)(`assert ${ifMatch[1]}`);
|
|
2023
|
+
if (parsed) {
|
|
2024
|
+
const expressions = [parsed.leftExpr];
|
|
2025
|
+
if (parsed.rightExpr && parsed.operator !== 'isType') {
|
|
2026
|
+
expressions.push(parsed.rightExpr);
|
|
2027
|
+
}
|
|
2028
|
+
let searchFrom = lineWithoutComment.indexOf(ifMatch[1]);
|
|
2029
|
+
for (const expression of expressions) {
|
|
2030
|
+
const expressionStart = lineWithoutComment.indexOf(expression, searchFrom);
|
|
2031
|
+
if (expressionStart < 0) {
|
|
2032
|
+
continue;
|
|
2033
|
+
}
|
|
2034
|
+
addBareTemplateExpressionDiagnostic(diagnostics, expression, expressionStart, i, environmentVariableNames, nonEnvironmentVariableNames);
|
|
2035
|
+
addUndefinedVariableReadDiagnostic(diagnostics, expression, expressionStart, i, availableVars);
|
|
2036
|
+
searchFrom = expressionStart + expression.length;
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
continue;
|
|
2040
|
+
}
|
|
2041
|
+
const returnMatch = lineWithoutComment.match(/^\s*return\s+(.+)$/i);
|
|
2042
|
+
if (returnMatch) {
|
|
2043
|
+
const returnContentStart = lineWithoutComment.indexOf(returnMatch[1]);
|
|
2044
|
+
for (const part of splitCommaSeparatedExpressions(returnMatch[1])) {
|
|
2045
|
+
addUndefinedVariableReadDiagnostic(diagnostics, part.text, returnContentStart + part.start, i, availableVars);
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
// Conservative syntax diagnostics for malformed/unknown .norn statements.
|
|
2050
|
+
// Purpose: catch high-confidence typos early (e.g. method/keyword typos) without noisy squiggles while typing.
|
|
2051
|
+
const httpMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
|
|
2052
|
+
const commandKeywords = ['sequence', 'test', 'assert', 'print', 'run', 'import', 'return', 'wait', 'if', 'endif', 'foreach'];
|
|
2053
|
+
const lineHasDiagnostic = (lineNumber) => diagnostics.some(d => d.range.start.line === lineNumber);
|
|
2054
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2055
|
+
const originalLine = lines[i];
|
|
2056
|
+
if (isCommentLine(originalLine)) {
|
|
2057
|
+
continue;
|
|
2058
|
+
}
|
|
2059
|
+
const trimmed = (0, stringUtils_1.stripInlineComment)(originalLine).trim();
|
|
2060
|
+
if (!trimmed) {
|
|
2061
|
+
continue;
|
|
2062
|
+
}
|
|
2063
|
+
if (lineHasDiagnostic(i)) {
|
|
2064
|
+
continue;
|
|
2065
|
+
}
|
|
2066
|
+
if (/^end\s+sequence\b/i.test(trimmed) && !/^end\s+sequence$/i.test(trimmed)) {
|
|
2067
|
+
const startCol = originalLine.toLowerCase().indexOf('end sequence');
|
|
2068
|
+
const range = new vscode.Range(new vscode.Position(i, Math.max(0, startCol)), new vscode.Position(i, originalLine.length));
|
|
2069
|
+
pushNornDiagnostic(diagnostics, range, `Invalid 'end sequence' syntax. Use exactly: end sequence`, vscode.DiagnosticSeverity.Error, 'invalid-end-sequence');
|
|
2070
|
+
continue;
|
|
2071
|
+
}
|
|
2072
|
+
if ((/^end\s+if\b/i.test(trimmed) || /^endif\b/i.test(trimmed)) && !/^(?:end\s+if|endif)$/i.test(trimmed)) {
|
|
2073
|
+
const marker = /^endif\b/i.test(trimmed) ? 'endif' : 'end if';
|
|
2074
|
+
const startCol = originalLine.toLowerCase().indexOf(marker);
|
|
2075
|
+
const range = new vscode.Range(new vscode.Position(i, Math.max(0, startCol)), new vscode.Position(i, originalLine.length));
|
|
2076
|
+
pushNornDiagnostic(diagnostics, range, `Invalid '${marker}' syntax. Use exactly: end if (or endif)`, vscode.DiagnosticSeverity.Error, 'invalid-end-if');
|
|
2077
|
+
continue;
|
|
2078
|
+
}
|
|
2079
|
+
// Sequence declaration starts with a known keyword, so validate explicitly for extra junk text.
|
|
2080
|
+
if (/^(?:test\s+)?sequence\b/i.test(trimmed) && !isValidSequenceDeclarationLine(trimmed)) {
|
|
2081
|
+
// Allow obvious in-progress states while typing.
|
|
2082
|
+
if (!/^(?:test\s+)?sequence\s*$/i.test(trimmed)) {
|
|
2083
|
+
const seqWord = /^\s*test\s+sequence\b/i.test(originalLine) ? 'test sequence' : 'sequence';
|
|
2084
|
+
const startCol = originalLine.toLowerCase().indexOf(seqWord);
|
|
2085
|
+
const range = new vscode.Range(new vscode.Position(i, Math.max(0, startCol)), new vscode.Position(i, originalLine.length));
|
|
2086
|
+
pushNornDiagnostic(diagnostics, range, `Invalid sequence declaration. Use: ${seqWord} Name or ${seqWord} Name(param1, param2)`, vscode.DiagnosticSeverity.Error, 'invalid-sequence-declaration');
|
|
2087
|
+
continue;
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
if (/^wait\b/i.test(trimmed) && !isLikelyPartialCommand(trimmed) && !isValidWaitCommandLine(trimmed)) {
|
|
2091
|
+
const startCol = originalLine.toLowerCase().indexOf('wait');
|
|
2092
|
+
const range = new vscode.Range(new vscode.Position(i, Math.max(0, startCol)), new vscode.Position(i, originalLine.length));
|
|
2093
|
+
pushNornDiagnostic(diagnostics, range, `Invalid wait command. Use: wait 2s, wait 500ms, or wait 1.5s`, vscode.DiagnosticSeverity.Error, 'invalid-wait-command');
|
|
2094
|
+
continue;
|
|
2095
|
+
}
|
|
2096
|
+
if (/^(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?run\b/i.test(trimmed) &&
|
|
2097
|
+
!isLikelyPartialCommand(trimmed) &&
|
|
2098
|
+
!isValidRunLikeCommandLine(trimmed)) {
|
|
2099
|
+
const runIndex = originalLine.toLowerCase().indexOf('run');
|
|
2100
|
+
const range = new vscode.Range(new vscode.Position(i, Math.max(0, runIndex)), new vscode.Position(i, originalLine.length));
|
|
2101
|
+
let message = `Invalid run command syntax.`;
|
|
2102
|
+
if (/\bretry\s+\D/i.test(trimmed)) {
|
|
2103
|
+
message = `Invalid retry syntax in run command. Use: run Name retry 3 (optional backoff 500 ms).`;
|
|
2104
|
+
}
|
|
2105
|
+
else if (/\bbackoff\s+\D/i.test(trimmed)) {
|
|
2106
|
+
message = `Invalid backoff syntax in run command. Use: backoff 500 ms (or 1.5s).`;
|
|
2107
|
+
}
|
|
2108
|
+
else if (/\bheaders?\b/i.test(trimmed)) {
|
|
2109
|
+
message = `Unexpected token after run command. 'headers' is not valid in run syntax.`;
|
|
2110
|
+
}
|
|
2111
|
+
else {
|
|
2112
|
+
message = `Invalid run command. Use a named request/sequence, script command, run sql, or run mcp list/call.`;
|
|
2113
|
+
}
|
|
2114
|
+
pushNornDiagnostic(diagnostics, range, message, vscode.DiagnosticSeverity.Error, 'invalid-run-command');
|
|
2115
|
+
continue;
|
|
2116
|
+
}
|
|
2117
|
+
// Exact method with no URL: high-confidence error
|
|
2118
|
+
const missingUrlMatch = trimmed.match(/^(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s*$/i);
|
|
2119
|
+
if (missingUrlMatch) {
|
|
2120
|
+
// Suppress while typing a likely partial lowercase flow such as "get" without URL? No, exact method is actionable.
|
|
2121
|
+
const method = missingUrlMatch[1].toUpperCase();
|
|
2122
|
+
const methodIndex = originalLine.toUpperCase().indexOf(method);
|
|
2123
|
+
const range = new vscode.Range(new vscode.Position(i, Math.max(0, methodIndex)), new vscode.Position(i, originalLine.length));
|
|
2124
|
+
pushNornDiagnostic(diagnostics, range, `Request is missing a URL after ${method}. Use: ${method} https://api.example.com/path`, vscode.DiagnosticSeverity.Error, 'request-missing-url');
|
|
2125
|
+
continue;
|
|
2126
|
+
}
|
|
2127
|
+
// Regular request method line with suspicious trailing tokens after URL.
|
|
2128
|
+
// This catches invalid combinations like: GET "https://..." headers or GET {{baseUrl}} Peter
|
|
2129
|
+
const requestLineMatch = trimmed.match(/^(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(.+)$/i);
|
|
2130
|
+
if (requestLineMatch) {
|
|
2131
|
+
const { urlToken, tail } = splitRequestUrlAndTail(requestLineMatch[2]);
|
|
2132
|
+
if (urlToken && tail) {
|
|
2133
|
+
const tailWithoutRetry = stripRetryBackoffTokens(tail);
|
|
2134
|
+
if (tailWithoutRetry) {
|
|
2135
|
+
const tailTokens = tailWithoutRetry.split(/\s+/).filter(Boolean);
|
|
2136
|
+
const unknownTokens = tailTokens.filter(token => !importedHeaderGroups.has(token));
|
|
2137
|
+
if (unknownTokens.length > 0) {
|
|
2138
|
+
const firstUnknown = unknownTokens[0];
|
|
2139
|
+
const startCol = originalLine.indexOf(firstUnknown);
|
|
2140
|
+
const range = new vscode.Range(new vscode.Position(i, Math.max(0, startCol)), new vscode.Position(i, Math.max(0, startCol) + firstUnknown.length));
|
|
2141
|
+
const lower = firstUnknown.toLowerCase();
|
|
2142
|
+
let message = `Unexpected token '${firstUnknown}' after request URL.`;
|
|
2143
|
+
if (lower === 'headers' || lower === 'header') {
|
|
2144
|
+
message = `Invalid request syntax: use a header group name after the URL (e.g. Json), not the keyword '${firstUnknown}'.`;
|
|
2145
|
+
}
|
|
2146
|
+
else if (/^[A-Z][a-zA-Z0-9_]*$/.test(firstUnknown) || /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(firstUnknown)) {
|
|
2147
|
+
if (importedHeaderGroups.size > 0) {
|
|
2148
|
+
message = `Unknown header group '${firstUnknown}' after request URL. Import a .nornapi file that defines it.`;
|
|
2149
|
+
}
|
|
2150
|
+
else {
|
|
2151
|
+
message = `Unexpected token '${firstUnknown}' after request URL. If this is a header group, import a .nornapi file and use a defined group name.`;
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
pushNornDiagnostic(diagnostics, range, message, vscode.DiagnosticSeverity.Error, 'invalid-request-trailing-token');
|
|
2155
|
+
continue;
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
// HTTP method typo detection (e.g. POTS/GE T)
|
|
2161
|
+
const methodLikeMatch = trimmed.match(/^(?:var\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*)?([A-Za-z]{2,10})\b/);
|
|
2162
|
+
if (methodLikeMatch) {
|
|
2163
|
+
const token = methodLikeMatch[1];
|
|
2164
|
+
const upperToken = token.toUpperCase();
|
|
2165
|
+
if (!httpMethods.includes(upperToken)) {
|
|
2166
|
+
const suggestedMethod = suggestClosestKeyword(upperToken, httpMethods);
|
|
2167
|
+
// Only flag if it looks like an uppercase/loud request method typo and the line isn't clearly another valid statement.
|
|
2168
|
+
const looksLikeMethodIntent = token === upperToken || httpMethods.some(m => m.startsWith(upperToken));
|
|
2169
|
+
if (suggestedMethod && looksLikeMethodIntent && !isKnownNornCommandOrStructureStart(trimmed)) {
|
|
2170
|
+
const startCol = originalLine.indexOf(token);
|
|
2171
|
+
const range = new vscode.Range(new vscode.Position(i, Math.max(0, startCol)), new vscode.Position(i, Math.max(0, startCol) + token.length));
|
|
2172
|
+
pushNornDiagnostic(diagnostics, range, `Unknown HTTP method '${token}'. Did you mean '${suggestedMethod}'?`, vscode.DiagnosticSeverity.Error, 'invalid-http-method');
|
|
2173
|
+
continue;
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
if (isLikelyPartialCommand(trimmed)) {
|
|
2178
|
+
continue;
|
|
2179
|
+
}
|
|
2180
|
+
const allowAsRequestBodyValue = isLikelyRequestBodyValueLine(trimmed) && isInsideRequestBlock(lines, i);
|
|
2181
|
+
// Unknown command typo detection for the first word (outside known request/body/header structures)
|
|
2182
|
+
if (!allowAsRequestBodyValue && !isKnownNornCommandOrStructureStart(trimmed)) {
|
|
2183
|
+
const firstTokenMatch = trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_-]*)/);
|
|
2184
|
+
if (firstTokenMatch) {
|
|
2185
|
+
const firstToken = firstTokenMatch[1];
|
|
2186
|
+
const suggestion = suggestClosestKeyword(firstToken, commandKeywords);
|
|
2187
|
+
const startCol = originalLine.indexOf(firstToken);
|
|
2188
|
+
const range = new vscode.Range(new vscode.Position(i, Math.max(0, startCol)), new vscode.Position(i, Math.max(0, startCol) + firstToken.length));
|
|
2189
|
+
pushNornDiagnostic(diagnostics, range, suggestion
|
|
2190
|
+
? `Unknown command '${firstToken}'. Did you mean '${suggestion}'?`
|
|
2191
|
+
: `Unrecognized statement. Norn couldn't parse this line.`, vscode.DiagnosticSeverity.Error, suggestion ? 'unknown-command-typo' : 'unknown-statement');
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
// Check for bare text in print statements that isn't a string, variable, or keyword
|
|
2196
|
+
const printCommandRegex = /^\s*print\s+(.+)$/i;
|
|
2197
|
+
const validKeywords = new Set(['true', 'false', 'null']);
|
|
2198
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2199
|
+
const line = lines[i];
|
|
2200
|
+
// Skip comment lines
|
|
2201
|
+
if (isCommentLine(line)) {
|
|
2202
|
+
continue;
|
|
2203
|
+
}
|
|
2204
|
+
// Strip inline comments before processing print statement
|
|
2205
|
+
const lineWithoutComment = (0, stringUtils_1.stripInlineComment)(line);
|
|
2206
|
+
const printMatch = lineWithoutComment.match(printCommandRegex);
|
|
2207
|
+
if (!printMatch) {
|
|
2208
|
+
continue;
|
|
2209
|
+
}
|
|
2210
|
+
const printContent = printMatch[1];
|
|
2211
|
+
const printStartCol = line.indexOf(printContent);
|
|
2212
|
+
// Check if this print is inside a sequence
|
|
2213
|
+
const containingSeq = sequences.find(seq => i > seq.startLine && i < seq.endLine);
|
|
2214
|
+
// Get available variables for this scope
|
|
2215
|
+
let availableVars;
|
|
2216
|
+
if (containingSeq) {
|
|
2217
|
+
const localVars = getSequenceLocalVariables(containingSeq.content, containingSeq.parameters);
|
|
2218
|
+
availableVars = new Set([...Object.keys(globalVariables), ...localVars]);
|
|
2219
|
+
}
|
|
2220
|
+
else {
|
|
2221
|
+
availableVars = new Set(Object.keys(globalVariables));
|
|
2222
|
+
}
|
|
2223
|
+
// Parse print content into tokens (respecting quoted strings and operators)
|
|
2224
|
+
const tokens = tokenizePrintContent(printContent);
|
|
2225
|
+
// Check for missing concatenation operators between tokens
|
|
2226
|
+
for (let t = 0; t < tokens.length - 1; t++) {
|
|
2227
|
+
const currentToken = tokens[t];
|
|
2228
|
+
const nextToken = tokens[t + 1];
|
|
2229
|
+
// Skip if current token is an operator
|
|
2230
|
+
if (/^[+\-*/|&<>=!]+$/.test(currentToken.text)) {
|
|
2231
|
+
continue;
|
|
2232
|
+
}
|
|
2233
|
+
// If next token is not an operator, we're missing concatenation
|
|
2234
|
+
if (!/^[+\-*/|&<>=!]+$/.test(nextToken.text)) {
|
|
2235
|
+
// Calculate position between the two tokens
|
|
2236
|
+
const errorStartCol = printStartCol + currentToken.start + currentToken.text.length;
|
|
2237
|
+
const errorEndCol = printStartCol + nextToken.start;
|
|
2238
|
+
const range = new vscode.Range(new vscode.Position(i, errorStartCol), new vscode.Position(i, errorEndCol));
|
|
2239
|
+
pushNornDiagnostic(diagnostics, range, `Missing concatenation operator '+' between '${currentToken.text}' and '${nextToken.text}'`, vscode.DiagnosticSeverity.Error, 'missing-concatenation');
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
for (const token of tokens) {
|
|
2243
|
+
// Skip operators (+, -, etc.)
|
|
2244
|
+
if (/^[+\-*/|&<>=!]+$/.test(token.text)) {
|
|
2245
|
+
continue;
|
|
2246
|
+
}
|
|
2247
|
+
// Skip quoted strings
|
|
2248
|
+
if ((token.text.startsWith('"') && token.text.endsWith('"')) ||
|
|
2249
|
+
(token.text.startsWith("'") && token.text.endsWith("'"))) {
|
|
2250
|
+
continue;
|
|
2251
|
+
}
|
|
2252
|
+
// Skip numbers
|
|
2253
|
+
if (/^-?\d+(\.\d+)?$/.test(token.text)) {
|
|
2254
|
+
continue;
|
|
2255
|
+
}
|
|
2256
|
+
// Skip keywords (true, false, null)
|
|
2257
|
+
if (validKeywords.has(token.text.toLowerCase())) {
|
|
2258
|
+
continue;
|
|
2259
|
+
}
|
|
2260
|
+
// Skip variable references {{var}}
|
|
2261
|
+
if (/^\{\{.+\}\}$/.test(token.text)) {
|
|
2262
|
+
continue;
|
|
2263
|
+
}
|
|
2264
|
+
// Check if it's a valid variable (with optional path)
|
|
2265
|
+
const varPathMatch = token.text.match(/^([a-zA-Z_][a-zA-Z0-9_]*)((?:\.[a-zA-Z_][a-zA-Z0-9_]*|\[\d+\])*)$/);
|
|
2266
|
+
if (varPathMatch) {
|
|
2267
|
+
const refVarName = varPathMatch[1];
|
|
2268
|
+
if (availableVars.has(refVarName)) {
|
|
2269
|
+
continue;
|
|
2270
|
+
}
|
|
2271
|
+
const tokenStartCol = printStartCol + token.start;
|
|
2272
|
+
const range = new vscode.Range(new vscode.Position(i, tokenStartCol), new vscode.Position(i, tokenStartCol + refVarName.length));
|
|
2273
|
+
pushNornDiagnostic(diagnostics, range, `Undefined variable: '${refVarName}'. Use quotes for string literals: "${token.text}"`, vscode.DiagnosticSeverity.Error, 'undefined-print-variable');
|
|
2274
|
+
continue;
|
|
2275
|
+
}
|
|
2276
|
+
// Check if it looks like a plain text word (not a valid identifier pattern)
|
|
2277
|
+
// Flag words that look like they should be quoted
|
|
2278
|
+
if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(token.text)) {
|
|
2279
|
+
// It's a valid identifier but not a defined variable - flag it
|
|
2280
|
+
const tokenStartCol = printStartCol + token.start;
|
|
2281
|
+
const range = new vscode.Range(new vscode.Position(i, tokenStartCol), new vscode.Position(i, tokenStartCol + token.text.length));
|
|
2282
|
+
pushNornDiagnostic(diagnostics, range, `Undefined variable: '${token.text}'. Use quotes for string literals: "${token.text}"`, vscode.DiagnosticSeverity.Error, 'undefined-print-variable');
|
|
2283
|
+
}
|
|
2284
|
+
else if (!/^[a-zA-Z_]/.test(token.text) === false && !varPathMatch) {
|
|
2285
|
+
// Some other bare text that doesn't look like anything valid
|
|
2286
|
+
const tokenStartCol = printStartCol + token.start;
|
|
2287
|
+
const range = new vscode.Range(new vscode.Position(i, tokenStartCol), new vscode.Position(i, tokenStartCol + token.text.length));
|
|
2288
|
+
pushNornDiagnostic(diagnostics, range, `Invalid expression: '${token.text}'. Use quotes for string literals.`, vscode.DiagnosticSeverity.Error, 'invalid-print-expression');
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
this.diagnosticCollection.set(document.uri, diagnostics);
|
|
2293
|
+
}
|
|
2294
|
+
/**
|
|
2295
|
+
* Update diagnostics for .nornapi files
|
|
2296
|
+
*/
|
|
2297
|
+
updateNornapiDiagnostics(document) {
|
|
2298
|
+
const diagnostics = [];
|
|
2299
|
+
const lines = document.getText().split('\n');
|
|
2300
|
+
const httpMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
|
|
2301
|
+
const variableContext = getNornapiVariableDiagnosticContext(document.uri.fsPath);
|
|
2302
|
+
let inEndpointsBlock = false;
|
|
2303
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2304
|
+
const line = lines[i];
|
|
2305
|
+
const lineWithoutComment = (0, stringUtils_1.stripInlineComment)(line);
|
|
2306
|
+
const trimmed = lineWithoutComment.trim();
|
|
2307
|
+
// Skip empty lines/comments
|
|
2308
|
+
if (trimmed === '' || trimmed.startsWith('#')) {
|
|
2309
|
+
continue;
|
|
2310
|
+
}
|
|
2311
|
+
addNornapiPlaceholderDiagnostics(diagnostics, i, line, lineWithoutComment, 0, variableContext, false);
|
|
2312
|
+
// Track endpoints block state
|
|
2313
|
+
if (/^endpoints$/i.test(trimmed)) {
|
|
2314
|
+
inEndpointsBlock = true;
|
|
2315
|
+
continue;
|
|
2316
|
+
}
|
|
2317
|
+
if (/^end\s+endpoints$/i.test(trimmed)) {
|
|
2318
|
+
inEndpointsBlock = false;
|
|
2319
|
+
continue;
|
|
2320
|
+
}
|
|
2321
|
+
if (!inEndpointsBlock) {
|
|
2322
|
+
continue;
|
|
2323
|
+
}
|
|
2324
|
+
// Valid endpoint declaration: Name: METHOD path
|
|
2325
|
+
const validEndpoint = trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(.+)$/i);
|
|
2326
|
+
if (validEndpoint) {
|
|
2327
|
+
const endpointPath = validEndpoint[3];
|
|
2328
|
+
const pathStart = line.indexOf(endpointPath);
|
|
2329
|
+
addNornapiPlaceholderDiagnostics(diagnostics, i, line, endpointPath, pathStart >= 0 ? pathStart : 0, variableContext, true);
|
|
2330
|
+
continue;
|
|
2331
|
+
}
|
|
2332
|
+
const endpointMatch = trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/);
|
|
2333
|
+
if (!endpointMatch) {
|
|
2334
|
+
continue;
|
|
2335
|
+
}
|
|
2336
|
+
const endpointName = endpointMatch[1];
|
|
2337
|
+
const rhs = endpointMatch[2].trim();
|
|
2338
|
+
const colonIndex = line.indexOf(':');
|
|
2339
|
+
const valueStart = colonIndex >= 0 ? colonIndex + 1 : 0;
|
|
2340
|
+
const valueRange = new vscode.Range(new vscode.Position(i, Math.max(0, valueStart)), new vscode.Position(i, line.length));
|
|
2341
|
+
// While typing: "Name:" or "Name: G" should not error yet
|
|
2342
|
+
if (rhs === '') {
|
|
2343
|
+
continue;
|
|
2344
|
+
}
|
|
2345
|
+
const firstToken = rhs.split(/\s+/)[0];
|
|
2346
|
+
const upperFirstToken = firstToken.toUpperCase();
|
|
2347
|
+
const isPartialMethod = httpMethods.some(m => m.startsWith(upperFirstToken));
|
|
2348
|
+
if (!rhs.includes(' ') && isPartialMethod) {
|
|
2349
|
+
continue;
|
|
2350
|
+
}
|
|
2351
|
+
if (httpMethods.includes(upperFirstToken) && !rhs.includes(' ')) {
|
|
2352
|
+
pushNornDiagnostic(diagnostics, valueRange, `Endpoint '${endpointName}' is missing a URL/path after method. Use: ${endpointName}: ${upperFirstToken} /path`, vscode.DiagnosticSeverity.Error, 'missing-endpoint-path');
|
|
2353
|
+
continue;
|
|
2354
|
+
}
|
|
2355
|
+
pushNornDiagnostic(diagnostics, valueRange, `Endpoint '${endpointName}' is missing HTTP method. Use: ${endpointName}: GET /path`, vscode.DiagnosticSeverity.Error, 'missing-endpoint-method');
|
|
2356
|
+
}
|
|
2357
|
+
this.diagnosticCollection.set(document.uri, diagnostics);
|
|
2358
|
+
}
|
|
2359
|
+
/**
|
|
2360
|
+
* Update diagnostics for .nornenv files
|
|
2361
|
+
*/
|
|
2362
|
+
updateNornenvDiagnostics(document) {
|
|
2363
|
+
const diagnostics = [];
|
|
2364
|
+
const text = document.getText();
|
|
2365
|
+
const lines = text.split('\n');
|
|
2366
|
+
const templateDeclarations = [];
|
|
2367
|
+
const sections = [];
|
|
2368
|
+
const parentReferences = [];
|
|
2369
|
+
let currentSection;
|
|
2370
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2371
|
+
const line = lines[i];
|
|
2372
|
+
const trimmed = line.trim();
|
|
2373
|
+
// Skip comments and empty lines
|
|
2374
|
+
if (trimmed === '' || trimmed.startsWith('#')) {
|
|
2375
|
+
continue;
|
|
2376
|
+
}
|
|
2377
|
+
const section = parseNornenvSectionForDiagnostics(line, i);
|
|
2378
|
+
if (section) {
|
|
2379
|
+
sections.push(section);
|
|
2380
|
+
parentReferences.push(...getNornenvParentReferencesForDiagnostics(line, section));
|
|
2381
|
+
currentSection = { kind: section.kind, name: section.name };
|
|
2382
|
+
}
|
|
2383
|
+
const templateDeclaration = getNornenvTemplateDeclarationForDiagnostics(line, i, currentSection);
|
|
2384
|
+
if (templateDeclaration) {
|
|
2385
|
+
templateDeclarations.push(templateDeclaration);
|
|
2386
|
+
}
|
|
2387
|
+
// Check for invalid "secret var" pattern
|
|
2388
|
+
const secretVarMatch = trimmed.match(/^(secret)\s+(var)\b\s*/i);
|
|
2389
|
+
if (secretVarMatch) {
|
|
2390
|
+
const varStart = line.indexOf('var', line.indexOf('secret') + 6);
|
|
2391
|
+
const range = new vscode.Range(new vscode.Position(i, varStart), new vscode.Position(i, varStart + 3));
|
|
2392
|
+
pushNornDiagnostic(diagnostics, range, `Invalid syntax: 'var' is not allowed after 'secret'. Use 'secret variableName = value'.`, vscode.DiagnosticSeverity.Error, 'invalid-secret-var');
|
|
2393
|
+
continue;
|
|
2394
|
+
}
|
|
2395
|
+
const encryptedSecretMatch = trimmed.match(/^secret\s+(?:connectionString\s+([a-zA-Z_][a-zA-Z0-9_]*)|([a-zA-Z_][a-zA-Z0-9_]*))\s*=\s*(ENC\[.+)$/i);
|
|
2396
|
+
if (encryptedSecretMatch) {
|
|
2397
|
+
const encryptedValue = encryptedSecretMatch[3].trim();
|
|
2398
|
+
if (encryptedValue.endsWith(']')) {
|
|
2399
|
+
const parsed = (0, crypto_1.parseEncryptedSecretValue)(encryptedValue);
|
|
2400
|
+
if (!parsed.ok) {
|
|
2401
|
+
const range = new vscode.Range(new vscode.Position(i, 0), new vscode.Position(i, line.length));
|
|
2402
|
+
pushNornDiagnostic(diagnostics, range, parsed.error, vscode.DiagnosticSeverity.Error, 'invalid-encrypted-secret');
|
|
2403
|
+
continue;
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
// Check for valid patterns - if none match, it's an error
|
|
2408
|
+
const validPatterns = [
|
|
2409
|
+
/^\[(?:env|template):[a-zA-Z_][a-zA-Z0-9_-]*(?:\s+extends\s+[a-zA-Z_][a-zA-Z0-9_-]*(?:\s*,\s*[a-zA-Z_][a-zA-Z0-9_-]*)*)?\]$/, // Environment/template section
|
|
2410
|
+
/^connectionString\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*.+$/, // Complete connection string declaration
|
|
2411
|
+
/^secret\s+connectionString\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*.+$/, // Complete secret connection string declaration
|
|
2412
|
+
/^(secret|var)\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*.+$/, // Complete declaration
|
|
2413
|
+
/^connectionString\s*$/, // Just keyword (while typing)
|
|
2414
|
+
/^connectionString\s+[a-zA-Z_][a-zA-Z0-9_]*\s*$/, // Keyword + profile (while typing)
|
|
2415
|
+
/^connectionString\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*$/, // Keyword + profile + = (while typing)
|
|
2416
|
+
/^secret\s+connectionString\s*$/, // secret connectionString while typing
|
|
2417
|
+
/^secret\s+connectionString\s+[a-zA-Z_][a-zA-Z0-9_]*\s*$/, // secret connectionString + profile
|
|
2418
|
+
/^secret\s+connectionString\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*$/, // secret connectionString + profile + =
|
|
2419
|
+
/^(secret|var)\s*$/, // Just keyword (while typing)
|
|
2420
|
+
/^(secret|var)\s+[a-zA-Z_][a-zA-Z0-9_]*\s*$/, // Keyword + name (while typing)
|
|
2421
|
+
/^(secret|var)\s+[a-zA-Z_][a-zA-Z0-9_]*\s*=\s*$/, // Keyword + name + = (while typing)
|
|
2422
|
+
/^import\s+["']?.+["']?\s*$/, // Complete import statement
|
|
2423
|
+
/^import\s*$/, // Just keyword (while typing)
|
|
2424
|
+
/^import\s+$/, // Keyword + space (while typing)
|
|
2425
|
+
];
|
|
2426
|
+
const isValid = validPatterns.some(pattern => pattern.test(trimmed));
|
|
2427
|
+
if (!isValid) {
|
|
2428
|
+
const range = new vscode.Range(new vscode.Position(i, 0), new vscode.Position(i, line.length));
|
|
2429
|
+
pushNornDiagnostic(diagnostics, range, `Invalid syntax. Expected: 'connectionString profile = value', 'secret connectionString profile = value', 'var name = value', 'secret name = value', 'import "path"', '[env:name]', or '[template:name]'.`, vscode.DiagnosticSeverity.Error, 'invalid-nornenv-syntax');
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
const config = (0, environmentParser_1.parseEnvFile)(text, document.uri.scheme === 'file' ? document.uri.fsPath : undefined);
|
|
2433
|
+
let resolvedConfig = config;
|
|
2434
|
+
let importErrors = [];
|
|
2435
|
+
// --- Import validation ---
|
|
2436
|
+
// Only run import checks if the document is saved to disk (has a file URI)
|
|
2437
|
+
if (document.uri.scheme === 'file') {
|
|
2438
|
+
const filePath = document.uri.fsPath;
|
|
2439
|
+
if (config.imports.length > 0) {
|
|
2440
|
+
// Resolve imports and check for errors (duplicates, circular, missing)
|
|
2441
|
+
const result = (0, environmentParser_1.resolveNornenvImports)(config, path.dirname(filePath), filePath, (p) => fs.readFileSync(p, 'utf-8'));
|
|
2442
|
+
resolvedConfig = result.config;
|
|
2443
|
+
importErrors = result.errors;
|
|
2444
|
+
for (const err of importErrors) {
|
|
2445
|
+
// Find the relevant line for the error
|
|
2446
|
+
let errorLine = err.line;
|
|
2447
|
+
// If line is -1 (duplicate from merge), find the import line that caused it
|
|
2448
|
+
if (errorLine === -1) {
|
|
2449
|
+
// For errors about a specific imported file, place on that import line
|
|
2450
|
+
const importLine = config.imports.find(imp => {
|
|
2451
|
+
const resolvedPath = path.resolve(path.dirname(filePath), imp.path.replace(/^["']|["']$/g, ''));
|
|
2452
|
+
return resolvedPath === path.resolve(err.filePath);
|
|
2453
|
+
});
|
|
2454
|
+
if (importLine) {
|
|
2455
|
+
errorLine = importLine.lineNumber;
|
|
2456
|
+
}
|
|
2457
|
+
else {
|
|
2458
|
+
// Fallback: place on first import line
|
|
2459
|
+
errorLine = config.imports[0]?.lineNumber ?? 0;
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
const line = lines[errorLine] ?? '';
|
|
2463
|
+
const range = new vscode.Range(new vscode.Position(errorLine, 0), new vscode.Position(errorLine, line.length));
|
|
2464
|
+
pushNornDiagnostic(diagnostics, range, err.message, vscode.DiagnosticSeverity.Error, 'nornenv-import-error');
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
const sectionLineByName = new Map();
|
|
2469
|
+
for (const section of sections) {
|
|
2470
|
+
sectionLineByName.set(section.name, section);
|
|
2471
|
+
}
|
|
2472
|
+
for (const parentRef of parentReferences) {
|
|
2473
|
+
if ((0, environmentParser_1.findExtendsTemplate)(parentRef.name, resolvedConfig)) {
|
|
2474
|
+
continue;
|
|
2475
|
+
}
|
|
2476
|
+
const range = new vscode.Range(new vscode.Position(parentRef.section.lineNumber, parentRef.start), new vscode.Position(parentRef.section.lineNumber, parentRef.end));
|
|
2477
|
+
const envParent = resolvedConfig.environments.find(env => env.name === parentRef.name);
|
|
2478
|
+
if (envParent) {
|
|
2479
|
+
pushNornDiagnostic(diagnostics, range, `Invalid extends parent '${parentRef.name}'. Environments cannot be inherited; extract shared values to [template:${parentRef.name}] and extend that template instead.`, vscode.DiagnosticSeverity.Error, 'extends-env-parent');
|
|
2480
|
+
continue;
|
|
2481
|
+
}
|
|
2482
|
+
const closest = findClosestNornenvName(parentRef.name, [
|
|
2483
|
+
...resolvedConfig.templates.map(template => template.name)
|
|
2484
|
+
]);
|
|
2485
|
+
pushNornDiagnostic(diagnostics, range, closest
|
|
2486
|
+
? `Unknown extends parent '${parentRef.name}'. Did you mean '${closest}'?`
|
|
2487
|
+
: `Unknown extends parent '${parentRef.name}'. Define [template:${parentRef.name}] or remove it from extends.`, vscode.DiagnosticSeverity.Error, 'unknown-extends-parent');
|
|
2488
|
+
}
|
|
2489
|
+
for (const cycle of (0, environmentParser_1.detectExtendsCycles)(resolvedConfig)) {
|
|
2490
|
+
const section = sectionLineByName.get(cycle.name);
|
|
2491
|
+
if (!section) {
|
|
2492
|
+
continue;
|
|
2493
|
+
}
|
|
2494
|
+
const line = lines[section.lineNumber] ?? '';
|
|
2495
|
+
const range = new vscode.Range(new vscode.Position(section.lineNumber, 0), new vscode.Position(section.lineNumber, line.length));
|
|
2496
|
+
pushNornDiagnostic(diagnostics, range, `Extends cycle detected: ${cycle.path.join(' -> ')}`, vscode.DiagnosticSeverity.Error, 'extends-cycle');
|
|
2497
|
+
}
|
|
2498
|
+
const allDeclaredNames = buildDeclaredNornenvNameSet(resolvedConfig);
|
|
2499
|
+
for (const declaration of templateDeclarations) {
|
|
2500
|
+
if (!declaration.sectionName) {
|
|
2501
|
+
continue;
|
|
2502
|
+
}
|
|
2503
|
+
const found = (0, environmentParser_1.findExtendsNode)(declaration.sectionName, resolvedConfig);
|
|
2504
|
+
if (!found || found.node.parents.length === 0) {
|
|
2505
|
+
continue;
|
|
2506
|
+
}
|
|
2507
|
+
const inheritedNames = new Set([
|
|
2508
|
+
...Object.keys(resolvedConfig.common),
|
|
2509
|
+
...(0, environmentParser_1.collectAncestorVariableNames)(declaration.sectionName, resolvedConfig)
|
|
2510
|
+
]);
|
|
2511
|
+
if (inheritedNames.has(declaration.name)) {
|
|
2512
|
+
continue;
|
|
2513
|
+
}
|
|
2514
|
+
const closest = findClosestNornenvName(declaration.name, inheritedNames);
|
|
2515
|
+
if (!closest) {
|
|
2516
|
+
continue;
|
|
2517
|
+
}
|
|
2518
|
+
const nameStart = Math.max(0, lines[declaration.lineNumber].indexOf(declaration.name));
|
|
2519
|
+
const range = new vscode.Range(new vscode.Position(declaration.lineNumber, nameStart), new vscode.Position(declaration.lineNumber, nameStart + declaration.name.length));
|
|
2520
|
+
pushNornDiagnostic(diagnostics, range, `Variable '${declaration.name}' is not inherited by [${declaration.sectionKind}:${declaration.sectionName}]. Did you mean to override '${closest}'?`, vscode.DiagnosticSeverity.Warning, 'unknown-override-target');
|
|
2521
|
+
}
|
|
2522
|
+
const templateTokenRegex = /\{\{([^{}]+)\}\}/g;
|
|
2523
|
+
for (const declaration of templateDeclarations) {
|
|
2524
|
+
const allowedNames = buildAllowedNornenvReferenceNames(declaration, resolvedConfig);
|
|
2525
|
+
templateTokenRegex.lastIndex = 0;
|
|
2526
|
+
for (const match of declaration.value.matchAll(templateTokenRegex)) {
|
|
2527
|
+
const rawReference = match[1];
|
|
2528
|
+
const referenceName = parseNornenvTemplateReference(rawReference);
|
|
2529
|
+
const start = declaration.valueStart + (match.index ?? 0);
|
|
2530
|
+
const end = start + match[0].length;
|
|
2531
|
+
const range = new vscode.Range(new vscode.Position(declaration.lineNumber, start), new vscode.Position(declaration.lineNumber, end));
|
|
2532
|
+
if (!referenceName) {
|
|
2533
|
+
pushNornDiagnostic(diagnostics, range, `.nornenv template reference '${rawReference.trim()}' is not supported. Use {{name}} or {{$env.name}}.`, vscode.DiagnosticSeverity.Error, 'unsupported-nornenv-template-reference');
|
|
2534
|
+
continue;
|
|
2535
|
+
}
|
|
2536
|
+
if (!allowedNames.has(referenceName)) {
|
|
2537
|
+
const declaredSomewhere = allDeclaredNames.has(referenceName);
|
|
2538
|
+
pushNornDiagnostic(diagnostics, range, declaredSomewhere && declaration.sectionName
|
|
2539
|
+
? `.nornenv reference '${referenceName}' is not in scope for [${declaration.sectionKind}:${declaration.sectionName}]. Values in a section can reference common variables and ancestor variables only.`
|
|
2540
|
+
: `.nornenv reference '${referenceName}' was not found in common variables, templates, or environments.`, vscode.DiagnosticSeverity.Error, 'unresolved-cross-section-ref');
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
this.diagnosticCollection.set(document.uri, diagnostics);
|
|
2545
|
+
}
|
|
2546
|
+
clearDiagnostics(document) {
|
|
2547
|
+
this.diagnosticCollection.delete(document.uri);
|
|
2548
|
+
}
|
|
2549
|
+
dispose() {
|
|
2550
|
+
this.diagnosticCollection.dispose();
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
exports.DiagnosticProvider = DiagnosticProvider;
|
|
2554
|
+
//# sourceMappingURL=diagnosticProvider.js.map
|