beast-devtools 0.0.1

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.
Files changed (47) hide show
  1. package/.claude/launch.json +11 -0
  2. package/CHANGELOG.md +37 -0
  3. package/README.md +87 -0
  4. package/devtools/CHANGELOG.md +26 -0
  5. package/devtools/LICENSE +15 -0
  6. package/devtools/README.md +164 -0
  7. package/devtools/client/BeastDevtools.btsx +185 -0
  8. package/devtools/client/CodeView.btsx +61 -0
  9. package/devtools/client/ComponentsPanel.btsx +212 -0
  10. package/devtools/client/FileList.btsx +35 -0
  11. package/devtools/client/InspectorPanel.btsx +131 -0
  12. package/devtools/client/RefactorPanel.btsx +304 -0
  13. package/devtools/client/api.ts +69 -0
  14. package/devtools/client/compile.test.ts +22 -0
  15. package/devtools/client/devtools.css +1279 -0
  16. package/devtools/client/highlight.ts +210 -0
  17. package/devtools/client/mount.ts +16 -0
  18. package/devtools/client/runtime.ts +168 -0
  19. package/devtools/client/util.ts +136 -0
  20. package/devtools/package.json +73 -0
  21. package/devtools/server/analyze.test.ts +148 -0
  22. package/devtools/server/analyze.ts +737 -0
  23. package/devtools/server/diff.ts +82 -0
  24. package/devtools/server/line-map.ts +63 -0
  25. package/devtools/server/octane-bundler.d.ts +17 -0
  26. package/devtools/server/project.ts +369 -0
  27. package/devtools/server/refactor.test.ts +224 -0
  28. package/devtools/server/refactor.ts +224 -0
  29. package/devtools/server/source-scan.ts +377 -0
  30. package/devtools/shared/types.ts +208 -0
  31. package/devtools/test/fixtures/App.btsx +193 -0
  32. package/devtools/tsconfig.build.json +17 -0
  33. package/devtools/vite.ts +159 -0
  34. package/favicon.ico +0 -0
  35. package/index.html +14 -0
  36. package/package.json +34 -0
  37. package/public/beast.svg +1 -0
  38. package/src/App.btsx +144 -0
  39. package/src/AppHeader.btsx +24 -0
  40. package/src/LeftArticle.btsx +22 -0
  41. package/src/RightArticle.btsx +19 -0
  42. package/src/env.d.ts +8 -0
  43. package/src/lib/utils.ts +6 -0
  44. package/src/main.ts +8 -0
  45. package/src/style.css +44 -0
  46. package/tsconfig.json +39 -0
  47. package/vite.config.ts +19 -0
@@ -0,0 +1,82 @@
1
+ import type { DiffHunk, DiffLine } from '../shared/types.js'
2
+
3
+ export interface LineDiff {
4
+ added: number
5
+ removed: number
6
+ hunks: DiffHunk[]
7
+ }
8
+
9
+ /**
10
+ * Line diff for refactor previews. Edits are localized, so the common prefix
11
+ * and suffix are trimmed before an LCS pass over the changed middle.
12
+ */
13
+ export function diffLines(before: readonly string[], after: readonly string[], context = 2): LineDiff {
14
+ let prefix = 0
15
+ while (prefix < before.length && prefix < after.length && before[prefix] === after[prefix]) prefix++
16
+ let suffix = 0
17
+ while (
18
+ suffix < before.length - prefix &&
19
+ suffix < after.length - prefix &&
20
+ before[before.length - 1 - suffix] === after[after.length - 1 - suffix]
21
+ ) suffix++
22
+
23
+ const a = before.slice(prefix, before.length - suffix)
24
+ const b = after.slice(prefix, after.length - suffix)
25
+ const table = Array.from({ length: a.length + 1 }, () => new Uint32Array(b.length + 1))
26
+ for (let i = a.length - 1; i >= 0; i--) {
27
+ for (let j = b.length - 1; j >= 0; j--) {
28
+ table[i]![j] = a[i] === b[j] ? table[i + 1]![j + 1]! + 1 : Math.max(table[i + 1]![j]!, table[i]![j + 1]!)
29
+ }
30
+ }
31
+
32
+ // Every line of both files, tagged, with 1-based positions for hunk headers.
33
+ const ops: Array<DiffLine & { oldLine: number; newLine: number }> = []
34
+ for (let k = 0; k < prefix; k++) ops.push({ type: 'context', text: before[k]!, oldLine: k + 1, newLine: k + 1 })
35
+ let i = 0
36
+ let j = 0
37
+ while (i < a.length || j < b.length) {
38
+ if (i < a.length && j < b.length && a[i] === b[j]) {
39
+ ops.push({ type: 'context', text: a[i]!, oldLine: prefix + i + 1, newLine: prefix + j + 1 })
40
+ i++
41
+ j++
42
+ } else if (j < b.length && (i === a.length || table[i]![j + 1]! > table[i + 1]![j]!)) {
43
+ ops.push({ type: 'add', text: b[j]!, oldLine: prefix + i + 1, newLine: prefix + j + 1 })
44
+ j++
45
+ } else {
46
+ ops.push({ type: 'remove', text: a[i]!, oldLine: prefix + i + 1, newLine: prefix + j + 1 })
47
+ i++
48
+ }
49
+ }
50
+ for (let k = 0; k < suffix; k++) {
51
+ ops.push({
52
+ type: 'context',
53
+ text: before[before.length - suffix + k]!,
54
+ oldLine: before.length - suffix + k + 1,
55
+ newLine: after.length - suffix + k + 1,
56
+ })
57
+ }
58
+
59
+ // Group changes separated by at most 2 × context unchanged lines, then pad each group.
60
+ const changes = ops.flatMap((op, index) => (op.type === 'context' ? [] : [index]))
61
+ const groups: Array<[number, number]> = []
62
+ for (const index of changes) {
63
+ const last = groups.at(-1)
64
+ if (last !== undefined && index - last[1] <= context * 2 + 1) last[1] = index
65
+ else groups.push([index, index])
66
+ }
67
+ const hunks: DiffHunk[] = groups.map(([first, last]) => {
68
+ const from = Math.max(0, first - context)
69
+ const to = Math.min(ops.length - 1, last + context)
70
+ return {
71
+ oldStart: ops[from]!.oldLine,
72
+ newStart: ops[from]!.newLine,
73
+ lines: ops.slice(from, to + 1).map(({ type, text }) => ({ type, text })),
74
+ }
75
+ })
76
+
77
+ return {
78
+ added: ops.filter((op) => op.type === 'add').length,
79
+ removed: ops.filter((op) => op.type === 'remove').length,
80
+ hunks,
81
+ }
82
+ }
@@ -0,0 +1,63 @@
1
+ import type { BeastSourceMap } from 'beast-tsrx'
2
+
3
+ const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
4
+ const DIGITS = new Map([...BASE64].map((char, index) => [char, index]))
5
+
6
+ export interface LineMap {
7
+ tsrxToBtsx: Array<number | null>
8
+ btsxToTsrx: number[][]
9
+ }
10
+
11
+ /**
12
+ * Collapse Beast's node-level v3 source map into 1-based line correspondences
13
+ * in both directions. Beast emits a single source per map, so the source index
14
+ * is tracked only to keep the VLQ state machine correct.
15
+ */
16
+ export function buildLineMap(map: BeastSourceMap, generatedLineCount: number, sourceLineCount: number): LineMap {
17
+ const tsrxToBtsx: Array<number | null> = Array.from({ length: generatedLineCount }, () => null)
18
+ const btsxToTsrx: number[][] = Array.from({ length: sourceLineCount }, () => [])
19
+
20
+ let sourceIndex = 0
21
+ let sourceLine = 0
22
+ let sourceColumn = 0
23
+ const lines = map.mappings.split(';')
24
+
25
+ for (let generated = 0; generated < lines.length; generated++) {
26
+ const line = lines[generated]
27
+ if (line === undefined || line === '') continue
28
+ for (const segment of line.split(',')) {
29
+ const fields = decodeVlq(segment)
30
+ if (fields.length < 4) continue
31
+ sourceIndex += fields[1]!
32
+ sourceLine += fields[2]!
33
+ sourceColumn += fields[3]!
34
+ if (sourceIndex !== 0 || generated >= generatedLineCount) continue
35
+ const btsxLine = sourceLine + 1
36
+ tsrxToBtsx[generated] ??= btsxLine
37
+ const targets = btsxToTsrx[sourceLine]
38
+ if (targets !== undefined && targets.at(-1) !== generated + 1) targets.push(generated + 1)
39
+ }
40
+ }
41
+
42
+ void sourceColumn
43
+ return { tsrxToBtsx, btsxToTsrx }
44
+ }
45
+
46
+ function decodeVlq(segment: string): number[] {
47
+ const values: number[] = []
48
+ let value = 0
49
+ let shift = 0
50
+ for (const char of segment) {
51
+ const digit = DIGITS.get(char)
52
+ if (digit === undefined) return values
53
+ value += (digit & 31) << shift
54
+ if (digit & 32) {
55
+ shift += 5
56
+ continue
57
+ }
58
+ values.push(value & 1 ? -(value >>> 1) : value >>> 1)
59
+ value = 0
60
+ shift = 0
61
+ }
62
+ return values
63
+ }
@@ -0,0 +1,17 @@
1
+ // `octane/compiler/bundler` ships without typings; this covers the subset the
2
+ // devtools use to validate refactored sources.
3
+ declare module 'octane/compiler/bundler' {
4
+ export interface OctaneTransformOptions {
5
+ environment?: 'client' | 'server'
6
+ hmr?: false | 'vite'
7
+ dev?: boolean
8
+ profile?: boolean
9
+ strong?: boolean
10
+ }
11
+
12
+ export interface OctaneBundlerCompiler {
13
+ transform(code: string, id: string, options?: OctaneTransformOptions): { code: string; map: unknown } | null
14
+ }
15
+
16
+ export function createOctaneCompiler(options?: OctaneTransformOptions & { root?: string }): OctaneBundlerCompiler
17
+ }
@@ -0,0 +1,369 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { existsSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
3
+ import { join, relative, resolve, sep } from 'node:path'
4
+ import {
5
+ BeastCompileError,
6
+ compileBeastResult,
7
+ componentNameFromPath,
8
+ formatDiagnostic,
9
+ type BeastDiagnostic,
10
+ type BeastDocument,
11
+ type SetupDeclaration,
12
+ } from 'beast-tsrx'
13
+ import { createOctaneCompiler } from 'octane/compiler/bundler'
14
+ import type {
15
+ AnalyzerSettings,
16
+ ApplyRequest,
17
+ ApplyResult,
18
+ ComponentLocation,
19
+ DiagnosticInfo,
20
+ FileReport,
21
+ FileSummary,
22
+ HookBinding,
23
+ ProjectReport,
24
+ UndoResult,
25
+ } from '../shared/types.js'
26
+ import { analyzeDocument } from './analyze.js'
27
+ import { diffLines } from './diff.js'
28
+ import { buildLineMap } from './line-map.js'
29
+ import { planRefactor, RefactorError, type FileChange } from './refactor.js'
30
+ import { hookCall, topLevelDeclarations } from './source-scan.js'
31
+
32
+ const IGNORED_DIRECTORIES = new Set(['.git', '.beast', 'node_modules', 'dist', 'build', 'coverage'])
33
+
34
+ export interface ProjectOptions {
35
+ root: string
36
+ /** Directories, relative to root, searched recursively for `.btsx` files. */
37
+ include: readonly string[]
38
+ /** Absolute directories never listed (the overlay's own sources). */
39
+ exclude: readonly string[]
40
+ }
41
+
42
+ interface CompiledEntry {
43
+ mtimeMs: number
44
+ source: string
45
+ result: ReturnType<typeof compileBeastResult> | null
46
+ error: BeastDiagnostic | null
47
+ }
48
+
49
+ interface AppliedRefactor {
50
+ summary: string
51
+ changes: FileChange[]
52
+ }
53
+
54
+ /** How many applied refactors can still be undone. */
55
+ const UNDO_LIMIT = 20
56
+
57
+ /**
58
+ * Compiles project `.btsx` sources on demand for the overlay. Results are
59
+ * cached by modification time, so polling the API stays cheap.
60
+ */
61
+ export class BeastProject {
62
+ readonly #options: ProjectOptions
63
+ readonly #cache = new Map<string, CompiledEntry>()
64
+ readonly #applied = new Map<string, AppliedRefactor>()
65
+ #octane: ReturnType<typeof createOctaneCompiler> | null = null
66
+
67
+ constructor(options: ProjectOptions) {
68
+ this.#options = options
69
+ }
70
+
71
+ report(settings: AnalyzerSettings): ProjectReport {
72
+ const files: FileSummary[] = []
73
+ const components: ComponentLocation[] = []
74
+ for (const absolutePath of this.#discover()) {
75
+ const path = this.#relative(absolutePath)
76
+ const entry = this.#compile(absolutePath)
77
+ const lines = entry.source.split('\n').length
78
+ if (entry.result === null) {
79
+ files.push({ path, lines, maxDepth: null, deepLines: 0, suggestions: 0, error: entry.error?.message ?? 'Compile failed' })
80
+ continue
81
+ }
82
+ const analysis = analyzeDocument(entry.result.ast, entry.source, componentNameFromPath(absolutePath), settings)
83
+ files.push({
84
+ path,
85
+ lines,
86
+ maxDepth: analysis.maxDepth,
87
+ deepLines: analysis.deepLines,
88
+ suggestions: analysis.suggestions.length,
89
+ error: null,
90
+ })
91
+ components.push(...componentLocations(entry.result.ast, absolutePath, path))
92
+ }
93
+ return { root: this.#options.root, settings, files, components }
94
+ }
95
+
96
+ file(path: string, settings: AnalyzerSettings): FileReport | null {
97
+ const absolutePath = this.resolve(path)
98
+ if (absolutePath === null) return null
99
+ const entry = this.#compile(absolutePath)
100
+ const relativePath = this.#relative(absolutePath)
101
+
102
+ if (entry.result === null) {
103
+ return {
104
+ path: relativePath,
105
+ absolutePath,
106
+ hash: contentHash(entry.source),
107
+ source: entry.source,
108
+ compiled: { ok: false, error: diagnosticInfo(entry.error!, entry.source) },
109
+ analysis: null,
110
+ }
111
+ }
112
+
113
+ const { code, map, ast, diagnostics } = entry.result
114
+ const sourceLines = entry.source.split('\n')
115
+ const lineMap = buildLineMap(map, code.split('\n').length, sourceLines.length)
116
+ // `~` continuation lines belong to the node header above them.
117
+ sourceLines.forEach((line, index) => {
118
+ if (index > 0 && line.trimStart().startsWith('~') && lineMap.btsxToTsrx[index]!.length === 0) {
119
+ lineMap.btsxToTsrx[index] = lineMap.btsxToTsrx[index - 1]!
120
+ }
121
+ })
122
+ return {
123
+ path: relativePath,
124
+ absolutePath,
125
+ hash: contentHash(entry.source),
126
+ source: entry.source,
127
+ compiled: {
128
+ ok: true,
129
+ tsrx: code,
130
+ ...lineMap,
131
+ diagnostics: diagnostics.map((diagnostic) => diagnosticInfo(diagnostic, entry.source)),
132
+ },
133
+ analysis: analyzeDocument(ast, entry.source, componentNameFromPath(absolutePath), settings),
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Plan a refactor suggestion and, unless `dryRun`, write it. The suggestion
139
+ * is recomputed from the file on disk: the client names it but never supplies
140
+ * code, and every resulting file must compile through Beast and Octane
141
+ * before anything is written.
142
+ */
143
+ apply(request: ApplyRequest): ApplyResult {
144
+ const absolutePath = this.resolve(request.path)
145
+ if (absolutePath === null) throw new RefactorError('Unknown .btsx file.', 422)
146
+ this.invalidate(absolutePath)
147
+ const entry = this.#compile(absolutePath)
148
+ if (contentHash(entry.source) !== request.hash) {
149
+ throw new RefactorError('The file changed since it was analyzed. Review the refreshed suggestions and try again.', 409)
150
+ }
151
+ if (entry.result === null) throw new RefactorError('The file does not compile.', 422)
152
+
153
+ const analysis = analyzeDocument(entry.result.ast, entry.source, componentNameFromPath(absolutePath), request.settings)
154
+ const suggestion = analysis.suggestions.find((candidate) => candidate.id === request.suggestionId)
155
+ if (suggestion === undefined) throw new RefactorError('That suggestion no longer applies.', 409)
156
+
157
+ const plan = planRefactor({
158
+ absolutePath,
159
+ source: entry.source,
160
+ document: entry.result.ast,
161
+ suggestion,
162
+ target: request.target,
163
+ exists: existsSync,
164
+ })
165
+ for (const change of plan.changes) this.#validate(change)
166
+
167
+ const files = plan.changes.map((change) => ({
168
+ path: this.#relative(change.absolutePath),
169
+ action: change.before === null ? ('create' as const) : ('edit' as const),
170
+ ...diffLines(change.before === null ? [] : change.before.split('\n'), change.after.split('\n')),
171
+ }))
172
+ if (request.dryRun) return { undoId: null, component: plan.component, summary: plan.summary, files }
173
+
174
+ this.#write(plan.changes)
175
+ const undoId = randomUUID()
176
+ this.#applied.set(undoId, { summary: plan.summary, changes: plan.changes })
177
+ if (this.#applied.size > UNDO_LIMIT) this.#applied.delete(this.#applied.keys().next().value!)
178
+ return { undoId, component: plan.component, summary: plan.summary, files }
179
+ }
180
+
181
+ /** Restore the files an applied refactor touched, if nobody has edited them since. */
182
+ undo(id: string): UndoResult {
183
+ const applied = this.#applied.get(id)
184
+ if (applied === undefined) throw new RefactorError('Nothing to undo for that refactor.', 409)
185
+ for (const change of applied.changes) {
186
+ const current = existsSync(change.absolutePath) ? readFileSync(change.absolutePath, 'utf8') : null
187
+ if (current !== change.after) {
188
+ throw new RefactorError(`${this.#relative(change.absolutePath)} was edited after the refactor, so it was not undone.`, 409)
189
+ }
190
+ }
191
+ // Restore edited files first so nothing imports a file about to be removed.
192
+ const ordered = [...applied.changes].sort((a, b) => Number(a.before === null) - Number(b.before === null))
193
+ for (const change of ordered) {
194
+ if (change.before === null) unlinkSync(change.absolutePath)
195
+ else writeFileSync(change.absolutePath, change.before)
196
+ this.invalidate(change.absolutePath)
197
+ }
198
+ this.#applied.delete(id)
199
+ return { summary: `Undid: ${applied.summary}` }
200
+ }
201
+
202
+ #validate(change: FileChange): void {
203
+ const filename = change.absolutePath
204
+ const { code } = (() => {
205
+ try {
206
+ return compileBeastResult(change.after, { filename, componentName: componentNameFromPath(filename) })
207
+ } catch (error) {
208
+ const detail = error instanceof BeastCompileError ? formatDiagnostic(error.diagnostic, change.after) : String(error)
209
+ throw new RefactorError(`The refactored ${this.#relative(filename)} would not compile:\n${detail}`, 422)
210
+ }
211
+ })()
212
+ this.#octane ??= createOctaneCompiler({ root: this.#options.root, environment: 'client', hmr: false, dev: true })
213
+ try {
214
+ this.#octane.transform(code, filename.replace(/\.btsx$/, '.tsrx'), { environment: 'client', dev: true })
215
+ } catch (error) {
216
+ const message = error instanceof Error ? error.message : String(error)
217
+ throw new RefactorError(`Octane rejected the refactored ${this.#relative(filename)}: ${message}`, 422)
218
+ }
219
+ }
220
+
221
+ #write(changes: readonly FileChange[]): void {
222
+ for (const change of changes) {
223
+ const current = existsSync(change.absolutePath) ? readFileSync(change.absolutePath, 'utf8') : null
224
+ if (current !== change.before) {
225
+ throw new RefactorError(`${this.#relative(change.absolutePath)} changed while the refactor was prepared.`, 409)
226
+ }
227
+ }
228
+ // New files first, so the edited importer never points at a missing module.
229
+ const ordered = [...changes].sort((a, b) => Number(b.before === null) - Number(a.before === null))
230
+ const written: FileChange[] = []
231
+ try {
232
+ for (const change of ordered) {
233
+ writeFileSync(change.absolutePath, change.after, change.before === null ? { flag: 'wx' } : {})
234
+ written.push(change)
235
+ this.invalidate(change.absolutePath)
236
+ }
237
+ } catch (error) {
238
+ for (const change of written.reverse()) {
239
+ if (change.before === null) unlinkSync(change.absolutePath)
240
+ else writeFileSync(change.absolutePath, change.before)
241
+ }
242
+ throw error
243
+ }
244
+ }
245
+
246
+ /** Resolve a client-supplied path to a project `.btsx` file, refusing anything else. */
247
+ resolve(path: string): string | null {
248
+ const absolutePath = resolve(this.#options.root, path)
249
+ const inRoot = absolutePath.startsWith(this.#options.root + sep)
250
+ const listed = this.#options.include.some((dir) => absolutePath.startsWith(resolve(this.#options.root, dir) + sep))
251
+ if (!inRoot || !listed || !absolutePath.endsWith('.btsx') || absolutePath.split(sep).includes('node_modules')) return null
252
+ try {
253
+ return statSync(absolutePath).isFile() ? absolutePath : null
254
+ } catch {
255
+ return null
256
+ }
257
+ }
258
+
259
+ invalidate(absolutePath: string): void {
260
+ this.#cache.delete(absolutePath)
261
+ }
262
+
263
+ #discover(): string[] {
264
+ const found: string[] = []
265
+ const walk = (dir: string) => {
266
+ let entries
267
+ try {
268
+ entries = readdirSync(dir, { withFileTypes: true })
269
+ } catch {
270
+ return
271
+ }
272
+ for (const entry of entries) {
273
+ const full = join(dir, entry.name)
274
+ if (entry.isDirectory()) {
275
+ if (!IGNORED_DIRECTORIES.has(entry.name) && !this.#options.exclude.includes(full)) walk(full)
276
+ } else if (entry.name.endsWith('.btsx')) {
277
+ found.push(full)
278
+ }
279
+ }
280
+ }
281
+ for (const dir of this.#options.include) walk(resolve(this.#options.root, dir))
282
+ return found.sort()
283
+ }
284
+
285
+ #compile(absolutePath: string): CompiledEntry {
286
+ const mtimeMs = statSync(absolutePath).mtimeMs
287
+ const cached = this.#cache.get(absolutePath)
288
+ if (cached !== undefined && cached.mtimeMs === mtimeMs) return cached
289
+
290
+ const source = readFileSync(absolutePath, 'utf8')
291
+ let entry: CompiledEntry
292
+ try {
293
+ const result = compileBeastResult(source, {
294
+ filename: this.#relative(absolutePath),
295
+ componentName: componentNameFromPath(absolutePath),
296
+ })
297
+ entry = { mtimeMs, source, result, error: null }
298
+ } catch (error) {
299
+ if (!(error instanceof BeastCompileError)) throw error
300
+ entry = { mtimeMs, source, result: null, error: error.diagnostic }
301
+ }
302
+ this.#cache.set(absolutePath, entry)
303
+ return entry
304
+ }
305
+
306
+ #relative(absolutePath: string): string {
307
+ return relative(this.#options.root, absolutePath).split(sep).join('/')
308
+ }
309
+ }
310
+
311
+ function contentHash(source: string): string {
312
+ return createHash('sha1').update(source).digest('hex').slice(0, 16)
313
+ }
314
+
315
+ function diagnosticInfo(diagnostic: BeastDiagnostic, source: string): DiagnosticInfo {
316
+ return {
317
+ code: diagnostic.code,
318
+ severity: diagnostic.severity,
319
+ message: diagnostic.message,
320
+ line: diagnostic.span.start.line,
321
+ column: diagnostic.span.start.column,
322
+ endLine: diagnostic.span.end.line,
323
+ endColumn: diagnostic.span.end.column,
324
+ ...(diagnostic.hint === undefined ? {} : { hint: diagnostic.hint }),
325
+ formatted: formatDiagnostic(diagnostic, source),
326
+ }
327
+ }
328
+
329
+ /** Where each component of a file is declared, plus its value hooks in call order. */
330
+ function componentLocations(document: BeastDocument, absolutePath: string, path: string): ComponentLocation[] {
331
+ const locations: ComponentLocation[] = []
332
+ for (const declaration of document.declarations) {
333
+ if (declaration.kind !== 'component') continue
334
+ locations.push({
335
+ name: declaration.name,
336
+ path,
337
+ absolutePath,
338
+ line: declaration.span.start.line,
339
+ column: declaration.span.start.column,
340
+ local: true,
341
+ hooks: hookBindings(declaration.setup),
342
+ })
343
+ }
344
+ const root = document.children[0]
345
+ const topSetup = document.declarations.filter((d): d is SetupDeclaration => d.kind === 'setup')
346
+ locations.push({
347
+ name: componentNameFromPath(absolutePath),
348
+ path,
349
+ absolutePath,
350
+ line: root?.span.start.line ?? 1,
351
+ column: root?.span.start.column ?? 1,
352
+ local: false,
353
+ hooks: hookBindings(topSetup),
354
+ })
355
+ return locations
356
+ }
357
+
358
+ function hookBindings(setup: readonly SetupDeclaration[]): HookBinding[] {
359
+ const bindings: HookBinding[] = []
360
+ for (const declaration of setup) {
361
+ for (const { names, init, offset } of topLevelDeclarations(declaration.code)) {
362
+ const call = hookCall(init)
363
+ if (call === null) continue
364
+ const line = declaration.codeStart.line + declaration.code.slice(0, offset).split('\n').length - 1
365
+ bindings.push({ hook: call.hook, names, line })
366
+ }
367
+ }
368
+ return bindings
369
+ }