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,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight lexical helpers for the TypeScript slices Beast keeps verbatim
|
|
3
|
+
* (setup code, props parameters, attribute expressions). They deliberately
|
|
4
|
+
* avoid a full parser: devtools only needs identifier names, top-level
|
|
5
|
+
* declarations, and a few hook shapes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const RESERVED = new Set([
|
|
9
|
+
'as', 'async', 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default',
|
|
10
|
+
'delete', 'do', 'else', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'import', 'in',
|
|
11
|
+
'instanceof', 'let', 'new', 'null', 'of', 'return', 'satisfies', 'super', 'switch', 'this', 'throw',
|
|
12
|
+
'true', 'try', 'typeof', 'undefined', 'var', 'void', 'while', 'with', 'yield',
|
|
13
|
+
])
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Replace string, template, and comment contents with spaces so structural
|
|
17
|
+
* scanning cannot be confused by quoted brackets. Offsets are preserved.
|
|
18
|
+
* With `keepTemplateExpressions`, `${...}` bodies inside template literals are
|
|
19
|
+
* kept because they reference identifiers.
|
|
20
|
+
*/
|
|
21
|
+
export function maskLiterals(code: string, keepTemplateExpressions = false): string {
|
|
22
|
+
const out = [...code]
|
|
23
|
+
let i = 0
|
|
24
|
+
const blank = (from: number, to: number) => {
|
|
25
|
+
for (let k = from; k < to && k < out.length; k++) if (out[k] !== '\n') out[k] = ' '
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const skipTemplate = (start: number): number => {
|
|
29
|
+
let j = start + 1
|
|
30
|
+
let literalStart = j
|
|
31
|
+
while (j < code.length) {
|
|
32
|
+
const char = code[j]
|
|
33
|
+
if (char === '\\') {
|
|
34
|
+
j += 2
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
if (char === '`') {
|
|
38
|
+
blank(literalStart, j)
|
|
39
|
+
return j + 1
|
|
40
|
+
}
|
|
41
|
+
if (char === '$' && code[j + 1] === '{') {
|
|
42
|
+
blank(literalStart, j)
|
|
43
|
+
const end = skipBalanced(j + 1)
|
|
44
|
+
// `$` is an identifier character, so it is always masked.
|
|
45
|
+
blank(j, keepTemplateExpressions ? j + 1 : end)
|
|
46
|
+
j = end
|
|
47
|
+
literalStart = j
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
j++
|
|
51
|
+
}
|
|
52
|
+
blank(literalStart, j)
|
|
53
|
+
return j
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Scan from an opening `{` to just past its matching `}`, honoring nested literals.
|
|
57
|
+
const skipBalanced = (open: number): number => {
|
|
58
|
+
let depth = 0
|
|
59
|
+
let j = open
|
|
60
|
+
while (j < code.length) {
|
|
61
|
+
const char = code[j]
|
|
62
|
+
if (char === '"' || char === "'") {
|
|
63
|
+
j = skipQuoted(j)
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
if (char === '`') {
|
|
67
|
+
j = skipTemplate(j)
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
if (char === '{') depth++
|
|
71
|
+
else if (char === '}') {
|
|
72
|
+
depth--
|
|
73
|
+
if (depth === 0) return j + 1
|
|
74
|
+
}
|
|
75
|
+
j++
|
|
76
|
+
}
|
|
77
|
+
return j
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const skipQuoted = (start: number): number => {
|
|
81
|
+
const quote = code[start]
|
|
82
|
+
let j = start + 1
|
|
83
|
+
while (j < code.length && code[j] !== quote && code[j] !== '\n') {
|
|
84
|
+
j += code[j] === '\\' ? 2 : 1
|
|
85
|
+
}
|
|
86
|
+
blank(start + 1, j)
|
|
87
|
+
return j + 1
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
while (i < code.length) {
|
|
91
|
+
const char = code[i]
|
|
92
|
+
const next = code[i + 1]
|
|
93
|
+
if (char === '/' && next === '/') {
|
|
94
|
+
const end = code.indexOf('\n', i)
|
|
95
|
+
const stop = end === -1 ? code.length : end
|
|
96
|
+
blank(i, stop)
|
|
97
|
+
i = stop
|
|
98
|
+
} else if (char === '/' && next === '*') {
|
|
99
|
+
const end = code.indexOf('*/', i + 2)
|
|
100
|
+
const stop = end === -1 ? code.length : end + 2
|
|
101
|
+
blank(i, stop)
|
|
102
|
+
i = stop
|
|
103
|
+
} else if (char === '"' || char === "'") {
|
|
104
|
+
i = skipQuoted(i)
|
|
105
|
+
} else if (char === '`') {
|
|
106
|
+
i = skipTemplate(i)
|
|
107
|
+
} else {
|
|
108
|
+
i++
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return out.join('')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Every identifier referenced by an expression, excluding property names and keywords. */
|
|
115
|
+
export function identifiersIn(code: string): Set<string> {
|
|
116
|
+
const masked = maskLiterals(code, true)
|
|
117
|
+
const names = new Set<string>()
|
|
118
|
+
const pattern = /[A-Za-z_$][\w$]*/g
|
|
119
|
+
for (const match of masked.matchAll(pattern)) {
|
|
120
|
+
const name = match[0]
|
|
121
|
+
if (RESERVED.has(name)) continue
|
|
122
|
+
const before = masked.slice(0, match.index).trimEnd()
|
|
123
|
+
// `a.b` and `a?.b` are property reads of `b`; `...b` is a spread of `b`.
|
|
124
|
+
if (before.endsWith('.') && !before.endsWith('...')) continue
|
|
125
|
+
names.add(name)
|
|
126
|
+
}
|
|
127
|
+
return names
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Names bound by a destructuring pattern or a plain identifier. */
|
|
131
|
+
export function patternNames(pattern: string): string[] {
|
|
132
|
+
const text = pattern.trim()
|
|
133
|
+
if (text.startsWith('{') || text.startsWith('[')) {
|
|
134
|
+
const inner = text.slice(1, matchingClose(text, 0))
|
|
135
|
+
return splitTopLevel(inner, ',').flatMap((part) => {
|
|
136
|
+
let entry = part.trim()
|
|
137
|
+
if (entry === '') return []
|
|
138
|
+
if (entry.startsWith('...')) entry = entry.slice(3)
|
|
139
|
+
const eq = indexOfTopLevel(entry, '=')
|
|
140
|
+
if (eq !== -1) entry = entry.slice(0, eq)
|
|
141
|
+
if (text.startsWith('{')) {
|
|
142
|
+
const colon = indexOfTopLevel(entry, ':')
|
|
143
|
+
if (colon !== -1) return patternNames(entry.slice(colon + 1))
|
|
144
|
+
}
|
|
145
|
+
return patternNames(entry)
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
const identifier = /^[A-Za-z_$][\w$]*/.exec(text)
|
|
149
|
+
return identifier ? [identifier[0]] : []
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface Declaration {
|
|
153
|
+
names: string[]
|
|
154
|
+
/** Source text of the initializer (up to the end of the statement). */
|
|
155
|
+
init: string
|
|
156
|
+
/** Offset of the declaration keyword inside the scanned code. */
|
|
157
|
+
offset: number
|
|
158
|
+
keyword: string
|
|
159
|
+
/** `type` and `interface` declare types; everything else declares values. */
|
|
160
|
+
kind: 'value' | 'type'
|
|
161
|
+
exported: boolean
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Top-level `const`/`let`/`var`/`function`/`class`/`enum`/`type`/`interface` declarations. */
|
|
165
|
+
export function topLevelDeclarations(code: string): Declaration[] {
|
|
166
|
+
const masked = maskLiterals(code)
|
|
167
|
+
const declarations: Declaration[] = []
|
|
168
|
+
let depth = 0
|
|
169
|
+
for (let i = 0; i < masked.length; i++) {
|
|
170
|
+
const char = masked[i]!
|
|
171
|
+
if (char === '{' || char === '(' || char === '[') depth++
|
|
172
|
+
else if (char === '}' || char === ')' || char === ']') depth--
|
|
173
|
+
if (depth !== 0 || !/[a-z]/.test(char) || /[\w$]/.test(masked[i - 1] ?? '')) continue
|
|
174
|
+
|
|
175
|
+
const keyword = /^(const|let|var|function|class|enum|type|interface|async\s+function)\s+/.exec(masked.slice(i))
|
|
176
|
+
if (keyword === null) continue
|
|
177
|
+
const rest = i + keyword[0].length
|
|
178
|
+
const exported = /\bexport\s*$/.test(masked.slice(Math.max(0, i - 16), i))
|
|
179
|
+
const base = { offset: i, keyword: keyword[1]!, exported }
|
|
180
|
+
if (keyword[1] === 'const' || keyword[1] === 'let' || keyword[1] === 'var') {
|
|
181
|
+
const opener = masked[rest]
|
|
182
|
+
const patternEnd = opener === '{' || opener === '['
|
|
183
|
+
? matchingClose(masked, rest) + 1
|
|
184
|
+
: rest + (/^[A-Za-z_$][\w$]*/.exec(masked.slice(rest))?.[0].length ?? 0)
|
|
185
|
+
const eq = masked.indexOf('=', patternEnd)
|
|
186
|
+
const end = statementEnd(masked, eq === -1 ? patternEnd : eq + 1)
|
|
187
|
+
declarations.push({
|
|
188
|
+
...base,
|
|
189
|
+
names: patternNames(code.slice(rest, patternEnd)),
|
|
190
|
+
init: eq === -1 ? '' : code.slice(eq + 1, end).trim(),
|
|
191
|
+
kind: 'value',
|
|
192
|
+
})
|
|
193
|
+
i = end - 1
|
|
194
|
+
} else if (keyword[1] === 'type' || keyword[1] === 'interface') {
|
|
195
|
+
// `type` is only a declaration when a name and `=` or type parameters follow.
|
|
196
|
+
const name = /^([A-Za-z_$][\w$]*)\s*(?:[=<]|extends\b|\{)/.exec(masked.slice(rest))?.[1]
|
|
197
|
+
if (name !== undefined) declarations.push({ ...base, names: [name], init: '', kind: 'type' })
|
|
198
|
+
i = rest
|
|
199
|
+
} else {
|
|
200
|
+
const name = /^\*?\s*([A-Za-z_$][\w$]*)/.exec(masked.slice(rest))?.[1]
|
|
201
|
+
if (name !== undefined) declarations.push({ ...base, names: [name], init: '', kind: 'value' })
|
|
202
|
+
i = rest
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return declarations
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface ImportSpecifier {
|
|
209
|
+
/** Binding name in the importing module. */
|
|
210
|
+
local: string
|
|
211
|
+
/** `default`, `*`, or the exported name. */
|
|
212
|
+
imported: string
|
|
213
|
+
typeOnly: boolean
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface ParsedImport {
|
|
217
|
+
source: string
|
|
218
|
+
/** Quote character used for the module specifier. */
|
|
219
|
+
quote: string
|
|
220
|
+
typeOnly: boolean
|
|
221
|
+
specifiers: ImportSpecifier[]
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Parse one `import` statement; side-effect imports return no specifiers. */
|
|
225
|
+
export function parseImport(code: string): ParsedImport | null {
|
|
226
|
+
const text = code.trim().replace(/;$/, '').trim()
|
|
227
|
+
const bare = /^import\s+(['"])([^'"]+)\1$/.exec(text)
|
|
228
|
+
if (bare !== null) return { source: bare[2]!, quote: bare[1]!, typeOnly: false, specifiers: [] }
|
|
229
|
+
const match = /^import\s+(type\s+)?([\s\S]+?)\s+from\s+(['"])([^'"]+)\3$/.exec(text)
|
|
230
|
+
if (match === null) return null
|
|
231
|
+
const typeOnly = match[1] !== undefined
|
|
232
|
+
const specifiers: ImportSpecifier[] = []
|
|
233
|
+
let clause = match[2]!.trim()
|
|
234
|
+
const named = /\{([\s\S]*)\}/.exec(clause)
|
|
235
|
+
if (named !== null) {
|
|
236
|
+
for (const part of named[1]!.split(',')) {
|
|
237
|
+
const entry = part.trim()
|
|
238
|
+
if (entry === '') continue
|
|
239
|
+
const inlineType = /^type\s+/.test(entry)
|
|
240
|
+
const [imported, local = imported] = entry.replace(/^type\s+/, '').split(/\s+as\s+/).map((name) => name.trim())
|
|
241
|
+
specifiers.push({ local: local!, imported: imported!, typeOnly: typeOnly || inlineType })
|
|
242
|
+
}
|
|
243
|
+
clause = clause.replace(named[0], '')
|
|
244
|
+
}
|
|
245
|
+
for (const part of clause.split(',')) {
|
|
246
|
+
const entry = part.trim()
|
|
247
|
+
if (entry === '') continue
|
|
248
|
+
const namespace = /^\*\s+as\s+([A-Za-z_$][\w$]*)$/.exec(entry)
|
|
249
|
+
if (namespace !== null) specifiers.push({ local: namespace[1]!, imported: '*', typeOnly })
|
|
250
|
+
else if (/^[A-Za-z_$][\w$]*$/.test(entry)) specifiers.push({ local: entry, imported: 'default', typeOnly })
|
|
251
|
+
}
|
|
252
|
+
return { source: match[4]!, quote: match[3]!, typeOnly, specifiers }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Render import statements for a subset of specifiers, one line per statement. */
|
|
256
|
+
export function renderImport(parsed: ParsedImport, specifiers: readonly ImportSpecifier[]): string[] {
|
|
257
|
+
const q = parsed.quote
|
|
258
|
+
const from = `from ${q}${parsed.source}${q}`
|
|
259
|
+
const lines: string[] = []
|
|
260
|
+
const namespace = specifiers.find((s) => s.imported === '*')
|
|
261
|
+
if (namespace !== undefined) lines.push(`import ${namespace.typeOnly ? 'type ' : ''}* as ${namespace.local} ${from}`)
|
|
262
|
+
const rest = specifiers.filter((s) => s.imported !== '*')
|
|
263
|
+
const types = rest.filter((s) => s.typeOnly)
|
|
264
|
+
const values = rest.filter((s) => !s.typeOnly)
|
|
265
|
+
const clause = (list: readonly ImportSpecifier[]) => {
|
|
266
|
+
const fallback = list.find((s) => s.imported === 'default')
|
|
267
|
+
const named = list
|
|
268
|
+
.filter((s) => s.imported !== 'default')
|
|
269
|
+
.map((s) => (s.imported === s.local ? s.local : `${s.imported} as ${s.local}`))
|
|
270
|
+
return [fallback?.local, named.length > 0 ? `{ ${named.join(', ')} }` : undefined].filter(Boolean).join(', ')
|
|
271
|
+
}
|
|
272
|
+
if (values.length > 0) lines.push(`import ${clause(values)} ${from}`)
|
|
273
|
+
// A type-only import may name a default or named bindings, not both.
|
|
274
|
+
const typeDefault = types.filter((s) => s.imported === 'default')
|
|
275
|
+
const typeNamed = types.filter((s) => s.imported !== 'default')
|
|
276
|
+
if (typeDefault.length > 0) lines.push(`import type ${clause(typeDefault)} ${from}`)
|
|
277
|
+
if (typeNamed.length > 0) lines.push(`import type ${clause(typeNamed)} ${from}`)
|
|
278
|
+
return lines
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Split a `props` parameter such as `{ a, b = 1 }: Props` into names and type text. */
|
|
282
|
+
export function parsePropsParameter(parameter: string): { names: string[]; type: string | null } {
|
|
283
|
+
const text = parameter.trim().replace(/;$/, '')
|
|
284
|
+
const masked = maskLiterals(text)
|
|
285
|
+
const patternEnd = text.startsWith('{') || text.startsWith('[')
|
|
286
|
+
? matchingClose(masked, 0) + 1
|
|
287
|
+
: (/^[A-Za-z_$][\w$]*/.exec(text)?.[0].length ?? 0)
|
|
288
|
+
const names = patternNames(text.slice(0, patternEnd))
|
|
289
|
+
const rest = text.slice(patternEnd).trim()
|
|
290
|
+
return { names, type: rest.startsWith(':') ? rest.slice(1).trim() || null : null }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Return the balanced type-argument text after `callee<`, if the initializer starts with it. */
|
|
294
|
+
export function hookCall(init: string): { hook: string; typeArgument: string | null; argument: string } | null {
|
|
295
|
+
const match = /^(?:await\s+)?(use[A-Z]\w*)\s*/.exec(init)
|
|
296
|
+
if (match === null) return null
|
|
297
|
+
let i = match[0].length
|
|
298
|
+
let typeArgument: string | null = null
|
|
299
|
+
if (init[i] === '<') {
|
|
300
|
+
let depth = 0
|
|
301
|
+
const start = i
|
|
302
|
+
for (; i < init.length; i++) {
|
|
303
|
+
if (init[i] === '<') depth++
|
|
304
|
+
else if (init[i] === '>' && init[i - 1] !== '=') {
|
|
305
|
+
depth--
|
|
306
|
+
if (depth === 0) break
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
typeArgument = init.slice(start + 1, i).trim()
|
|
310
|
+
i++
|
|
311
|
+
}
|
|
312
|
+
if (init[i] !== '(') return null
|
|
313
|
+
const close = matchingClose(maskLiterals(init), i)
|
|
314
|
+
const args = splitTopLevel(init.slice(i + 1, close), ',')
|
|
315
|
+
return { hook: match[1]!, typeArgument, argument: (args[0] ?? '').trim() }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function matchingClose(text: string, open: number): number {
|
|
319
|
+
const pairs: Record<string, string> = { '{': '}', '[': ']', '(': ')' }
|
|
320
|
+
const stack: string[] = []
|
|
321
|
+
for (let i = open; i < text.length; i++) {
|
|
322
|
+
const char = text[i]!
|
|
323
|
+
if (char in pairs) stack.push(pairs[char]!)
|
|
324
|
+
else if (char === stack.at(-1)) {
|
|
325
|
+
stack.pop()
|
|
326
|
+
if (stack.length === 0) return i
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return text.length
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function splitTopLevel(text: string, separator: string): string[] {
|
|
333
|
+
const masked = maskLiterals(text)
|
|
334
|
+
const parts: string[] = []
|
|
335
|
+
let depth = 0
|
|
336
|
+
let start = 0
|
|
337
|
+
for (let i = 0; i < masked.length; i++) {
|
|
338
|
+
const char = masked[i]!
|
|
339
|
+
if ('{[(<'.includes(char)) depth++
|
|
340
|
+
else if ('}])>'.includes(char) && !(char === '>' && masked[i - 1] === '=')) depth--
|
|
341
|
+
else if (char === separator && depth === 0) {
|
|
342
|
+
parts.push(text.slice(start, i))
|
|
343
|
+
start = i + 1
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
parts.push(text.slice(start))
|
|
347
|
+
return parts
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function indexOfTopLevel(text: string, char: string): number {
|
|
351
|
+
let depth = 0
|
|
352
|
+
for (let i = 0; i < text.length; i++) {
|
|
353
|
+
const current = text[i]!
|
|
354
|
+
if ('{[(<'.includes(current)) depth++
|
|
355
|
+
else if ('}])>'.includes(current) && !(current === '>' && text[i - 1] === '=')) depth--
|
|
356
|
+
else if (current === char && depth === 0) return i
|
|
357
|
+
}
|
|
358
|
+
return -1
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function statementEnd(masked: string, from: number): number {
|
|
362
|
+
let depth = 0
|
|
363
|
+
for (let i = from; i < masked.length; i++) {
|
|
364
|
+
const char = masked[i]!
|
|
365
|
+
if (char === '{' || char === '(' || char === '[') depth++
|
|
366
|
+
else if (char === '}' || char === ')' || char === ']') {
|
|
367
|
+
if (depth === 0) return i
|
|
368
|
+
depth--
|
|
369
|
+
} else if (depth === 0 && (char === ';' || char === '\n')) {
|
|
370
|
+
// A newline only ends the statement when the next line does not continue it.
|
|
371
|
+
if (char === ';') return i
|
|
372
|
+
const rest = masked.slice(i + 1).trimStart()
|
|
373
|
+
if (!/^[.?:+\-*/%&|=,)]/.test(rest) && !/[=,(+\-*/%&|?:]\s*$/.test(masked.slice(from, i))) return i
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return masked.length
|
|
377
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire types shared by the dev-server API (`devtools/server`) and the in-page
|
|
3
|
+
* overlay (`devtools/client`). Everything here must stay JSON-serializable.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const API_BASE = '/__beast-devtools/api'
|
|
7
|
+
export const SOURCE_CHANGED_EVENT = 'beast-devtools:source-changed'
|
|
8
|
+
|
|
9
|
+
export interface AnalyzerSettings {
|
|
10
|
+
/** Template nesting depth (0 = component root) above which a line counts as too deep. */
|
|
11
|
+
depthLimit: number
|
|
12
|
+
/** Smallest section, in source lines, worth extracting into a component. */
|
|
13
|
+
minLines: number
|
|
14
|
+
/** Sections at least this long are extracted into their own `.btsx` file by default. */
|
|
15
|
+
fileLines: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_SETTINGS: AnalyzerSettings = { depthLimit: 5, minLines: 8, fileLines: 30 }
|
|
19
|
+
|
|
20
|
+
export type Severity = 'info' | 'warning' | 'critical'
|
|
21
|
+
|
|
22
|
+
export interface LineRange {
|
|
23
|
+
startLine: number
|
|
24
|
+
endLine: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SuggestedProp {
|
|
28
|
+
name: string
|
|
29
|
+
type: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RefactorSuggestion extends LineRange {
|
|
33
|
+
id: string
|
|
34
|
+
kind: 'extract' | 'duplicate'
|
|
35
|
+
severity: Severity
|
|
36
|
+
/** Component (default or local `component`) the section currently lives in. */
|
|
37
|
+
host: string
|
|
38
|
+
/** Proposed PascalCase name for the new local component. */
|
|
39
|
+
name: string
|
|
40
|
+
/** Selector-style label of the section root, e.g. `section#workflow-panel`. */
|
|
41
|
+
label: string
|
|
42
|
+
reason: string
|
|
43
|
+
lines: number
|
|
44
|
+
/** Nesting depth of the section root inside its host. */
|
|
45
|
+
depth: number
|
|
46
|
+
/** Deepest nesting depth reached inside the section, measured in the host. */
|
|
47
|
+
reach: number
|
|
48
|
+
props: SuggestedProp[]
|
|
49
|
+
/** Ready-to-paste `component` declaration. */
|
|
50
|
+
snippet: string
|
|
51
|
+
/** Call site that replaces the section. */
|
|
52
|
+
usage: string
|
|
53
|
+
/** Line before which the `component` declaration should be inserted. */
|
|
54
|
+
insertBeforeLine: number
|
|
55
|
+
/** Every structurally identical copy (duplicate suggestions only). */
|
|
56
|
+
occurrences: LineRange[]
|
|
57
|
+
/** Identifiers and component tags the section references, for moving it to another file. */
|
|
58
|
+
references: string[]
|
|
59
|
+
autoApply: AutoApply
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type RefactorTarget = 'inline' | 'file'
|
|
63
|
+
|
|
64
|
+
/** Whether the suggestion can be applied automatically, and where by default. */
|
|
65
|
+
export interface AutoApply {
|
|
66
|
+
/** Default target: sections of `fileLines` or more go to their own file. */
|
|
67
|
+
target: RefactorTarget
|
|
68
|
+
/** Why the suggestion cannot be applied at all, if so. */
|
|
69
|
+
blocked: string | null
|
|
70
|
+
/** Why the section cannot move to its own file, if so. */
|
|
71
|
+
fileBlocked: string | null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ComponentMetrics {
|
|
75
|
+
name: string
|
|
76
|
+
line: number
|
|
77
|
+
templateLines: number
|
|
78
|
+
maxDepth: number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface FileAnalysis {
|
|
82
|
+
settings: AnalyzerSettings
|
|
83
|
+
/** Detected indentation step in spaces. */
|
|
84
|
+
indentUnit: number
|
|
85
|
+
/** Structural nesting depth per source line (index = line - 1); `null` outside templates. */
|
|
86
|
+
lineDepths: Array<number | null>
|
|
87
|
+
maxDepth: number
|
|
88
|
+
averageDepth: number
|
|
89
|
+
templateLines: number
|
|
90
|
+
/** Template lines deeper than `settings.depthLimit`. */
|
|
91
|
+
deepLines: number
|
|
92
|
+
/** Number of template lines at each depth (index = depth). */
|
|
93
|
+
histogram: number[]
|
|
94
|
+
components: ComponentMetrics[]
|
|
95
|
+
suggestions: RefactorSuggestion[]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface DiagnosticInfo {
|
|
99
|
+
code: string
|
|
100
|
+
severity: 'error' | 'warning'
|
|
101
|
+
message: string
|
|
102
|
+
line: number
|
|
103
|
+
column: number
|
|
104
|
+
endLine: number
|
|
105
|
+
endColumn: number
|
|
106
|
+
hint?: string
|
|
107
|
+
/** Human-readable rendering with a caret under the failing source. */
|
|
108
|
+
formatted: string
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type CompiledOutput =
|
|
112
|
+
| {
|
|
113
|
+
ok: true
|
|
114
|
+
tsrx: string
|
|
115
|
+
/** BTSX line (index = line - 1) for each generated TSRX line; `null` when unmapped. */
|
|
116
|
+
tsrxToBtsx: Array<number | null>
|
|
117
|
+
/** Generated TSRX lines for each BTSX line (index = line - 1). */
|
|
118
|
+
btsxToTsrx: number[][]
|
|
119
|
+
diagnostics: DiagnosticInfo[]
|
|
120
|
+
}
|
|
121
|
+
| { ok: false; error: DiagnosticInfo }
|
|
122
|
+
|
|
123
|
+
export interface FileSummary {
|
|
124
|
+
path: string
|
|
125
|
+
lines: number
|
|
126
|
+
maxDepth: number | null
|
|
127
|
+
deepLines: number
|
|
128
|
+
suggestions: number
|
|
129
|
+
error: string | null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface HookBinding {
|
|
133
|
+
/** Hook call, e.g. `useState`. */
|
|
134
|
+
hook: string
|
|
135
|
+
/** Names bound by the declaration, e.g. `['activeId', 'setActiveId']`. */
|
|
136
|
+
names: string[]
|
|
137
|
+
line: number
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface ComponentLocation {
|
|
141
|
+
name: string
|
|
142
|
+
path: string
|
|
143
|
+
absolutePath: string
|
|
144
|
+
line: number
|
|
145
|
+
column: number
|
|
146
|
+
local: boolean
|
|
147
|
+
/** Value-bearing hooks declared in setup, in call order. */
|
|
148
|
+
hooks: HookBinding[]
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface ProjectReport {
|
|
152
|
+
root: string
|
|
153
|
+
settings: AnalyzerSettings
|
|
154
|
+
files: FileSummary[]
|
|
155
|
+
components: ComponentLocation[]
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface FileReport {
|
|
159
|
+
path: string
|
|
160
|
+
absolutePath: string
|
|
161
|
+
/** Content hash; refactors are refused when the file changed since it was analyzed. */
|
|
162
|
+
hash: string
|
|
163
|
+
source: string
|
|
164
|
+
compiled: CompiledOutput
|
|
165
|
+
analysis: FileAnalysis | null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface ApplyRequest {
|
|
169
|
+
path: string
|
|
170
|
+
hash: string
|
|
171
|
+
settings: AnalyzerSettings
|
|
172
|
+
suggestionId: string
|
|
173
|
+
target: RefactorTarget
|
|
174
|
+
/** Plan and validate without writing. */
|
|
175
|
+
dryRun: boolean
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface DiffLine {
|
|
179
|
+
type: 'context' | 'add' | 'remove'
|
|
180
|
+
text: string
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface DiffHunk {
|
|
184
|
+
oldStart: number
|
|
185
|
+
newStart: number
|
|
186
|
+
lines: DiffLine[]
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface PlannedFile {
|
|
190
|
+
path: string
|
|
191
|
+
action: 'create' | 'edit'
|
|
192
|
+
added: number
|
|
193
|
+
removed: number
|
|
194
|
+
hunks: DiffHunk[]
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface ApplyResult {
|
|
198
|
+
/** Undo handle; present once the change has been written. */
|
|
199
|
+
undoId: string | null
|
|
200
|
+
/** Name of the extracted component. */
|
|
201
|
+
component: string
|
|
202
|
+
summary: string
|
|
203
|
+
files: PlannedFile[]
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface UndoResult {
|
|
207
|
+
summary: string
|
|
208
|
+
}
|