aero-vscode 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.vscodeignore +13 -0
- package/LICENSE +21 -0
- package/README.md +69 -0
- package/aero-vscode-0.0.1.vsix +0 -0
- package/dist/extension.js +19 -0
- package/images/logo.png +0 -0
- package/package.json +98 -0
- package/src/__tests__/analyzer.test.ts +202 -0
- package/src/__tests__/diagnostics.test.ts +964 -0
- package/src/__tests__/providers.test.ts +292 -0
- package/src/__tests__/utils.test.ts +120 -0
- package/src/analyzer.ts +914 -0
- package/src/completionProvider.ts +328 -0
- package/src/constants.ts +35 -0
- package/src/definitionProvider.ts +371 -0
- package/src/diagnostics.ts +732 -0
- package/src/extension.ts +74 -0
- package/src/hoverProvider.ts +134 -0
- package/src/pathResolver.ts +171 -0
- package/src/positionAt.ts +509 -0
- package/src/scope.ts +116 -0
- package/src/utils.ts +56 -0
- package/syntaxes/aero-attributes.json +54 -0
- package/syntaxes/aero-expressions.json +26 -0
- package/syntaxes/aero-globals.json +22 -0
- package/tsconfig.json +19 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aero diagnostics: validate script directives, control flow, component imports, and template references.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* Runs on Aero HTML files; reports missing/duplicate script types, invalid directive expressions, unresolved components, and undefined template variables. Uses analyzer (scopes, defined variables, template refs) and getResolver.
|
|
6
|
+
*/
|
|
7
|
+
import * as vscode from 'vscode'
|
|
8
|
+
import * as fs from 'node:fs'
|
|
9
|
+
import { getResolver } from './pathResolver'
|
|
10
|
+
import { COMPONENT_SUFFIX_REGEX, CONTENT_GLOBALS } from './constants'
|
|
11
|
+
import { isAeroDocument } from './scope'
|
|
12
|
+
import {
|
|
13
|
+
collectDefinedVariables,
|
|
14
|
+
collectVariablesByScope,
|
|
15
|
+
collectTemplateScopes,
|
|
16
|
+
collectTemplateReferences,
|
|
17
|
+
TemplateScope,
|
|
18
|
+
maskJsComments,
|
|
19
|
+
} from './analyzer'
|
|
20
|
+
import { kebabToCamelCase, collectImportedSpecifiers, findInnermostScope } from './utils'
|
|
21
|
+
|
|
22
|
+
const DIAGNOSTIC_SOURCE = 'aero'
|
|
23
|
+
|
|
24
|
+
/** Matches `<script ...>...</script>` tags with attributes and content. */
|
|
25
|
+
const SCRIPT_TAG_REGEX = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi
|
|
26
|
+
|
|
27
|
+
/** Matches is:build, is:inline, or is:blocking in attributes */
|
|
28
|
+
const IS_ATTR_REGEX = /\bis:(build|inline|blocking)\b/
|
|
29
|
+
|
|
30
|
+
/** Matches src= in script attributes (external scripts are exempt) */
|
|
31
|
+
const SRC_ATTR_REGEX = /\bsrc\s*=/
|
|
32
|
+
|
|
33
|
+
/** Matches control flow attributes */
|
|
34
|
+
const IF_ATTR_REGEX = /\b(?:data-)?if(?:\s*=)/
|
|
35
|
+
const ELSE_IF_ATTR_REGEX = /\b(?:data-)?else-if(?:\s*=)/
|
|
36
|
+
const ELSE_ATTR_REGEX = /\b(?:data-)?else\b/
|
|
37
|
+
|
|
38
|
+
/** Matches opening tags and captures the attributes part */
|
|
39
|
+
const OPEN_TAG_REGEX = /<([a-z][a-z0-9]*(?:-[a-z0-9]+)*)\b([^>]*?)\/?>/gi
|
|
40
|
+
|
|
41
|
+
/** Matches opening and closing tags and captures attributes for opening tags */
|
|
42
|
+
const ANY_TAG_REGEX = /<\/?([a-z][a-z0-9]*(?:-[a-z0-9]+)*)\b([^>]*?)\/?>/gi
|
|
43
|
+
|
|
44
|
+
/** HTML void elements that do not create a new nesting level */
|
|
45
|
+
const VOID_ELEMENTS = new Set([
|
|
46
|
+
'area',
|
|
47
|
+
'base',
|
|
48
|
+
'br',
|
|
49
|
+
'col',
|
|
50
|
+
'embed',
|
|
51
|
+
'hr',
|
|
52
|
+
'img',
|
|
53
|
+
'input',
|
|
54
|
+
'link',
|
|
55
|
+
'meta',
|
|
56
|
+
'param',
|
|
57
|
+
'source',
|
|
58
|
+
'track',
|
|
59
|
+
'wbr',
|
|
60
|
+
])
|
|
61
|
+
|
|
62
|
+
/** Matches directive attributes with explicit values */
|
|
63
|
+
const DIRECTIVE_ATTR_VALUE_REGEX =
|
|
64
|
+
/\b(data-if|if|data-else-if|else-if|data-each|each|data-props|props)\s*=\s*(['"])(.*?)\2/gi
|
|
65
|
+
|
|
66
|
+
/** Matches HTML comment blocks */
|
|
67
|
+
const HTML_COMMENT_REGEX = /<!--[\s\S]*?-->/g
|
|
68
|
+
|
|
69
|
+
/** Matches opening tags with component/layout suffix */
|
|
70
|
+
const COMPONENT_TAG_OPEN_REGEX =
|
|
71
|
+
/<([a-z][a-z0-9]*(?:-[a-z0-9]+)*-(?:component|layout))\b[^>]*\/?>/gi
|
|
72
|
+
|
|
73
|
+
export class AeroDiagnostics implements vscode.Disposable {
|
|
74
|
+
private collection: vscode.DiagnosticCollection
|
|
75
|
+
private disposables: vscode.Disposable[] = []
|
|
76
|
+
|
|
77
|
+
constructor(context: vscode.ExtensionContext) {
|
|
78
|
+
this.collection = vscode.languages.createDiagnosticCollection('aero')
|
|
79
|
+
|
|
80
|
+
// Run diagnostics on open and save
|
|
81
|
+
this.disposables.push(
|
|
82
|
+
vscode.workspace.onDidOpenTextDocument(doc => this.updateDiagnostics(doc)),
|
|
83
|
+
vscode.workspace.onDidSaveTextDocument(doc => this.updateDiagnostics(doc)),
|
|
84
|
+
vscode.workspace.onDidChangeTextDocument(e => this.updateDiagnostics(e.document)),
|
|
85
|
+
vscode.workspace.onDidCloseTextDocument(doc => this.collection.delete(doc.uri)),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
// Run on all currently open documents
|
|
89
|
+
for (const doc of vscode.workspace.textDocuments) {
|
|
90
|
+
this.updateDiagnostics(doc)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
dispose(): void {
|
|
95
|
+
this.collection.dispose()
|
|
96
|
+
for (const d of this.disposables) d.dispose()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private updateDiagnostics(document: vscode.TextDocument): void {
|
|
100
|
+
if (!isAeroDocument(document)) {
|
|
101
|
+
this.collection.delete(document.uri)
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const diagnostics: vscode.Diagnostic[] = []
|
|
106
|
+
const text = document.getText()
|
|
107
|
+
|
|
108
|
+
this.checkScriptTags(document, text, diagnostics)
|
|
109
|
+
this.checkConditionalChains(document, text, diagnostics)
|
|
110
|
+
this.checkDirectiveExpressionBraces(document, text, diagnostics)
|
|
111
|
+
this.checkComponentReferences(document, text, diagnostics)
|
|
112
|
+
this.checkUndefinedVariables(document, text, diagnostics)
|
|
113
|
+
this.checkUnusedVariables(document, text, diagnostics)
|
|
114
|
+
this.checkDuplicateDeclarations(document, text, diagnostics)
|
|
115
|
+
|
|
116
|
+
this.collection.set(document.uri, diagnostics)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// -----------------------------------------------------------------------
|
|
120
|
+
// 3. Directive attributes must use brace-wrapped expressions
|
|
121
|
+
// -----------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
private checkDirectiveExpressionBraces(
|
|
124
|
+
document: vscode.TextDocument,
|
|
125
|
+
text: string,
|
|
126
|
+
diagnostics: vscode.Diagnostic[],
|
|
127
|
+
): void {
|
|
128
|
+
const ignoredRanges = getIgnoredRanges(text)
|
|
129
|
+
|
|
130
|
+
OPEN_TAG_REGEX.lastIndex = 0
|
|
131
|
+
let match: RegExpExecArray | null
|
|
132
|
+
|
|
133
|
+
while ((match = OPEN_TAG_REGEX.exec(text)) !== null) {
|
|
134
|
+
const tagStart = match.index
|
|
135
|
+
if (isInRanges(tagStart, ignoredRanges)) continue
|
|
136
|
+
|
|
137
|
+
const attrs = match[2] || ''
|
|
138
|
+
if (!attrs) continue
|
|
139
|
+
|
|
140
|
+
DIRECTIVE_ATTR_VALUE_REGEX.lastIndex = 0
|
|
141
|
+
let attrMatch: RegExpExecArray | null
|
|
142
|
+
while ((attrMatch = DIRECTIVE_ATTR_VALUE_REGEX.exec(attrs)) !== null) {
|
|
143
|
+
const attrName = attrMatch[1]
|
|
144
|
+
const attrValue = (attrMatch[3] || '').trim()
|
|
145
|
+
const needsBraces = this.requiresBracedDirectiveValue(attrName)
|
|
146
|
+
|
|
147
|
+
if (!needsBraces) continue
|
|
148
|
+
if (attrValue.startsWith('{') && attrValue.endsWith('}')) continue
|
|
149
|
+
|
|
150
|
+
const attrsStart = tagStart + match[0].indexOf(attrs)
|
|
151
|
+
const start = attrsStart + attrMatch.index
|
|
152
|
+
const end = start + attrMatch[0].length
|
|
153
|
+
const example = `${attrName}="{ expression }"`
|
|
154
|
+
const diagnostic = new vscode.Diagnostic(
|
|
155
|
+
new vscode.Range(document.positionAt(start), document.positionAt(end)),
|
|
156
|
+
`Directive \`${attrName}\` must use a braced expression, e.g. ${example}`,
|
|
157
|
+
vscode.DiagnosticSeverity.Error,
|
|
158
|
+
)
|
|
159
|
+
diagnostic.source = DIAGNOSTIC_SOURCE
|
|
160
|
+
diagnostics.push(diagnostic)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private requiresBracedDirectiveValue(attrName: string): boolean {
|
|
166
|
+
return [
|
|
167
|
+
'if',
|
|
168
|
+
'data-if',
|
|
169
|
+
'else-if',
|
|
170
|
+
'data-else-if',
|
|
171
|
+
'each',
|
|
172
|
+
'data-each',
|
|
173
|
+
'props',
|
|
174
|
+
'data-props',
|
|
175
|
+
].includes(attrName)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// -----------------------------------------------------------------------
|
|
179
|
+
// 1. Script tags validation
|
|
180
|
+
// -----------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
private isInHead(text: string, position: number): boolean {
|
|
183
|
+
const beforeText = text.slice(0, position)
|
|
184
|
+
const headOpenMatch = beforeText.match(/<head(?:\s|>)/)
|
|
185
|
+
const headCloseMatch = beforeText.match(/<\/head\s*>/)
|
|
186
|
+
const headOpen = headOpenMatch ? headOpenMatch.index! : -1
|
|
187
|
+
const headClose = headCloseMatch ? headCloseMatch.index! : -1
|
|
188
|
+
return headOpen > headClose
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private checkScriptTags(
|
|
192
|
+
document: vscode.TextDocument,
|
|
193
|
+
text: string,
|
|
194
|
+
diagnostics: vscode.Diagnostic[],
|
|
195
|
+
): void {
|
|
196
|
+
const ignoredRanges = getIgnoredRanges(text)
|
|
197
|
+
|
|
198
|
+
SCRIPT_TAG_REGEX.lastIndex = 0
|
|
199
|
+
let match: RegExpExecArray | null
|
|
200
|
+
|
|
201
|
+
while ((match = SCRIPT_TAG_REGEX.exec(text)) !== null) {
|
|
202
|
+
const tagStart = match.index
|
|
203
|
+
if (isInRanges(tagStart, ignoredRanges)) continue
|
|
204
|
+
|
|
205
|
+
const attrs = match[1]
|
|
206
|
+
const content = match[2]
|
|
207
|
+
|
|
208
|
+
// Skip external scripts (have src attribute) - they stay in place
|
|
209
|
+
if (SRC_ATTR_REGEX.test(attrs)) continue
|
|
210
|
+
|
|
211
|
+
// Skip scripts in <head> that might be third-party
|
|
212
|
+
// Use regex to match <head> or <head > tag, not substrings like <header> or <base-layout>
|
|
213
|
+
const beforeText = text.slice(0, tagStart)
|
|
214
|
+
const headOpenMatch = beforeText.match(/<head(?:\s|>)/)
|
|
215
|
+
const headCloseMatch = beforeText.match(/<\/head\s*>/)
|
|
216
|
+
const headOpen = headOpenMatch ? headOpenMatch.index! : -1
|
|
217
|
+
const headClose = headCloseMatch ? headCloseMatch.index! : -1
|
|
218
|
+
if (headOpen > headClose) {
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Check for imports in is:inline scripts (in body) without type="module"
|
|
223
|
+
const hasImport = /\bimport\b/.test(content)
|
|
224
|
+
const hasModuleType = /\btype\s*=\s*["']?module["']?\b/.test(attrs)
|
|
225
|
+
|
|
226
|
+
if (hasImport && !hasModuleType) {
|
|
227
|
+
// Check if it's is:inline (and not in head) — only is:inline needs type="module" for imports
|
|
228
|
+
// Plain <script> are bundled as module by default; no warning for them.
|
|
229
|
+
if (/\bis:inline\b/.test(attrs) && !this.isInHead(text, tagStart)) {
|
|
230
|
+
const contentStart = tagStart + match[0].indexOf(content)
|
|
231
|
+
const importMatch = /\bimport\b/.exec(content)
|
|
232
|
+
if (importMatch) {
|
|
233
|
+
const importStart = contentStart + importMatch.index
|
|
234
|
+
const importEnd = importStart + 6
|
|
235
|
+
const diagnostic = new vscode.Diagnostic(
|
|
236
|
+
new vscode.Range(
|
|
237
|
+
document.positionAt(importStart),
|
|
238
|
+
document.positionAt(importEnd),
|
|
239
|
+
),
|
|
240
|
+
"Imports in <script is:inline> require type=\"module\" attribute.",
|
|
241
|
+
vscode.DiagnosticSeverity.Error,
|
|
242
|
+
)
|
|
243
|
+
diagnostic.source = DIAGNOSTIC_SOURCE
|
|
244
|
+
diagnostics.push(diagnostic)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Valid if has any is:* attribute (build, inline, blocking) or pass:data (handled by Vite). Plain <script> = client by default — no warning.
|
|
250
|
+
if (IS_ATTR_REGEX.test(attrs) || /\bpass:data\b/.test(attrs)) {
|
|
251
|
+
continue
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Plain <script> without attributes are valid (bundled as module by default)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// -----------------------------------------------------------------------
|
|
259
|
+
// 2. Orphaned else-if / else without preceding if
|
|
260
|
+
// -----------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
private checkConditionalChains(
|
|
263
|
+
document: vscode.TextDocument,
|
|
264
|
+
text: string,
|
|
265
|
+
diagnostics: vscode.Diagnostic[],
|
|
266
|
+
): void {
|
|
267
|
+
const lastConditionalTypeByDepth = new Map<number, 'if' | 'else-if' | null>()
|
|
268
|
+
let depth = 0
|
|
269
|
+
const ignoredRanges = getIgnoredRanges(text)
|
|
270
|
+
|
|
271
|
+
ANY_TAG_REGEX.lastIndex = 0
|
|
272
|
+
let match: RegExpExecArray | null
|
|
273
|
+
|
|
274
|
+
const getLastConditionalType = (currentDepth: number): 'if' | 'else-if' | null => {
|
|
275
|
+
return lastConditionalTypeByDepth.get(currentDepth) ?? null
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const setLastConditionalType = (
|
|
279
|
+
currentDepth: number,
|
|
280
|
+
type: 'if' | 'else-if' | null,
|
|
281
|
+
): void => {
|
|
282
|
+
lastConditionalTypeByDepth.set(currentDepth, type)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
while ((match = ANY_TAG_REGEX.exec(text)) !== null) {
|
|
286
|
+
const tagStart = match.index
|
|
287
|
+
if (isInRanges(tagStart, ignoredRanges)) continue
|
|
288
|
+
|
|
289
|
+
const fullTag = match[0]
|
|
290
|
+
const tagName = (match[1] || '').toLowerCase()
|
|
291
|
+
const isClosingTag = fullTag.startsWith('</')
|
|
292
|
+
const isSelfClosingTag = /\/\s*>$/.test(fullTag) || VOID_ELEMENTS.has(tagName)
|
|
293
|
+
|
|
294
|
+
if (isClosingTag) {
|
|
295
|
+
depth = Math.max(0, depth - 1)
|
|
296
|
+
continue
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const currentDepth = depth
|
|
300
|
+
const lastConditionalType = getLastConditionalType(currentDepth)
|
|
301
|
+
|
|
302
|
+
const attrs = match[2] || ''
|
|
303
|
+
if (!attrs) {
|
|
304
|
+
setLastConditionalType(currentDepth, null)
|
|
305
|
+
if (!isSelfClosingTag) depth += 1
|
|
306
|
+
continue
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (IF_ATTR_REGEX.test(attrs) && !ELSE_IF_ATTR_REGEX.test(attrs)) {
|
|
310
|
+
setLastConditionalType(currentDepth, 'if')
|
|
311
|
+
if (!isSelfClosingTag) depth += 1
|
|
312
|
+
continue
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (ELSE_IF_ATTR_REGEX.test(attrs)) {
|
|
316
|
+
if (lastConditionalType !== 'if' && lastConditionalType !== 'else-if') {
|
|
317
|
+
const attrMatch = attrs.match(/(?:data-)?else-if\b/)
|
|
318
|
+
if (attrMatch && attrMatch.index !== undefined) {
|
|
319
|
+
const attrBase = tagStart + match[0].indexOf(attrs)
|
|
320
|
+
const start = attrBase + attrMatch.index
|
|
321
|
+
const end = start + attrMatch[0].length
|
|
322
|
+
const diagnostic = new vscode.Diagnostic(
|
|
323
|
+
new vscode.Range(document.positionAt(start), document.positionAt(end)),
|
|
324
|
+
'else-if must follow an element with if or else-if',
|
|
325
|
+
vscode.DiagnosticSeverity.Error,
|
|
326
|
+
)
|
|
327
|
+
diagnostic.source = DIAGNOSTIC_SOURCE
|
|
328
|
+
diagnostics.push(diagnostic)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
setLastConditionalType(currentDepth, 'else-if')
|
|
332
|
+
if (!isSelfClosingTag) depth += 1
|
|
333
|
+
continue
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (ELSE_ATTR_REGEX.test(attrs) && !ELSE_IF_ATTR_REGEX.test(attrs)) {
|
|
337
|
+
if (lastConditionalType !== 'if' && lastConditionalType !== 'else-if') {
|
|
338
|
+
const attrMatch = attrs.match(/(?:data-)?else\b/)
|
|
339
|
+
if (attrMatch && attrMatch.index !== undefined) {
|
|
340
|
+
const attrBase = tagStart + match[0].indexOf(attrs)
|
|
341
|
+
const start = attrBase + attrMatch.index
|
|
342
|
+
const end = start + attrMatch[0].length
|
|
343
|
+
const diagnostic = new vscode.Diagnostic(
|
|
344
|
+
new vscode.Range(document.positionAt(start), document.positionAt(end)),
|
|
345
|
+
'else must follow an element with if or else-if',
|
|
346
|
+
vscode.DiagnosticSeverity.Error,
|
|
347
|
+
)
|
|
348
|
+
diagnostic.source = DIAGNOSTIC_SOURCE
|
|
349
|
+
diagnostics.push(diagnostic)
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
setLastConditionalType(currentDepth, null)
|
|
353
|
+
if (!isSelfClosingTag) depth += 1
|
|
354
|
+
continue
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
setLastConditionalType(currentDepth, null)
|
|
358
|
+
if (!isSelfClosingTag) depth += 1
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// -----------------------------------------------------------------------
|
|
363
|
+
// 4. Missing component/layout files
|
|
364
|
+
// -----------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
private checkComponentReferences(
|
|
367
|
+
document: vscode.TextDocument,
|
|
368
|
+
text: string,
|
|
369
|
+
diagnostics: vscode.Diagnostic[],
|
|
370
|
+
): void {
|
|
371
|
+
const resolver = getResolver(document)
|
|
372
|
+
if (!resolver) return
|
|
373
|
+
const imports = collectImportedSpecifiers(text)
|
|
374
|
+
const ignoredRanges = getIgnoredRanges(text)
|
|
375
|
+
|
|
376
|
+
COMPONENT_TAG_OPEN_REGEX.lastIndex = 0
|
|
377
|
+
let match: RegExpExecArray | null
|
|
378
|
+
|
|
379
|
+
while ((match = COMPONENT_TAG_OPEN_REGEX.exec(text)) !== null) {
|
|
380
|
+
const tagStart = match.index
|
|
381
|
+
if (isInRanges(tagStart, ignoredRanges)) continue
|
|
382
|
+
|
|
383
|
+
const tagName = match[1]
|
|
384
|
+
const suffixMatch = COMPONENT_SUFFIX_REGEX.exec(tagName)
|
|
385
|
+
if (!suffixMatch) continue
|
|
386
|
+
|
|
387
|
+
const suffix = suffixMatch[1] as 'component' | 'layout'
|
|
388
|
+
const baseName = tagName.replace(COMPONENT_SUFFIX_REGEX, '')
|
|
389
|
+
const importName = kebabToCamelCase(baseName)
|
|
390
|
+
const importedSpecifier = imports.get(importName)
|
|
391
|
+
|
|
392
|
+
if (!importedSpecifier) {
|
|
393
|
+
const startPos = document.positionAt(match.index)
|
|
394
|
+
const endPos = document.positionAt(match.index + match[0].length)
|
|
395
|
+
const diagnostic = new vscode.Diagnostic(
|
|
396
|
+
new vscode.Range(startPos, endPos),
|
|
397
|
+
`Component '${baseName}' is not imported. Explicit imports are required.`,
|
|
398
|
+
vscode.DiagnosticSeverity.Error,
|
|
399
|
+
)
|
|
400
|
+
diagnostic.source = DIAGNOSTIC_SOURCE
|
|
401
|
+
diagnostics.push(diagnostic)
|
|
402
|
+
continue
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const resolved = resolver.resolve(importedSpecifier, document.uri.fsPath)
|
|
406
|
+
if (resolved && !fs.existsSync(resolved)) {
|
|
407
|
+
const startPos = document.positionAt(match.index)
|
|
408
|
+
const endPos = document.positionAt(match.index + match[0].length)
|
|
409
|
+
const diagnostic = new vscode.Diagnostic(
|
|
410
|
+
new vscode.Range(startPos, endPos),
|
|
411
|
+
`${suffix === 'component' ? 'Component' : 'Layout'} file not found: ${baseName}.html`,
|
|
412
|
+
vscode.DiagnosticSeverity.Warning,
|
|
413
|
+
)
|
|
414
|
+
diagnostic.source = DIAGNOSTIC_SOURCE
|
|
415
|
+
diagnostics.push(diagnostic)
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
// -----------------------------------------------------------------------
|
|
420
|
+
// 5. Undefined variables in template
|
|
421
|
+
// -----------------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
private checkUndefinedVariables(
|
|
424
|
+
document: vscode.TextDocument,
|
|
425
|
+
text: string,
|
|
426
|
+
diagnostics: vscode.Diagnostic[],
|
|
427
|
+
): void {
|
|
428
|
+
const [definedVars] = collectDefinedVariables(document, text)
|
|
429
|
+
const templateScopes = collectTemplateScopes(document, text)
|
|
430
|
+
const references = collectTemplateReferences(document, text)
|
|
431
|
+
|
|
432
|
+
// Allowed globals that are always available
|
|
433
|
+
const ALLOWED_GLOBALS = new Set([
|
|
434
|
+
...Object.keys(CONTENT_GLOBALS),
|
|
435
|
+
'Aero',
|
|
436
|
+
'console',
|
|
437
|
+
'Math',
|
|
438
|
+
'JSON',
|
|
439
|
+
'Object',
|
|
440
|
+
'Array',
|
|
441
|
+
'String',
|
|
442
|
+
'Number',
|
|
443
|
+
'Boolean',
|
|
444
|
+
'Date',
|
|
445
|
+
'RegExp',
|
|
446
|
+
'Map',
|
|
447
|
+
'Set',
|
|
448
|
+
'WeakMap',
|
|
449
|
+
'WeakSet',
|
|
450
|
+
'Promise',
|
|
451
|
+
'Error',
|
|
452
|
+
'NaN',
|
|
453
|
+
'Infinity',
|
|
454
|
+
'undefined',
|
|
455
|
+
'null',
|
|
456
|
+
'true',
|
|
457
|
+
false,
|
|
458
|
+
'window',
|
|
459
|
+
'document',
|
|
460
|
+
'globalThis',
|
|
461
|
+
// Alpine.js built-ins
|
|
462
|
+
'$el',
|
|
463
|
+
'$event',
|
|
464
|
+
'$data',
|
|
465
|
+
'$dispatch',
|
|
466
|
+
'$refs',
|
|
467
|
+
'$watch',
|
|
468
|
+
'$root',
|
|
469
|
+
'$nextTick',
|
|
470
|
+
'$tick',
|
|
471
|
+
'$store',
|
|
472
|
+
'$persist',
|
|
473
|
+
'$restore',
|
|
474
|
+
'$abi',
|
|
475
|
+
// HTMX built-ins
|
|
476
|
+
'$target',
|
|
477
|
+
'$trigger',
|
|
478
|
+
'$triggerElement',
|
|
479
|
+
'$response',
|
|
480
|
+
])
|
|
481
|
+
|
|
482
|
+
for (const ref of references) {
|
|
483
|
+
// 1. Check if it's a global
|
|
484
|
+
if (ALLOWED_GLOBALS.has(ref.content)) continue
|
|
485
|
+
|
|
486
|
+
// 1.5. Skip undefined check for Alpine-defined variables
|
|
487
|
+
// Variables in x-data, @click, etc. are defined in Alpine's runtime scope
|
|
488
|
+
if (ref.isAlpine) continue
|
|
489
|
+
|
|
490
|
+
// 2. Check if it's defined in <script>
|
|
491
|
+
const def = definedVars.get(ref.content)
|
|
492
|
+
if (def) {
|
|
493
|
+
if (def.properties && ref.propertyPath && ref.propertyPath.length > 0) {
|
|
494
|
+
const firstProp = ref.propertyPath[0]
|
|
495
|
+
if (!def.properties.has(firstProp)) {
|
|
496
|
+
const range =
|
|
497
|
+
ref.propertyRanges && ref.propertyRanges.length > 0
|
|
498
|
+
? ref.propertyRanges[0]
|
|
499
|
+
: ref.range
|
|
500
|
+
const diagnostic = new vscode.Diagnostic(
|
|
501
|
+
range,
|
|
502
|
+
`Property '${firstProp}' does not exist on type '${ref.content}'`,
|
|
503
|
+
vscode.DiagnosticSeverity.Error,
|
|
504
|
+
)
|
|
505
|
+
diagnostic.source = DIAGNOSTIC_SOURCE
|
|
506
|
+
diagnostics.push(diagnostic)
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
continue
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// 3. Check if it's in a template scope (data-each)
|
|
513
|
+
// We need to find if the reference is within a scope that defines it
|
|
514
|
+
const scope = findInnermostScope(templateScopes, ref.offset)
|
|
515
|
+
if (scope && scope.itemName === ref.content) continue
|
|
516
|
+
|
|
517
|
+
// Also check parent scopes!
|
|
518
|
+
let parentScope = scope
|
|
519
|
+
let foundInScope = false
|
|
520
|
+
while (parentScope) {
|
|
521
|
+
if (parentScope.itemName === ref.content) {
|
|
522
|
+
foundInScope = true
|
|
523
|
+
break
|
|
524
|
+
}
|
|
525
|
+
// find parent... naive approach: re-search in scopes considering endOffset
|
|
526
|
+
// Optimization: TemplateScope could have parent ref, but list is flat
|
|
527
|
+
// For now, simpler: iterating all scopes is okay for typical file size
|
|
528
|
+
parentScope = findParentScope(templateScopes, parentScope)
|
|
529
|
+
}
|
|
530
|
+
if (foundInScope) continue
|
|
531
|
+
|
|
532
|
+
const message = ref.isComponent
|
|
533
|
+
? `Component '${ref.content}' is not defined`
|
|
534
|
+
: `Variable '${ref.content}' is not defined`
|
|
535
|
+
|
|
536
|
+
const diagnostic = new vscode.Diagnostic(
|
|
537
|
+
ref.range,
|
|
538
|
+
message,
|
|
539
|
+
vscode.DiagnosticSeverity.Error,
|
|
540
|
+
)
|
|
541
|
+
diagnostic.source = DIAGNOSTIC_SOURCE
|
|
542
|
+
diagnostics.push(diagnostic)
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// -----------------------------------------------------------------------
|
|
547
|
+
// 6. Unused variables in script
|
|
548
|
+
// -----------------------------------------------------------------------
|
|
549
|
+
|
|
550
|
+
private checkUnusedVariables(
|
|
551
|
+
document: vscode.TextDocument,
|
|
552
|
+
text: string,
|
|
553
|
+
diagnostics: vscode.Diagnostic[],
|
|
554
|
+
): void {
|
|
555
|
+
const references = collectTemplateReferences(document, text)
|
|
556
|
+
const usedInTemplate = new Set<string>()
|
|
557
|
+
for (const ref of references) {
|
|
558
|
+
usedInTemplate.add(ref.content)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Check unused in is:build scope (template + build scripts)
|
|
562
|
+
this.checkUnusedInScope(document, text, 'build', usedInTemplate, diagnostics)
|
|
563
|
+
|
|
564
|
+
// Check unused in bundled scope (plain/client scripts)
|
|
565
|
+
this.checkUnusedInScope(document, text, 'bundled', usedInTemplate, diagnostics)
|
|
566
|
+
|
|
567
|
+
// Check unused in is:inline scope (inline scripts only)
|
|
568
|
+
this.checkUnusedInScope(document, text, 'inline', usedInTemplate, diagnostics)
|
|
569
|
+
|
|
570
|
+
// Check unused in is:blocking scope (blocking scripts only)
|
|
571
|
+
this.checkUnusedInScope(document, text, 'blocking', usedInTemplate, diagnostics)
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
private checkUnusedInScope(
|
|
575
|
+
document: vscode.TextDocument,
|
|
576
|
+
text: string,
|
|
577
|
+
scope: 'build' | 'bundled' | 'inline' | 'blocking',
|
|
578
|
+
usedInTemplate: Set<string>,
|
|
579
|
+
diagnostics: vscode.Diagnostic[],
|
|
580
|
+
): void {
|
|
581
|
+
const definedVars = collectVariablesByScope(document, text, scope)
|
|
582
|
+
|
|
583
|
+
// Get script content for this scope only
|
|
584
|
+
const scopeContent = this.getScriptContentByScope(text, scope)
|
|
585
|
+
const maskedContent = maskJsComments(scopeContent).replace(
|
|
586
|
+
/(['"])(?:(?=(\\?))\2.)*?\1/g,
|
|
587
|
+
() => ' '.repeat(20),
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
for (const [name, def] of definedVars) {
|
|
591
|
+
// For build scope: check if used in template or in build scripts
|
|
592
|
+
if (scope === 'build') {
|
|
593
|
+
if (usedInTemplate.has(name)) continue
|
|
594
|
+
|
|
595
|
+
// Check usage in build scripts
|
|
596
|
+
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
597
|
+
const usageRegex = new RegExp(`\\b${escapedName}\\b`, 'g')
|
|
598
|
+
const matches = maskedContent.match(usageRegex)
|
|
599
|
+
if (matches && matches.length > 1) continue
|
|
600
|
+
}
|
|
601
|
+
// For bundled or blocking: check usage in client scripts (including pass:data references)
|
|
602
|
+
else if (scope === 'bundled' || scope === 'blocking') {
|
|
603
|
+
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
604
|
+
const usageRegex = new RegExp(`\\b${escapedName}\\b`, 'g')
|
|
605
|
+
const matches = maskedContent.match(usageRegex)
|
|
606
|
+
// For pass:data references, require at least one usage in the script
|
|
607
|
+
// For declarations/imports, require more than just the definition
|
|
608
|
+
if (def.kind === 'reference') {
|
|
609
|
+
if (matches && matches.length >= 1) continue
|
|
610
|
+
} else {
|
|
611
|
+
if (matches && matches.length > 1) continue
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
// For inline: check usage only within inline scripts
|
|
615
|
+
else {
|
|
616
|
+
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
617
|
+
const usageRegex = new RegExp(`\\b${escapedName}\\b`, 'g')
|
|
618
|
+
const matches = maskedContent.match(usageRegex)
|
|
619
|
+
if (matches && matches.length > 1) continue
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const diagnostic = new vscode.Diagnostic(
|
|
623
|
+
def.range,
|
|
624
|
+
`'${name}' is declared but its value is never read.`,
|
|
625
|
+
vscode.DiagnosticSeverity.Hint,
|
|
626
|
+
)
|
|
627
|
+
diagnostic.tags = [vscode.DiagnosticTag.Unnecessary]
|
|
628
|
+
diagnostic.source = DIAGNOSTIC_SOURCE
|
|
629
|
+
diagnostics.push(diagnostic)
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
private getScriptContentByScope(text: string, scope: 'build' | 'bundled' | 'inline' | 'blocking'): string {
|
|
634
|
+
const scopeAttr: Record<'build' | 'bundled' | 'inline' | 'blocking', RegExp> = {
|
|
635
|
+
build: /\bis:build\b/,
|
|
636
|
+
bundled: /(?!)/, // bundled = plain/client scripts; match via fallback below
|
|
637
|
+
inline: /\bis:inline\b/,
|
|
638
|
+
blocking: /\bis:blocking\b/,
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
let content = ''
|
|
642
|
+
const scriptRegex = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi
|
|
643
|
+
let scriptMatch: RegExpExecArray | null
|
|
644
|
+
|
|
645
|
+
while ((scriptMatch = scriptRegex.exec(text)) !== null) {
|
|
646
|
+
const rawAttrs = scriptMatch[1] || ''
|
|
647
|
+
const attrs = rawAttrs.toLowerCase()
|
|
648
|
+
if (/\bsrc\s*=/.test(attrs)) continue
|
|
649
|
+
|
|
650
|
+
// Check if script matches the requested scope
|
|
651
|
+
let isMatch = scopeAttr[scope].test(attrs)
|
|
652
|
+
|
|
653
|
+
// For bundled scope: plain <script> (no is:build, is:inline, is:blocking)
|
|
654
|
+
if (scope === 'bundled' && !isMatch) {
|
|
655
|
+
if (!/\bis:build\b/.test(attrs) && !/\bis:inline\b/.test(attrs) && !/\bis:blocking\b/.test(attrs)) {
|
|
656
|
+
isMatch = true
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (!isMatch) continue
|
|
661
|
+
|
|
662
|
+
content += ' ' + scriptMatch[2]
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return content
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
private checkDuplicateDeclarations(
|
|
669
|
+
document: vscode.TextDocument,
|
|
670
|
+
text: string,
|
|
671
|
+
diagnostics: vscode.Diagnostic[],
|
|
672
|
+
): void {
|
|
673
|
+
const [, duplicates] = collectDefinedVariables(document, text)
|
|
674
|
+
|
|
675
|
+
for (const dup of duplicates) {
|
|
676
|
+
const diagnostic = new vscode.Diagnostic(
|
|
677
|
+
dup.range,
|
|
678
|
+
`'${dup.name}' is declared multiple times (as '${dup.kind1}' and '${dup.kind2}').`,
|
|
679
|
+
vscode.DiagnosticSeverity.Error,
|
|
680
|
+
)
|
|
681
|
+
diagnostic.source = DIAGNOSTIC_SOURCE
|
|
682
|
+
diagnostics.push(diagnostic)
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function findParentScope(scopes: TemplateScope[], child: TemplateScope): TemplateScope | null {
|
|
688
|
+
let best: TemplateScope | null = null
|
|
689
|
+
for (const scope of scopes) {
|
|
690
|
+
if (scope === child) continue
|
|
691
|
+
if (child.startOffset >= scope.startOffset && child.endOffset <= scope.endOffset) {
|
|
692
|
+
if (!best) {
|
|
693
|
+
best = scope
|
|
694
|
+
continue
|
|
695
|
+
}
|
|
696
|
+
const bestSize = best.endOffset - best.startOffset
|
|
697
|
+
const thisSize = scope.endOffset - scope.startOffset
|
|
698
|
+
if (thisSize <= bestSize) {
|
|
699
|
+
best = scope
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return best
|
|
704
|
+
}
|
|
705
|
+
function getIgnoredRanges(text: string): Array<{ start: number; end: number }> {
|
|
706
|
+
const ranges: Array<{ start: number; end: number }> = []
|
|
707
|
+
HTML_COMMENT_REGEX.lastIndex = 0
|
|
708
|
+
let match: RegExpExecArray | null
|
|
709
|
+
while ((match = HTML_COMMENT_REGEX.exec(text)) !== null) {
|
|
710
|
+
ranges.push({ start: match.index, end: match.index + match[0].length })
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const scriptStyleRegex = /<(script|style)\b[^>]*>([\s\S]*?)<\/\1>/gi
|
|
714
|
+
let scriptMatch: RegExpExecArray | null
|
|
715
|
+
while ((scriptMatch = scriptStyleRegex.exec(text)) !== null) {
|
|
716
|
+
const tagName = scriptMatch[1]
|
|
717
|
+
const closeTagLen = `</${tagName}>`.length
|
|
718
|
+
const contentLen = scriptMatch[2].length
|
|
719
|
+
const start = scriptMatch.index + scriptMatch[0].length - closeTagLen - contentLen
|
|
720
|
+
const end = start + contentLen
|
|
721
|
+
ranges.push({ start, end })
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
return ranges
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function isInRanges(offset: number, ranges: Array<{ start: number; end: number }>): boolean {
|
|
728
|
+
for (const range of ranges) {
|
|
729
|
+
if (offset >= range.start && offset < range.end) return true
|
|
730
|
+
}
|
|
731
|
+
return false
|
|
732
|
+
}
|