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