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,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Completion provider for Aero HTML: attributes, import paths, component/layout tags, content globals.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* Triggers on `<`, `/`, `@`, `"`, `'`. Supplies Aero attributes (data-each, data-if, etc.), path/alias completions from the resolver, and content global properties. Uses classifyPosition and getResolver.
|
|
6
|
+
*/
|
|
7
|
+
import * as vscode from 'vscode'
|
|
8
|
+
import * as path from 'node:path'
|
|
9
|
+
import * as fs from 'node:fs'
|
|
10
|
+
import { getResolver } from './pathResolver'
|
|
11
|
+
import { CONTENT_GLOBALS } from './constants'
|
|
12
|
+
import { isAeroDocument } from './scope'
|
|
13
|
+
|
|
14
|
+
const AERO_ATTRIBUTES: Array<{
|
|
15
|
+
label: string
|
|
16
|
+
detail: string
|
|
17
|
+
snippet?: string
|
|
18
|
+
kind: vscode.CompletionItemKind
|
|
19
|
+
}> = [
|
|
20
|
+
{
|
|
21
|
+
label: 'is:build',
|
|
22
|
+
detail: 'Build-time script block (Aero)',
|
|
23
|
+
kind: vscode.CompletionItemKind.Property,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
label: 'is:inline',
|
|
27
|
+
detail: 'Inline client script, no bundling (Aero)',
|
|
28
|
+
kind: vscode.CompletionItemKind.Property,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
label: 'data-each',
|
|
32
|
+
detail: 'Loop over items (Aero)',
|
|
33
|
+
snippet: 'data-each="{ ${1:item} in ${2:items} }"',
|
|
34
|
+
kind: vscode.CompletionItemKind.Keyword,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
label: 'data-if',
|
|
38
|
+
detail: 'Conditional rendering (Aero)',
|
|
39
|
+
snippet: 'data-if="{ ${1:condition} }"',
|
|
40
|
+
kind: vscode.CompletionItemKind.Keyword,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
label: 'data-else-if',
|
|
44
|
+
detail: 'Chained conditional (Aero)',
|
|
45
|
+
snippet: 'data-else-if="{ ${1:condition} }"',
|
|
46
|
+
kind: vscode.CompletionItemKind.Keyword,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
label: 'data-else',
|
|
50
|
+
detail: 'Fallback conditional (Aero)',
|
|
51
|
+
kind: vscode.CompletionItemKind.Keyword,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
label: 'data-props',
|
|
55
|
+
detail: 'Spread props to component (Aero)',
|
|
56
|
+
kind: vscode.CompletionItemKind.Property,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
label: 'each',
|
|
60
|
+
detail: 'Loop over items (Aero shorthand)',
|
|
61
|
+
snippet: 'each="{ ${1:item} in ${2:items} }"',
|
|
62
|
+
kind: vscode.CompletionItemKind.Keyword,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
label: 'if',
|
|
66
|
+
detail: 'Conditional rendering (Aero shorthand)',
|
|
67
|
+
snippet: 'if="{ ${1:condition} }"',
|
|
68
|
+
kind: vscode.CompletionItemKind.Keyword,
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
label: 'else-if',
|
|
72
|
+
detail: 'Chained conditional (Aero shorthand)',
|
|
73
|
+
snippet: 'else-if="{ ${1:condition} }"',
|
|
74
|
+
kind: vscode.CompletionItemKind.Keyword,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
label: 'else',
|
|
78
|
+
detail: 'Fallback conditional (Aero shorthand)',
|
|
79
|
+
kind: vscode.CompletionItemKind.Keyword,
|
|
80
|
+
},
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Completion Provider
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
export class AeroCompletionProvider implements vscode.CompletionItemProvider {
|
|
88
|
+
provideCompletionItems(
|
|
89
|
+
document: vscode.TextDocument,
|
|
90
|
+
position: vscode.Position,
|
|
91
|
+
_token: vscode.CancellationToken,
|
|
92
|
+
context: vscode.CompletionContext,
|
|
93
|
+
): vscode.ProviderResult<vscode.CompletionItem[]> {
|
|
94
|
+
if (!isAeroDocument(document)) return null
|
|
95
|
+
|
|
96
|
+
const lineText = document.lineAt(position.line).text
|
|
97
|
+
const textBefore = lineText.slice(0, position.character)
|
|
98
|
+
|
|
99
|
+
// 1. Component/layout tag completions after `<`
|
|
100
|
+
const tagMatch = textBefore.match(/<([a-z][a-z0-9-]*)$/)
|
|
101
|
+
if (tagMatch) {
|
|
102
|
+
return this.getComponentTagCompletions(document, tagMatch[1])
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 2. Aero attribute completions inside a tag
|
|
106
|
+
if (this.isInsideTag(lineText, position.character)) {
|
|
107
|
+
return this.getAttributeCompletions(textBefore)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 3. Import path / alias completions
|
|
111
|
+
const importMatch = textBefore.match(/from\s+['"]([^'"]*?)$/)
|
|
112
|
+
if (importMatch) {
|
|
113
|
+
return this.getImportPathCompletions(document, importMatch[1])
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 4. Content global completions inside { }
|
|
117
|
+
if (this.isInsideExpression(textBefore)) {
|
|
118
|
+
return this.getExpressionCompletions()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// -----------------------------------------------------------------------
|
|
125
|
+
// Component tag completions
|
|
126
|
+
// -----------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
private getComponentTagCompletions(
|
|
129
|
+
document: vscode.TextDocument,
|
|
130
|
+
prefix: string,
|
|
131
|
+
): vscode.CompletionItem[] {
|
|
132
|
+
const resolver = getResolver(document)
|
|
133
|
+
if (!resolver) return []
|
|
134
|
+
|
|
135
|
+
const items: vscode.CompletionItem[] = []
|
|
136
|
+
|
|
137
|
+
// Scan client/components/ for component files
|
|
138
|
+
const componentsDir = path.join(resolver.root, 'client', 'components')
|
|
139
|
+
items.push(...this.scanDirForTags(componentsDir, 'component', prefix))
|
|
140
|
+
|
|
141
|
+
// Scan client/layouts/ for layout files
|
|
142
|
+
const layoutsDir = path.join(resolver.root, 'client', 'layouts')
|
|
143
|
+
items.push(...this.scanDirForTags(layoutsDir, 'layout', prefix))
|
|
144
|
+
|
|
145
|
+
return items
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private scanDirForTags(
|
|
149
|
+
dir: string,
|
|
150
|
+
suffix: 'component' | 'layout',
|
|
151
|
+
prefix: string,
|
|
152
|
+
): vscode.CompletionItem[] {
|
|
153
|
+
const items: vscode.CompletionItem[] = []
|
|
154
|
+
|
|
155
|
+
if (!fs.existsSync(dir)) return items
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const files = fs.readdirSync(dir)
|
|
159
|
+
for (const file of files) {
|
|
160
|
+
if (!file.endsWith('.html')) continue
|
|
161
|
+
const baseName = file.replace(/\.html$/, '')
|
|
162
|
+
// Convert camelCase to kebab-case for tag name
|
|
163
|
+
const kebab = baseName.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
|
|
164
|
+
const tagName = `${kebab}-${suffix}`
|
|
165
|
+
|
|
166
|
+
if (!tagName.startsWith(prefix)) continue
|
|
167
|
+
|
|
168
|
+
const item = new vscode.CompletionItem(
|
|
169
|
+
tagName,
|
|
170
|
+
suffix === 'component'
|
|
171
|
+
? vscode.CompletionItemKind.Class
|
|
172
|
+
: vscode.CompletionItemKind.Struct,
|
|
173
|
+
)
|
|
174
|
+
item.detail = `${suffix === 'component' ? 'Component' : 'Layout'}: ${file}`
|
|
175
|
+
item.insertText = new vscode.SnippetString(`${tagName} $1/>\n`)
|
|
176
|
+
items.push(item)
|
|
177
|
+
}
|
|
178
|
+
} catch {
|
|
179
|
+
// Directory read error
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return items
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// -----------------------------------------------------------------------
|
|
186
|
+
// Attribute completions
|
|
187
|
+
// -----------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
private getAttributeCompletions(textBefore: string): vscode.CompletionItem[] {
|
|
190
|
+
// Only suggest if we're at an attribute position (after whitespace following tag name or other attr)
|
|
191
|
+
if (!/\s$/.test(textBefore) && !/=["'][^"']*$/.test(textBefore)) {
|
|
192
|
+
// Check if we're mid-word at an attribute position
|
|
193
|
+
const attrPrefix = textBefore.match(/\s([a-z-:]*)$/)?.[1]
|
|
194
|
+
if (attrPrefix === undefined) return []
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return AERO_ATTRIBUTES.map(attr => {
|
|
198
|
+
const item = new vscode.CompletionItem(attr.label, attr.kind)
|
|
199
|
+
item.detail = attr.detail
|
|
200
|
+
if (attr.snippet) {
|
|
201
|
+
item.insertText = new vscode.SnippetString(attr.snippet)
|
|
202
|
+
}
|
|
203
|
+
return item
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// -----------------------------------------------------------------------
|
|
208
|
+
// Import path completions
|
|
209
|
+
// -----------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
private getImportPathCompletions(
|
|
212
|
+
document: vscode.TextDocument,
|
|
213
|
+
partial: string,
|
|
214
|
+
): vscode.CompletionItem[] {
|
|
215
|
+
const resolver = getResolver(document)
|
|
216
|
+
if (!resolver) return []
|
|
217
|
+
|
|
218
|
+
const items: vscode.CompletionItem[] = []
|
|
219
|
+
|
|
220
|
+
// Suggest alias prefixes if nothing typed yet or starts with @
|
|
221
|
+
if (!partial || partial === '@') {
|
|
222
|
+
const aliases = [
|
|
223
|
+
'@components/',
|
|
224
|
+
'@layouts/',
|
|
225
|
+
'@content/',
|
|
226
|
+
'@pages/',
|
|
227
|
+
'@styles/',
|
|
228
|
+
'@scripts/',
|
|
229
|
+
'@images/',
|
|
230
|
+
'@client/',
|
|
231
|
+
'@server/',
|
|
232
|
+
'~/',
|
|
233
|
+
]
|
|
234
|
+
for (const alias of aliases) {
|
|
235
|
+
if (alias.startsWith(partial)) {
|
|
236
|
+
const item = new vscode.CompletionItem(alias, vscode.CompletionItemKind.Folder)
|
|
237
|
+
item.detail = 'Aero path alias'
|
|
238
|
+
items.push(item)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return items
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// If partial starts with an alias, list files in that directory
|
|
245
|
+
const resolved = resolver.resolve(partial)
|
|
246
|
+
if (resolved) {
|
|
247
|
+
const dir =
|
|
248
|
+
fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()
|
|
249
|
+
? resolved
|
|
250
|
+
: path.dirname(resolved)
|
|
251
|
+
|
|
252
|
+
if (fs.existsSync(dir)) {
|
|
253
|
+
try {
|
|
254
|
+
const files = fs.readdirSync(dir)
|
|
255
|
+
for (const file of files) {
|
|
256
|
+
const filePath = path.join(dir, file)
|
|
257
|
+
const stat = fs.statSync(filePath)
|
|
258
|
+
if (stat.isDirectory()) {
|
|
259
|
+
const item = new vscode.CompletionItem(
|
|
260
|
+
file + '/',
|
|
261
|
+
vscode.CompletionItemKind.Folder,
|
|
262
|
+
)
|
|
263
|
+
items.push(item)
|
|
264
|
+
} else {
|
|
265
|
+
const baseName = file.replace(/\.(html|ts|js|json)$/, '')
|
|
266
|
+
const item = new vscode.CompletionItem(baseName, vscode.CompletionItemKind.File)
|
|
267
|
+
item.detail = file
|
|
268
|
+
items.push(item)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
} catch {
|
|
272
|
+
// Directory read error
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return items
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// -----------------------------------------------------------------------
|
|
281
|
+
// Expression completions
|
|
282
|
+
// -----------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
private getExpressionCompletions(): vscode.CompletionItem[] {
|
|
285
|
+
const items: vscode.CompletionItem[] = []
|
|
286
|
+
|
|
287
|
+
// Content globals
|
|
288
|
+
for (const [name, alias] of Object.entries(CONTENT_GLOBALS)) {
|
|
289
|
+
const item = new vscode.CompletionItem(name, vscode.CompletionItemKind.Variable)
|
|
290
|
+
item.detail = `Content global (${alias})`
|
|
291
|
+
items.push(item)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Aero.props
|
|
295
|
+
const propsItem = new vscode.CompletionItem('Aero', vscode.CompletionItemKind.Module)
|
|
296
|
+
propsItem.detail = 'Aero runtime context'
|
|
297
|
+
items.push(propsItem)
|
|
298
|
+
|
|
299
|
+
return items
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// -----------------------------------------------------------------------
|
|
303
|
+
// Context detection helpers
|
|
304
|
+
// -----------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
private isInsideTag(lineText: string, offset: number): boolean {
|
|
307
|
+
// Simple heuristic: find the last `<` before offset that isn't closed by `>`
|
|
308
|
+
let lastOpen = -1
|
|
309
|
+
let lastClose = -1
|
|
310
|
+
for (let i = 0; i < offset; i++) {
|
|
311
|
+
if (lineText[i] === '<' && lineText[i + 1] !== '/') lastOpen = i
|
|
312
|
+
if (lineText[i] === '>') lastClose = i
|
|
313
|
+
}
|
|
314
|
+
return lastOpen > lastClose
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private isInsideExpression(textBefore: string): boolean {
|
|
318
|
+
let depth = 0
|
|
319
|
+
for (let i = textBefore.length - 1; i >= 0; i--) {
|
|
320
|
+
if (textBefore[i] === '}') depth++
|
|
321
|
+
if (textBefore[i] === '{') {
|
|
322
|
+
if (depth === 0) return true
|
|
323
|
+
depth--
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return false
|
|
327
|
+
}
|
|
328
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constants for the Aero VS Code extension: regexes, attribute names, selectors, path resolution.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* Regex patterns mirror `packages/core/compiler/constants.ts` for consistent parsing. Used by analyzer, positionAt, and providers.
|
|
6
|
+
*/
|
|
7
|
+
import * as vscode from 'vscode'
|
|
8
|
+
|
|
9
|
+
/** Matches component/layout suffix on tag names: `-component` or `-layout`. */
|
|
10
|
+
export const COMPONENT_SUFFIX_REGEX = /-(component|layout)$/
|
|
11
|
+
|
|
12
|
+
/** Matches import statements: `import X from 'path'` */
|
|
13
|
+
export const IMPORT_REGEX =
|
|
14
|
+
/((?:^|[\r\n;])\s*)import\s+(?:(\w+)|\{([^}]+)\}|\*\s+as\s+(\w+))\s+from\s+(['"])(.+?)\5/g
|
|
15
|
+
|
|
16
|
+
/** Matches `{ ... }` expressions in template text. */
|
|
17
|
+
export const CURLY_INTERPOLATION_REGEX = /{([\s\S]+?)}/g
|
|
18
|
+
|
|
19
|
+
/** Alpine.js attributes that should not be treated as Aero expressions (`x-*`, `@*`, `:*`, `.*`). */
|
|
20
|
+
export const ALPINE_ATTR_REGEX = /^(x-|[@:.]).*/
|
|
21
|
+
|
|
22
|
+
export const ATTR_IS_BUILD = 'is:build'
|
|
23
|
+
export const ATTR_IS_INLINE = 'is:inline'
|
|
24
|
+
|
|
25
|
+
/** Content globals: identifier → alias path. Files in `client/content/` are exposed as globals in Aero templates. */
|
|
26
|
+
export const CONTENT_GLOBALS: Record<string, string> = {
|
|
27
|
+
site: '@content/site',
|
|
28
|
+
theme: '@content/theme',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Document selector for Aero-relevant HTML files (language: html, scheme: file). */
|
|
32
|
+
export const HTML_SELECTOR: vscode.DocumentSelector = { language: 'html', scheme: 'file' }
|
|
33
|
+
|
|
34
|
+
/** Extensions to try when resolving imports without an extension. */
|
|
35
|
+
export const RESOLVE_EXTENSIONS = ['.html', '.ts', '.js', '.json']
|