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,737 @@
1
+ import type {
2
+ BeastDocument,
3
+ BeastNode,
4
+ ElementNode,
5
+ PropsDeclaration,
6
+ SetupDeclaration,
7
+ } from 'beast-tsrx'
8
+ import type {
9
+ AnalyzerSettings,
10
+ AutoApply,
11
+ ComponentMetrics,
12
+ FileAnalysis,
13
+ LineRange,
14
+ RefactorSuggestion,
15
+ Severity,
16
+ SuggestedProp,
17
+ } from '../shared/types.js'
18
+ import { hookCall, identifiersIn, parsePropsParameter, topLevelDeclarations } from './source-scan.js'
19
+
20
+ /** Component-scope bindings visible at a template position, mapped to a best-effort type. */
21
+ type Bindings = ReadonlyMap<string, string>
22
+
23
+ interface NodeInfo {
24
+ node: BeastNode
25
+ host: Host
26
+ depth: number
27
+ start: number
28
+ end: number
29
+ height: number
30
+ nodeCount: number
31
+ uses: Set<string>
32
+ shape: string
33
+ /** Attribute, text, and expression values in document order, for variant diffing. */
34
+ values: string[]
35
+ available: Bindings
36
+ parent: NodeInfo | null
37
+ children: NodeInfo[]
38
+ }
39
+
40
+ interface Host {
41
+ name: string
42
+ /** Line of the `component` keyword, or null for the file's default component. */
43
+ declarationLine: number | null
44
+ roots: NodeInfo[]
45
+ insertBeforeLine: number
46
+ /** Scoped `style` blocks match only their owning component's elements. */
47
+ hasStyle: boolean
48
+ }
49
+
50
+ interface Context {
51
+ settings: AnalyzerSettings
52
+ lines: string[]
53
+ lineDepths: Array<number | null>
54
+ moduleNames: Set<string>
55
+ takenNames: Set<string>
56
+ localComponents: Set<string>
57
+ }
58
+
59
+ export function analyzeDocument(
60
+ document: BeastDocument,
61
+ source: string,
62
+ componentName: string,
63
+ settings: AnalyzerSettings,
64
+ ): FileAnalysis {
65
+ const lines = source.split('\n')
66
+ const context: Context = {
67
+ settings,
68
+ lines,
69
+ lineDepths: Array.from({ length: lines.length }, () => null),
70
+ moduleNames: new Set(),
71
+ takenNames: new Set([componentName]),
72
+ localComponents: new Set([componentName]),
73
+ }
74
+
75
+ for (const declaration of document.declarations) {
76
+ if (declaration.kind === 'module') {
77
+ for (const { names } of topLevelDeclarations(declaration.code)) names.forEach((name) => context.moduleNames.add(name))
78
+ } else if (declaration.kind === 'import') {
79
+ for (const name of identifiersIn(declaration.code.replace(/\bfrom\s+(['"]).*?\1/, ''))) {
80
+ if (/^[A-Z]/.test(name)) context.takenNames.add(name)
81
+ }
82
+ } else if (declaration.kind === 'component') {
83
+ context.takenNames.add(declaration.name)
84
+ context.localComponents.add(declaration.name)
85
+ }
86
+ }
87
+
88
+ const hosts: Host[] = []
89
+ const topProps = document.declarations.find((d): d is PropsDeclaration => d.kind === 'props') ?? null
90
+ const topSetup = document.declarations.filter((d): d is SetupDeclaration => d.kind === 'setup')
91
+ const firstOwnDeclaration = [topProps, ...topSetup]
92
+ .filter((d) => d !== null)
93
+ .reduce((min, d) => Math.min(min, d.span.start.line), Number.POSITIVE_INFINITY)
94
+
95
+ for (const declaration of document.declarations) {
96
+ if (declaration.kind !== 'component') continue
97
+ const host: Host = {
98
+ name: declaration.name,
99
+ declarationLine: declaration.span.start.line,
100
+ roots: [],
101
+ insertBeforeLine: declaration.span.start.line,
102
+ hasStyle: false,
103
+ }
104
+ host.roots = buildAll(context, host, declaration.children, 0, null, componentBindings(declaration.props, declaration.setup))
105
+ hosts.push(host)
106
+ }
107
+
108
+ const defaultHost: Host = {
109
+ name: componentName,
110
+ declarationLine: null,
111
+ roots: [],
112
+ insertBeforeLine: Number.isFinite(firstOwnDeclaration)
113
+ ? firstOwnDeclaration
114
+ : (document.children[0]?.span.start.line ?? lines.length + 1),
115
+ hasStyle: false,
116
+ }
117
+ defaultHost.roots = buildAll(context, defaultHost, document.children, 0, null, componentBindings(topProps, topSetup))
118
+ hosts.push(defaultHost)
119
+
120
+ const suggestions = [
121
+ ...hosts.flatMap((host) => suggestExtractions(context, host)),
122
+ ...suggestDuplicates(context, hosts),
123
+ ].map((suggestion, index) => ({ ...suggestion, id: `s${index + 1}` }))
124
+
125
+ const components: ComponentMetrics[] = hosts.map((host) => {
126
+ const all = host.roots.flatMap(flatten)
127
+ const templateLineSet = new Set<number>()
128
+ for (const info of all) for (let line = info.start; line <= info.end; line++) {
129
+ if (context.lineDepths[line - 1] != null) templateLineSet.add(line)
130
+ }
131
+ return {
132
+ name: host.name,
133
+ line: host.declarationLine ?? host.roots[0]?.start ?? 1,
134
+ templateLines: templateLineSet.size,
135
+ maxDepth: all.reduce((max, info) => Math.max(max, info.depth), 0),
136
+ }
137
+ })
138
+
139
+ const depths = context.lineDepths.filter((depth): depth is number => depth !== null)
140
+ const maxDepth = depths.reduce((max, depth) => Math.max(max, depth), 0)
141
+ const histogram = Array.from({ length: maxDepth + 1 }, () => 0)
142
+ for (const depth of depths) histogram[depth]!++
143
+
144
+ return {
145
+ settings,
146
+ indentUnit: detectIndentUnit(lines, context.lineDepths),
147
+ lineDepths: context.lineDepths,
148
+ maxDepth,
149
+ averageDepth: depths.length === 0 ? 0 : round(depths.reduce((sum, depth) => sum + depth, 0) / depths.length),
150
+ templateLines: depths.length,
151
+ deepLines: depths.filter((depth) => depth > settings.depthLimit).length,
152
+ histogram,
153
+ components,
154
+ suggestions,
155
+ }
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Tree construction
160
+
161
+ function buildAll(
162
+ context: Context,
163
+ host: Host,
164
+ nodes: readonly BeastNode[],
165
+ depth: number,
166
+ parent: NodeInfo | null,
167
+ available: Bindings,
168
+ ): NodeInfo[] {
169
+ return nodes.map((node) => build(context, host, node, depth, parent, available))
170
+ }
171
+
172
+ function build(
173
+ context: Context,
174
+ host: Host,
175
+ node: BeastNode,
176
+ depth: number,
177
+ parent: NodeInfo | null,
178
+ available: Bindings,
179
+ ): NodeInfo {
180
+ const info: NodeInfo = {
181
+ node,
182
+ host,
183
+ depth,
184
+ start: node.span.start.line,
185
+ end: node.span.end.line,
186
+ height: 0,
187
+ nodeCount: 1,
188
+ uses: new Set(),
189
+ shape: '',
190
+ values: [],
191
+ available,
192
+ parent,
193
+ children: [],
194
+ }
195
+ markLines(context, node.span.start.line, node.span.end.line, depth)
196
+
197
+ const own: string[] = []
198
+ const groups: Array<{ label: string; children: NodeInfo[] }> = []
199
+ const addGroup = (label: string, children: readonly BeastNode[], childDepth: number, bindings: Bindings) => {
200
+ const built = buildAll(context, host, children, childDepth, info, bindings)
201
+ groups.push({ label, children: built })
202
+ info.children.push(...built)
203
+ }
204
+
205
+ switch (node.kind) {
206
+ case 'element': {
207
+ // A component tag references a binding just like an expression does.
208
+ if (node.isComponent) own.push(node.tag)
209
+ if (node.id !== null) info.values.push(`#${node.id}`)
210
+ for (const name of node.classes) info.values.push(`.${name}`)
211
+ for (const attr of node.attrs) {
212
+ if (attr.kind === 'spread') {
213
+ own.push(attr.code)
214
+ info.values.push(`...${attr.code}`)
215
+ } else {
216
+ if (attr.value.type === 'expr') own.push(attr.value.code)
217
+ info.values.push(`${attr.name}=${attr.value.type === 'string' ? attr.value.value : attr.value.type === 'expr' ? attr.value.code : ''}`)
218
+ }
219
+ }
220
+ for (const span of node.inlineSpans ?? []) {
221
+ if (span.type === 'expr') own.push(span.code)
222
+ info.values.push(span.type === 'expr' ? span.code : span.text)
223
+ }
224
+ addGroup(node.isComponent ? node.tag : node.tag.toLowerCase(), node.children, depth + 1, available)
225
+ break
226
+ }
227
+ case 'text':
228
+ for (const span of node.spans) {
229
+ if (span.type === 'expr') own.push(span.code)
230
+ info.values.push(span.type === 'expr' ? span.code : span.text)
231
+ }
232
+ groups.push({ label: 't', children: [] })
233
+ break
234
+ case 'fragment':
235
+ addGroup('fragment', node.children, depth + 1, available)
236
+ break
237
+ case 'style':
238
+ host.hasStyle = true
239
+ groups.push({ label: 'style', children: [] })
240
+ break
241
+ case 'scope': {
242
+ markSetupLines(context, node.setup, depth + 1)
243
+ const scoped = new Map(available)
244
+ for (const setup of node.setup) {
245
+ own.push(setup.code)
246
+ for (const declaration of topLevelDeclarations(setup.code)) {
247
+ for (const [name, type] of inferDeclarationTypes(declaration.names, declaration.init)) scoped.set(name, type)
248
+ }
249
+ }
250
+ addGroup('scope', node.children, depth + 1, scoped)
251
+ break
252
+ }
253
+ case 'if':
254
+ for (const branch of node.branches) {
255
+ markLines(context, branch.span.start.line, branch.span.end.line, depth)
256
+ info.end = Math.max(info.end, branch.span.end.line)
257
+ if (branch.test !== null) own.push(branch.test)
258
+ info.values.push(branch.test ?? 'else')
259
+ addGroup(branch.test === null ? 'else' : 'if', branch.children, depth + 1, available)
260
+ }
261
+ break
262
+ case 'each': {
263
+ own.push(node.iterable)
264
+ info.values.push(node.iterable)
265
+ const iterated = new Map(available)
266
+ const iterable = node.iterable.trim()
267
+ iterated.set(
268
+ node.itemName,
269
+ /^[A-Za-z_$][\w$]*$/.test(iterable) && context.moduleNames.has(iterable) ? `(typeof ${iterable})[number]` : 'any',
270
+ )
271
+ if (node.indexName !== null) iterated.set(node.indexName, 'number')
272
+ if (node.key !== null) own.push(node.key)
273
+ addGroup('each', node.children, depth + 1, iterated)
274
+ if (node.emptyChildren !== null) addGroup('empty', node.emptyChildren, depth + 1, available)
275
+ break
276
+ }
277
+ case 'switch':
278
+ own.push(node.discriminant)
279
+ info.values.push(node.discriminant)
280
+ for (const branch of node.branches) {
281
+ markLines(context, branch.span.start.line, branch.span.end.line, depth + 1)
282
+ info.end = Math.max(info.end, branch.span.end.line)
283
+ if (branch.test !== null) own.push(branch.test)
284
+ info.values.push(branch.test ?? 'default')
285
+ addGroup('case', branch.children, depth + 2, available)
286
+ }
287
+ break
288
+ case 'try': {
289
+ addGroup('try', node.children, depth + 1, available)
290
+ if (node.pendingBranch !== null) {
291
+ markLines(context, node.pendingBranch.span.start.line, node.pendingBranch.span.end.line, depth)
292
+ info.end = Math.max(info.end, node.pendingBranch.span.end.line)
293
+ addGroup('pending', node.pendingBranch.children, depth + 1, available)
294
+ }
295
+ if (node.catchBranch !== null) {
296
+ markLines(context, node.catchBranch.span.start.line, node.catchBranch.span.end.line, depth)
297
+ info.end = Math.max(info.end, node.catchBranch.span.end.line)
298
+ const caught = new Map(available)
299
+ const bindings = (node.catchBranch.bindings ?? '').replace(/^\(|\)$/g, '')
300
+ for (const name of identifiersIn(bindings)) caught.set(name, 'any')
301
+ addGroup('catch', node.catchBranch.children, depth + 1, caught)
302
+ }
303
+ break
304
+ }
305
+ }
306
+
307
+ for (const code of own) for (const name of identifiersIn(code)) info.uses.add(name)
308
+ for (const child of info.children) {
309
+ info.end = Math.max(info.end, child.end)
310
+ info.height = Math.max(info.height, child.height + (child.depth - depth))
311
+ info.nodeCount += child.nodeCount
312
+ child.uses.forEach((name) => info.uses.add(name))
313
+ }
314
+ info.shape = `${node.kind === 'element' ? '' : node.kind}${groups
315
+ .map((group) => `${group.label}(${group.children.map((child) => child.shape).join(',')})`)
316
+ .join('|')}`
317
+ return info
318
+ }
319
+
320
+ function markLines(context: Context, start: number, end: number, depth: number): void {
321
+ for (let line = start; line <= end; line++) context.lineDepths[line - 1] ??= depth
322
+ }
323
+
324
+ /**
325
+ * Component setup is not template, but a `scope` block's setup sits inside the
326
+ * template and adds indentation the reader must track.
327
+ */
328
+ function markSetupLines(context: Context, setup: readonly SetupDeclaration[], depth: number): void {
329
+ for (const declaration of setup) {
330
+ const end = declaration.codeStart.line + declaration.code.split('\n').length - 1
331
+ markLines(context, declaration.span.start.line, Math.max(end, declaration.span.end.line), depth)
332
+ }
333
+ }
334
+
335
+ function flatten(info: NodeInfo): NodeInfo[] {
336
+ return [info, ...info.children.flatMap(flatten)]
337
+ }
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // Bindings and types
341
+
342
+ function componentBindings(
343
+ props: PropsDeclaration | null,
344
+ setup: readonly SetupDeclaration[],
345
+ ): Bindings {
346
+ const bindings = new Map<string, string>()
347
+ if (props !== null) {
348
+ const { names, type } = parsePropsParameter(props.parameter)
349
+ const indexable = type === null ? null : /^[A-Za-z_$][\w$.]*$/.test(type) ? type : `(${type})`
350
+ for (const name of names) bindings.set(name, indexable === null ? 'any' : `${indexable}['${name}']`)
351
+ }
352
+ for (const declaration of setup) {
353
+ for (const { names, init } of topLevelDeclarations(declaration.code)) {
354
+ for (const [name, type] of inferDeclarationTypes(names, init)) bindings.set(name, type)
355
+ }
356
+ }
357
+ return bindings
358
+ }
359
+
360
+ function inferDeclarationTypes(names: readonly string[], init: string): Array<[string, string]> {
361
+ const call = hookCall(init)
362
+ const [first, second] = names
363
+ if (call !== null) {
364
+ const valueType = call.typeArgument ?? literalType(call.argument)
365
+ switch (call.hook) {
366
+ case 'useState':
367
+ return pairs(names, [valueType, `(value: ${valueType}) => void`])
368
+ case 'useLinkedState':
369
+ return pairs(names, [call.typeArgument ?? 'any', `(value: ${call.typeArgument ?? 'any'}) => void`])
370
+ case 'useRef':
371
+ return pairs(names, [`{ current: ${call.typeArgument ?? 'any'} }`])
372
+ case 'useReducer':
373
+ return pairs(names, ['any', '(action: any) => void', '() => any'])
374
+ case 'useTransition':
375
+ return pairs(names, ['boolean', '(callback: () => void) => void'])
376
+ case 'useId':
377
+ return pairs(names, ['string'])
378
+ case 'useMemo':
379
+ case 'useCallback':
380
+ case 'useDeferredValue':
381
+ return pairs(names, [call.typeArgument ?? 'any'])
382
+ }
383
+ }
384
+ if (first !== undefined && second === undefined) return [[first, literalType(init)]]
385
+ return names.map((name) => [name, 'any'])
386
+ }
387
+
388
+ function pairs(names: readonly string[], types: readonly string[]): Array<[string, string]> {
389
+ return names.map((name, index) => [name, types[index] ?? 'any'])
390
+ }
391
+
392
+ function literalType(expression: string): string {
393
+ const text = expression.trim()
394
+ if (/^(['"`])/.test(text)) return 'string'
395
+ if (/^-?\d[\d_]*(\.\d+)?$/.test(text)) return 'number'
396
+ if (text === 'true' || text === 'false') return 'boolean'
397
+ return 'any'
398
+ }
399
+
400
+ function propsFor(infos: readonly NodeInfo[]): SuggestedProp[] {
401
+ const props = new Map<string, string>()
402
+ for (const info of infos) {
403
+ for (const name of info.uses) {
404
+ const type = info.available.get(name)
405
+ if (type !== undefined && !props.has(name)) props.set(name, type)
406
+ }
407
+ }
408
+ return [...props].map(([name, type]) => ({ name, type }))
409
+ }
410
+
411
+ // ---------------------------------------------------------------------------
412
+ // Suggestions
413
+
414
+ function suggestExtractions(context: Context, host: Host): Array<Omit<RefactorSuggestion, 'id'>> {
415
+ const { depthLimit, minLines } = context.settings
416
+ const suggestions: Array<Omit<RefactorSuggestion, 'id'>> = []
417
+ const all = host.roots.flatMap(flatten)
418
+ if (all.length === 0) return suggestions
419
+ const hostLines = Math.max(...all.map((info) => info.end)) - Math.min(...all.map((info) => info.start)) + 1
420
+
421
+ const scan = (infos: readonly NodeInfo[], baseDepth: number, maxLines: number) => {
422
+ for (const info of infos) {
423
+ const relativeDepth = info.depth - baseDepth
424
+ if (relativeDepth + info.height <= depthLimit) continue
425
+ const lines = info.end - info.start + 1
426
+ // A loop body is a natural component boundary at any size.
427
+ const loopBody = info.parent?.node.kind === 'each'
428
+ if (info.node.kind === 'element' && relativeDepth >= 1 && lines >= minLines && (loopBody || lines <= maxLines)) {
429
+ suggestions.push(extractSuggestion(context, info))
430
+ if (info.height > depthLimit) scan(info.children, info.depth, Math.max(minLines, Math.floor(lines * 0.6)))
431
+ } else {
432
+ scan(info.children, baseDepth, maxLines)
433
+ }
434
+ }
435
+ }
436
+ scan(host.roots, 0, Math.max(minLines * 2, Math.floor(hostLines * 0.5)))
437
+ return suggestions
438
+ }
439
+
440
+ function extractSuggestion(context: Context, info: NodeInfo): Omit<RefactorSuggestion, 'id'> {
441
+ const { depthLimit } = context.settings
442
+ const reach = info.depth + info.height
443
+ const excess = reach - depthLimit
444
+ const name = uniqueName(context, suggestName(context, info))
445
+ const props = propsFor([info])
446
+ const lines = info.end - info.start + 1
447
+ const severity: Severity = excess >= 3 ? 'critical' : 'warning'
448
+ const where = info.parent?.node.kind === 'each' ? ', repeated by a loop,' : ''
449
+ return {
450
+ kind: 'extract',
451
+ severity,
452
+ host: info.host.name,
453
+ name,
454
+ label: labelOf(info.node),
455
+ reason:
456
+ `${lines} lines${where} reach depth ${reach} (limit ${depthLimit}). ` +
457
+ `Extracted as ${name}, its deepest line drops to depth ${info.height}` +
458
+ (props.length === 0 ? ' and it needs no props.' : ` and it needs ${props.length} prop${props.length === 1 ? '' : 's'}.`),
459
+ startLine: info.start,
460
+ endLine: info.end,
461
+ lines,
462
+ depth: info.depth,
463
+ reach,
464
+ props,
465
+ snippet: componentSnippet(context, info, name, props),
466
+ usage: usageLine(context, info, name, props),
467
+ insertBeforeLine: info.host.insertBeforeLine,
468
+ occurrences: [{ startLine: info.start, endLine: info.end }],
469
+ ...applicability(context, [info], props, 0),
470
+ }
471
+ }
472
+
473
+ function suggestDuplicates(context: Context, hosts: readonly Host[]): Array<Omit<RefactorSuggestion, 'id'>> {
474
+ const minLines = Math.max(4, Math.ceil(context.settings.minLines / 2))
475
+ const groups = new Map<string, NodeInfo[]>()
476
+ for (const info of hosts.flatMap((host) => host.roots.flatMap(flatten))) {
477
+ if (info.node.kind === 'text' || info.node.kind === 'style' || info.nodeCount < 3) continue
478
+ if (info.end - info.start + 1 < minLines) continue
479
+ const group = groups.get(info.shape) ?? []
480
+ group.push(info)
481
+ groups.set(info.shape, group)
482
+ }
483
+
484
+ const ranked = [...groups.values()]
485
+ .map((group) => group.filter((info) => !group.some((other) => other !== info && contains(other, info))))
486
+ .filter((group) => group.length >= 2)
487
+ .sort((a, b) => weight(b) - weight(a))
488
+
489
+ // An accepted group hides the groups nested in it, except that an identical
490
+ // inner group still surfaces when its container's copies differ, because
491
+ // only identical copies can be replaced automatically.
492
+ const covered: Array<LineRange & { identical: boolean }> = []
493
+ const suggestions: Array<Omit<RefactorSuggestion, 'id'>> = []
494
+ for (const group of ranked) {
495
+ const variants = countVariants(group)
496
+ const inside = (info: NodeInfo, identicalOnly: boolean) =>
497
+ covered.some((range) => range.startLine <= info.start && info.end <= range.endLine && (range.identical || !identicalOnly))
498
+ if (group.every((info) => inside(info, variants === 0))) continue
499
+ covered.push(...group.map((info) => ({ startLine: info.start, endLine: info.end, identical: variants === 0 })))
500
+
501
+ const [first] = group as [NodeInfo, ...NodeInfo[]]
502
+ const element = firstElement(first)
503
+ const name = uniqueName(context, element === null ? `${first.host.name}Block` : suggestName(context, element, first))
504
+ const props = propsFor(group)
505
+ const lines = first.end - first.start + 1
506
+ suggestions.push({
507
+ kind: 'duplicate',
508
+ severity: 'info',
509
+ host: first.host.name,
510
+ name,
511
+ label: labelOf(first.node),
512
+ reason:
513
+ `${group.length} structurally identical blocks of ${lines} lines. ` +
514
+ (variants === 0
515
+ ? `They are identical, so one ${name} can replace every copy.`
516
+ : `They differ in ${variants} attribute or text value${variants === 1 ? '' : 's'}; pass those as props to one ${name}.`),
517
+ startLine: first.start,
518
+ endLine: first.end,
519
+ lines,
520
+ depth: first.depth,
521
+ reach: first.depth + first.height,
522
+ props,
523
+ snippet: componentSnippet(context, first, name, props),
524
+ usage: usageLine(context, first, name, props),
525
+ insertBeforeLine: first.host.insertBeforeLine,
526
+ occurrences: group.map((info) => ({ startLine: info.start, endLine: info.end })),
527
+ ...applicability(context, group, props, variants),
528
+ })
529
+ }
530
+ return suggestions
531
+ }
532
+
533
+ /**
534
+ * What a section references, and whether it can be rewritten automatically.
535
+ * Refusals are conservative: anything that could change behavior is left to
536
+ * the developer.
537
+ */
538
+ function applicability(
539
+ context: Context,
540
+ infos: readonly NodeInfo[],
541
+ props: readonly SuggestedProp[],
542
+ variants: number,
543
+ ): { references: string[]; autoApply: AutoApply } {
544
+ const [first] = infos as [NodeInfo, ...NodeInfo[]]
545
+ const references = new Set<string>()
546
+ for (const info of infos) info.uses.forEach((name) => references.add(name))
547
+ for (const prop of props) identifiersIn(prop.type).forEach((name) => references.add(name))
548
+ const lines = first.end - first.start + 1
549
+
550
+ let blocked: string | null = null
551
+ if (first.host.hasStyle) {
552
+ blocked = `${first.host.name} has a scoped style block, which would stop matching the moved elements.`
553
+ } else if (variants > 0) {
554
+ blocked = `The copies differ in ${variants} value${variants === 1 ? '' : 's'}, so replacing them with one call would change behavior.`
555
+ } else if (infos.some((info) => info.host !== first.host)) {
556
+ blocked = 'The copies live in different components, which may not share the bindings the props need.'
557
+ }
558
+
559
+ const locals = [...references].filter((name) => context.localComponents.has(name) && !props.some((p) => p.name === name))
560
+ const fileBlocked = locals.length === 0
561
+ ? null
562
+ : `Uses ${locals.join(', ')}, which ${locals.length === 1 ? 'is' : 'are'} declared in this file and cannot be imported.`
563
+
564
+ return {
565
+ references: [...references].sort(),
566
+ autoApply: {
567
+ target: lines >= context.settings.fileLines && fileBlocked === null ? 'file' : 'inline',
568
+ blocked,
569
+ fileBlocked,
570
+ },
571
+ }
572
+ }
573
+
574
+ function contains(outer: NodeInfo, inner: NodeInfo): boolean {
575
+ return outer.start <= inner.start && inner.end <= outer.end && outer.nodeCount > inner.nodeCount
576
+ }
577
+
578
+ function weight(group: readonly NodeInfo[]): number {
579
+ return group.reduce((sum, info) => sum + (info.end - info.start + 1), 0)
580
+ }
581
+
582
+ function countVariants(group: readonly NodeInfo[]): number {
583
+ const values = group.map((info) => flatten(info).flatMap((node) => node.values))
584
+ const [base, ...rest] = values as [string[], ...string[][]]
585
+ let differences = 0
586
+ for (let index = 0; index < base.length; index++) {
587
+ if (rest.some((other) => other[index] !== base[index])) differences++
588
+ }
589
+ return differences + Math.max(0, ...rest.map((other) => other.length - base.length))
590
+ }
591
+
592
+ function firstElement(info: NodeInfo): NodeInfo | null {
593
+ if (info.node.kind === 'element') return info
594
+ for (const child of info.children) {
595
+ const found = firstElement(child)
596
+ if (found !== null) return found
597
+ }
598
+ return null
599
+ }
600
+
601
+ // ---------------------------------------------------------------------------
602
+ // Naming and code generation
603
+
604
+ function suggestName(context: Context, info: NodeInfo, root: NodeInfo = info): string {
605
+ const node = info.node as ElementNode
606
+ // String attributes name a section; so does the static lead of a template
607
+ // literal, e.g. aria-label={`Copy note: ${note}`} → `CopyNote`.
608
+ const stringAttr = (name: string) => {
609
+ const attr = node.attrs.find((a) => a.kind === 'attribute' && a.name === name)
610
+ if (attr?.kind !== 'attribute') return null
611
+ if (attr.value.type === 'string') return attr.value.value
612
+ if (attr.value.type !== 'expr') return null
613
+ const lead = /^\s*(['"`])([^'"`$]*)/.exec(attr.value.code)?.[2]?.split(':')[0]?.trim()
614
+ return lead === undefined || lead === '' ? null : lead
615
+ }
616
+ const tag = node.isComponent
617
+ ? pascal(node.tag.split('.').at(-1) ?? node.tag)
618
+ : (TAG_SUFFIX[node.tag] ?? pascal(node.tag))
619
+
620
+ if (node.id !== null) return pascal(node.id)
621
+ const label = stringAttr('aria-label')
622
+ if (label !== null && pascal(label) !== '') return pascal(label)
623
+ const comment = commentAbove(context, root)
624
+ if (comment !== null) return withSuffix(pascal(comment), tag)
625
+ if (info.parent?.node.kind === 'each') return withSuffix(pascal(info.parent.node.itemName), tag)
626
+ const semanticClass = node.classes.find((name) => /^[a-z][a-z0-9-]*$/.test(name))
627
+ if (semanticClass !== undefined) return pascal(semanticClass)
628
+ const role = stringAttr('role')
629
+ if (role !== null) return withSuffix(pascal(role), tag)
630
+ return node.isComponent ? `${tag}Section` : `${info.host.name}${tag || 'Section'}`
631
+ }
632
+
633
+ /** Friendlier name suffixes for generic tags: `each product` + `li` → `ProductItem`. */
634
+ const TAG_SUFFIX: Record<string, string> = {
635
+ a: 'Link',
636
+ div: '',
637
+ li: 'Item',
638
+ ol: 'List',
639
+ p: 'Text',
640
+ span: '',
641
+ td: 'Cell',
642
+ th: 'Cell',
643
+ tr: 'Row',
644
+ ul: 'List',
645
+ }
646
+
647
+ /** A `//` comment on the line directly above a section names it: `// LEFT` → `Left`. */
648
+ function commentAbove(context: Context, info: NodeInfo): string | null {
649
+ const line = context.lines[info.start - 2]?.trim() ?? ''
650
+ if (!line.startsWith('//')) return null
651
+ const text = line.replace(/^\/+/, '').trim()
652
+ return text === '' ? null : text
653
+ }
654
+
655
+ function withSuffix(base: string, suffix: string): string {
656
+ if (base === '') return suffix || 'Section'
657
+ return base.toLowerCase().endsWith(suffix.toLowerCase()) ? base : `${base}${suffix}`
658
+ }
659
+
660
+ function uniqueName(context: Context, base: string): string {
661
+ const safe = /^[A-Z]/.test(base) ? base : `Section${base}`
662
+ let name = safe
663
+ for (let n = 2; context.takenNames.has(name); n++) name = `${safe}${n}`
664
+ context.takenNames.add(name)
665
+ return name
666
+ }
667
+
668
+ function pascal(text: string): string {
669
+ return text
670
+ .split(/[^A-Za-z0-9]+/)
671
+ .filter(Boolean)
672
+ .slice(0, 3)
673
+ .map((word) => word[0]!.toUpperCase() + word.slice(1).toLowerCase())
674
+ .join('')
675
+ .replace(/^\d+/, '')
676
+ }
677
+
678
+ function labelOf(node: BeastNode): string {
679
+ if (node.kind !== 'element') return node.kind
680
+ return `${node.tag}${node.id === null ? '' : `#${node.id}`}${node.classes.map((c) => `.${c}`).join('')}`
681
+ }
682
+
683
+ function componentSnippet(context: Context, info: NodeInfo, name: string, props: readonly SuggestedProp[]): string {
684
+ const base = info.node.span.start.column - 1
685
+ const headerEnd = info.node.span.end.line
686
+ const key = keyAttribute(context, info)
687
+ const body = context.lines.slice(info.start - 1, info.end).map((line, index) => {
688
+ const indent = /^ */.exec(line)![0].length
689
+ // A loop key identifies the call site, so it moves to the usage line.
690
+ const text = key !== null && info.start + index <= headerEnd
691
+ ? line.replace(KEY_ATTRIBUTE, '').replace(/^(\s*[\w.#$-]+)\(\s*\)/, '$1')
692
+ : line
693
+ return text.trim() === '' || text.trim() === '~' ? '' : ` ${text.slice(Math.min(indent, base))}`
694
+ }).filter((line, index) => line !== '' || index > headerEnd - info.start)
695
+ while (body.at(-1) === '') body.pop()
696
+ const header = [`component ${name}`]
697
+ if (props.length > 0) {
698
+ header.push(
699
+ ` props { ${props.map((p) => p.name).join(', ')} }: { ${props.map((p) => `${p.name}: ${p.type}`).join('; ')} }`,
700
+ )
701
+ }
702
+ return [...header, ...body].join('\n')
703
+ }
704
+
705
+ function usageLine(context: Context, info: NodeInfo, name: string, props: readonly SuggestedProp[]): string {
706
+ const indent = ' '.repeat(info.node.span.start.column - 1)
707
+ const key = keyAttribute(context, info)
708
+ const attrs = [...(key === null ? [] : [`key={${key}}`]), ...props.map((p) => `${p.name}={${p.name}}`)]
709
+ return attrs.length === 0 ? `${indent}${name}` : `${indent}${name}(${attrs.join(' ')})`
710
+ }
711
+
712
+ const KEY_ATTRIBUTE = /[\s,]*\bkey=\{[^{}]*\}/
713
+
714
+ /** Beast hoists a loop root's `key=` into the `each` header, so read it from source. */
715
+ function keyAttribute(context: Context, info: NodeInfo): string | null {
716
+ if (info.node.kind !== 'element') return null
717
+ const header = context.lines.slice(info.start - 1, info.node.span.end.line).join('\n')
718
+ return /\bkey=\{([^{}]*)\}/.exec(header)?.[1]?.trim() ?? null
719
+ }
720
+
721
+ function detectIndentUnit(lines: readonly string[], lineDepths: ReadonlyArray<number | null>): number {
722
+ let unit = 0
723
+ lines.forEach((line, index) => {
724
+ if (lineDepths[index] == null || line.trim() === '' || line.trimStart().startsWith('~')) return
725
+ const indent = /^ */.exec(line)![0].length
726
+ if (indent > 0) unit = gcd(unit, indent)
727
+ })
728
+ return unit || 2
729
+ }
730
+
731
+ function gcd(a: number, b: number): number {
732
+ return b === 0 ? a : gcd(b, a % b)
733
+ }
734
+
735
+ function round(value: number): number {
736
+ return Math.round(value * 100) / 100
737
+ }