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.
- package/.claude/launch.json +11 -0
- package/CHANGELOG.md +37 -0
- package/README.md +87 -0
- package/devtools/CHANGELOG.md +26 -0
- package/devtools/LICENSE +15 -0
- package/devtools/README.md +164 -0
- package/devtools/client/BeastDevtools.btsx +185 -0
- package/devtools/client/CodeView.btsx +61 -0
- package/devtools/client/ComponentsPanel.btsx +212 -0
- package/devtools/client/FileList.btsx +35 -0
- package/devtools/client/InspectorPanel.btsx +131 -0
- package/devtools/client/RefactorPanel.btsx +304 -0
- package/devtools/client/api.ts +69 -0
- package/devtools/client/compile.test.ts +22 -0
- package/devtools/client/devtools.css +1279 -0
- package/devtools/client/highlight.ts +210 -0
- package/devtools/client/mount.ts +16 -0
- package/devtools/client/runtime.ts +168 -0
- package/devtools/client/util.ts +136 -0
- package/devtools/package.json +73 -0
- package/devtools/server/analyze.test.ts +148 -0
- package/devtools/server/analyze.ts +737 -0
- package/devtools/server/diff.ts +82 -0
- package/devtools/server/line-map.ts +63 -0
- package/devtools/server/octane-bundler.d.ts +17 -0
- package/devtools/server/project.ts +369 -0
- package/devtools/server/refactor.test.ts +224 -0
- package/devtools/server/refactor.ts +224 -0
- package/devtools/server/source-scan.ts +377 -0
- package/devtools/shared/types.ts +208 -0
- package/devtools/test/fixtures/App.btsx +193 -0
- package/devtools/tsconfig.build.json +17 -0
- package/devtools/vite.ts +159 -0
- package/favicon.ico +0 -0
- package/index.html +14 -0
- package/package.json +34 -0
- package/public/beast.svg +1 -0
- package/src/App.btsx +144 -0
- package/src/AppHeader.btsx +24 -0
- package/src/LeftArticle.btsx +22 -0
- package/src/RightArticle.btsx +19 -0
- package/src/env.d.ts +8 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.ts +8 -0
- package/src/style.css +44 -0
- package/tsconfig.json +39 -0
- 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
|
+
}
|