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.
@@ -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
+ }