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,224 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { compileBeastResult, componentNameFromPath } from 'beast-tsrx'
|
|
6
|
+
import { createOctaneCompiler } from 'octane/compiler/bundler'
|
|
7
|
+
import type { AnalyzerSettings, RefactorSuggestion, RefactorTarget } from '../shared/types.ts'
|
|
8
|
+
import { analyzeDocument } from './analyze.ts'
|
|
9
|
+
import { BeastProject } from './project.ts'
|
|
10
|
+
import { planRefactor, RefactorError, type RefactorPlan } from './refactor.ts'
|
|
11
|
+
|
|
12
|
+
const APP = readFileSync(new URL('../test/fixtures/App.btsx', import.meta.url), 'utf8')
|
|
13
|
+
const SETTINGS: AnalyzerSettings = { depthLimit: 5, minLines: 8, fileLines: 30 }
|
|
14
|
+
const octane = createOctaneCompiler({ root: process.cwd(), environment: 'client', hmr: false, dev: true })
|
|
15
|
+
|
|
16
|
+
function suggestionsFor(source: string, name: string, settings: Partial<AnalyzerSettings> = {}) {
|
|
17
|
+
const absolutePath = `/project/src/${name}.btsx`
|
|
18
|
+
const { ast } = compileBeastResult(source, { filename: absolutePath, componentName: name })
|
|
19
|
+
const analysis = analyzeDocument(ast, source, name, { ...SETTINGS, ...settings })
|
|
20
|
+
return { absolutePath, ast, suggestions: analysis.suggestions }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function plan(
|
|
24
|
+
source: string,
|
|
25
|
+
host: string,
|
|
26
|
+
pick: (suggestion: RefactorSuggestion) => boolean,
|
|
27
|
+
target: RefactorTarget,
|
|
28
|
+
options: { settings?: Partial<AnalyzerSettings>; exists?: (path: string) => boolean } = {},
|
|
29
|
+
): RefactorPlan {
|
|
30
|
+
const { absolutePath, ast, suggestions } = suggestionsFor(source, host, options.settings)
|
|
31
|
+
const suggestion = suggestions.find(pick)
|
|
32
|
+
if (suggestion === undefined) throw new Error(`No matching suggestion among ${suggestions.map((s) => s.name)}`)
|
|
33
|
+
return planRefactor({ absolutePath, source, document: ast, suggestion, target, exists: options.exists ?? (() => false) })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function expectCompiles(path: string, source: string): void {
|
|
37
|
+
const { code } = compileBeastResult(source, { filename: path, componentName: componentNameFromPath(path) })
|
|
38
|
+
expect(octane.transform(code, path.replace(/\.btsx$/, '.tsrx'), { environment: 'client', dev: true })).not.toBeNull()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('hoisting into the same file', () => {
|
|
42
|
+
test('inserts a local component above props and replaces the section with a call', () => {
|
|
43
|
+
const result = plan(APP, 'App', (s) => s.name === 'AppHeader', 'inline')
|
|
44
|
+
expect(result.changes).toHaveLength(1)
|
|
45
|
+
const after = result.changes[0]!.after
|
|
46
|
+
expect(after.indexOf('component AppHeader')).toBeLessThan(after.indexOf('props { docsUrl }: Props'))
|
|
47
|
+
expect(after).toContain(' AppHeader(activeId={activeId} setActiveId={setActiveId} setCopiedSide={setCopiedSide})')
|
|
48
|
+
expect(after.split('\n').filter((line) => line.includes("aria-controls=\"workflow-panel\""))).toHaveLength(1)
|
|
49
|
+
expectCompiles('/project/src/App.btsx', after)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('replaces every copy of an identical block', () => {
|
|
53
|
+
const block = [' .card', ' h2 Title', ' p Body', ' footer', ' small Fine print']
|
|
54
|
+
const source = ['section', ' .left', ...block, ' .right', ...block, ''].join('\n')
|
|
55
|
+
const result = plan(source, 'Cards', (s) => s.kind === 'duplicate' && s.autoApply.blocked === null, 'inline', { settings: { minLines: 4 } })
|
|
56
|
+
const after = result.changes[0]!.after
|
|
57
|
+
expect(after.match(/^ {4}Card$/gm)).toHaveLength(2)
|
|
58
|
+
expect(after.startsWith('component Card\n .card\n')).toBe(true)
|
|
59
|
+
expectCompiles('/project/src/Cards.btsx', after)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('refuses copies that differ, since one call would change behavior', () => {
|
|
63
|
+
expect(() => plan(APP, 'App', (s) => s.kind === 'duplicate', 'inline')).toThrow(RefactorError)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('refuses components with scoped styles', () => {
|
|
67
|
+
const source = [
|
|
68
|
+
'section',
|
|
69
|
+
' .a',
|
|
70
|
+
' .b',
|
|
71
|
+
' .c',
|
|
72
|
+
' p One',
|
|
73
|
+
' p Two',
|
|
74
|
+
' p Three',
|
|
75
|
+
' style',
|
|
76
|
+
' .c { color: red; }',
|
|
77
|
+
'',
|
|
78
|
+
].join('\n')
|
|
79
|
+
expect(() => plan(source, 'Styled', (s) => s.kind === 'extract', 'inline', { settings: { depthLimit: 2, minLines: 3 } })).toThrow(
|
|
80
|
+
/scoped style/,
|
|
81
|
+
)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('moving to a new file', () => {
|
|
86
|
+
const settings = { fileLines: 18 }
|
|
87
|
+
|
|
88
|
+
test('creates a sibling file that imports the types and values it uses', () => {
|
|
89
|
+
const result = plan(APP, 'App', (s) => s.name === 'AppHeader', 'file', { settings })
|
|
90
|
+
const [source, created] = result.changes as [RefactorPlan['changes'][0], RefactorPlan['changes'][0]]
|
|
91
|
+
expect(created.absolutePath).toBe('/project/src/AppHeader.btsx')
|
|
92
|
+
expect(created.before).toBeNull()
|
|
93
|
+
expect(created.after.split('\n').slice(0, 3)).toEqual([
|
|
94
|
+
"import { panels } from './App.btsx'",
|
|
95
|
+
"import type { PanelId, PanelSide } from './App.btsx'",
|
|
96
|
+
'props { activeId, setActiveId, setCopiedSide }: { activeId: PanelId; setActiveId: (value: PanelId) => void; setCopiedSide: (value: PanelSide | null) => void }',
|
|
97
|
+
])
|
|
98
|
+
expect(source.after).toContain("import AppHeader from './AppHeader.btsx'")
|
|
99
|
+
expect(source.after).toContain(" export type PanelId = 'language' | 'integration' | 'skills'")
|
|
100
|
+
expect(source.after).toContain(' export const panels: Panel[] = [')
|
|
101
|
+
expect(source.after).not.toContain('export type LeftPanel')
|
|
102
|
+
expectCompiles(source.absolutePath, source.after)
|
|
103
|
+
expectCompiles(created.absolutePath, created.after)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('copies the imports the section needs and keeps module directives', () => {
|
|
107
|
+
const source = [
|
|
108
|
+
'module "use strong";',
|
|
109
|
+
"import Badge from './Badge.btsx'",
|
|
110
|
+
"import { useState } from 'octane'",
|
|
111
|
+
'props { groups }: { groups: { id: string; items: string[] }[] }',
|
|
112
|
+
'.list',
|
|
113
|
+
' each group in groups',
|
|
114
|
+
' .group(key={group.id})',
|
|
115
|
+
' Badge(tone="green") OK',
|
|
116
|
+
' ul',
|
|
117
|
+
' each item in group.items',
|
|
118
|
+
' li #{item}',
|
|
119
|
+
'',
|
|
120
|
+
].join('\n')
|
|
121
|
+
const result = plan(source, 'Status', (s) => s.name === 'Group', 'file', { settings: { depthLimit: 3, minLines: 4 } })
|
|
122
|
+
const [edited, created] = result.changes
|
|
123
|
+
expect(created!.after.split('\n').slice(0, 4)).toEqual([
|
|
124
|
+
'module',
|
|
125
|
+
' "use strong";',
|
|
126
|
+
"import Badge from './Badge.btsx'",
|
|
127
|
+
'props { group }: { group: any }',
|
|
128
|
+
])
|
|
129
|
+
expect(edited!.after).toContain(' Group(key={group.id} group={group})')
|
|
130
|
+
expect(edited!.after.split('\n')[3]).toBe("import Group from './Group.btsx'")
|
|
131
|
+
expectCompiles('/project/src/Status.btsx', edited!.after)
|
|
132
|
+
expectCompiles('/project/src/Group.btsx', created!.after)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('picks a free file name', () => {
|
|
136
|
+
const taken = new Set(['/project/src/AppHeader.btsx'])
|
|
137
|
+
const result = plan(APP, 'App', (s) => s.name === 'AppHeader', 'file', { settings, exists: (path) => taken.has(path) })
|
|
138
|
+
expect(result.component).toBe('AppHeader2')
|
|
139
|
+
expect(result.changes[1]!.absolutePath).toBe('/project/src/AppHeader2.btsx')
|
|
140
|
+
expect(result.changes[0]!.after).toContain('AppHeader2(activeId={activeId}')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('keeps sections that use a file-local component in the file', () => {
|
|
144
|
+
const source = [
|
|
145
|
+
'component Badge',
|
|
146
|
+
' span.badge ok',
|
|
147
|
+
'section',
|
|
148
|
+
' .a',
|
|
149
|
+
' .b',
|
|
150
|
+
' .c',
|
|
151
|
+
' Badge',
|
|
152
|
+
' p Two',
|
|
153
|
+
' p Three',
|
|
154
|
+
'',
|
|
155
|
+
].join('\n')
|
|
156
|
+
const [suggestion] = suggestionsFor(source, 'Local', { depthLimit: 2, minLines: 3 }).suggestions
|
|
157
|
+
expect(suggestion?.autoApply.fileBlocked).toContain('Badge')
|
|
158
|
+
expect(() => plan(source, 'Local', (s) => s.kind === 'extract', 'file', { settings: { depthLimit: 2, minLines: 3 } })).toThrow(
|
|
159
|
+
/Badge/,
|
|
160
|
+
)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('defaults large sections to their own file', () => {
|
|
164
|
+
const { suggestions } = suggestionsFor(APP, 'App', { fileLines: 20 })
|
|
165
|
+
expect(suggestions.find((s) => s.name === 'AppHeader')?.autoApply.target).toBe('file')
|
|
166
|
+
expect(suggestions.find((s) => s.name === 'RightArticle')?.autoApply.target).toBe('inline')
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
describe('applying through the project', () => {
|
|
171
|
+
let root: string | null = null
|
|
172
|
+
afterEach(() => {
|
|
173
|
+
if (root !== null) rmSync(root, { recursive: true, force: true })
|
|
174
|
+
root = null
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
function project() {
|
|
178
|
+
root = mkdtempSync(join(tmpdir(), 'beast-devtools-'))
|
|
179
|
+
mkdirSync(join(root, 'src'))
|
|
180
|
+
writeFileSync(join(root, 'src/App.btsx'), APP)
|
|
181
|
+
return new BeastProject({ root, include: ['src'], exclude: [] })
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
test('previews, writes, and undoes a move to a new file', () => {
|
|
185
|
+
const beast = project()
|
|
186
|
+
const settings = { ...SETTINGS, fileLines: 18 }
|
|
187
|
+
const report = beast.file('src/App.btsx', settings)!
|
|
188
|
+
const suggestion = report.analysis!.suggestions.find((s) => s.name === 'AppHeader')!
|
|
189
|
+
const request = { path: 'src/App.btsx', hash: report.hash, settings, suggestionId: suggestion.id, target: 'file' as const }
|
|
190
|
+
|
|
191
|
+
const preview = beast.apply({ ...request, dryRun: true })
|
|
192
|
+
expect(preview.undoId).toBeNull()
|
|
193
|
+
expect(preview.files.map((f) => [f.path, f.action])).toEqual([
|
|
194
|
+
['src/App.btsx', 'edit'],
|
|
195
|
+
['src/AppHeader.btsx', 'create'],
|
|
196
|
+
])
|
|
197
|
+
expect(existsSync(join(root!, 'src/AppHeader.btsx'))).toBe(false)
|
|
198
|
+
|
|
199
|
+
const applied = beast.apply({ ...request, dryRun: false })
|
|
200
|
+
expect(existsSync(join(root!, 'src/AppHeader.btsx'))).toBe(true)
|
|
201
|
+
expect(readFileSync(join(root!, 'src/App.btsx'), 'utf8')).toContain('AppHeader(activeId={activeId}')
|
|
202
|
+
expect(() => beast.apply({ ...request, dryRun: true })).toThrow(/changed since it was analyzed/)
|
|
203
|
+
|
|
204
|
+
beast.undo(applied.undoId!)
|
|
205
|
+
expect(readFileSync(join(root!, 'src/App.btsx'), 'utf8')).toBe(APP)
|
|
206
|
+
expect(existsSync(join(root!, 'src/AppHeader.btsx'))).toBe(false)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('refuses to undo over later edits', () => {
|
|
210
|
+
const beast = project()
|
|
211
|
+
const report = beast.file('src/App.btsx', SETTINGS)!
|
|
212
|
+
const suggestion = report.analysis!.suggestions.find((s) => s.name === 'RightArticle')!
|
|
213
|
+
const applied = beast.apply({
|
|
214
|
+
path: 'src/App.btsx',
|
|
215
|
+
hash: report.hash,
|
|
216
|
+
settings: SETTINGS,
|
|
217
|
+
suggestionId: suggestion.id,
|
|
218
|
+
target: 'inline',
|
|
219
|
+
dryRun: false,
|
|
220
|
+
})
|
|
221
|
+
writeFileSync(join(root!, 'src/App.btsx'), `${readFileSync(join(root!, 'src/App.btsx'), 'utf8')}\n// edited`)
|
|
222
|
+
expect(() => beast.undo(applied.undoId!)).toThrow(/edited after the refactor/)
|
|
223
|
+
})
|
|
224
|
+
})
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { basename, dirname, join } from 'node:path'
|
|
2
|
+
import type { BeastDocument, ModuleDeclaration } from 'beast-tsrx'
|
|
3
|
+
import type { LineRange, RefactorSuggestion, RefactorTarget } from '../shared/types.js'
|
|
4
|
+
import { parseImport, renderImport, topLevelDeclarations, type Declaration, type ImportSpecifier } from './source-scan.js'
|
|
5
|
+
|
|
6
|
+
/** A refusal with an HTTP status: 409 for stale input, 422 for refactors that cannot be done safely. */
|
|
7
|
+
export class RefactorError extends Error {
|
|
8
|
+
constructor(
|
|
9
|
+
message: string,
|
|
10
|
+
readonly status: 409 | 422,
|
|
11
|
+
) {
|
|
12
|
+
super(message)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface FileChange {
|
|
17
|
+
absolutePath: string
|
|
18
|
+
/** Previous content, or null when the file is created. */
|
|
19
|
+
before: string | null
|
|
20
|
+
after: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RefactorPlan {
|
|
24
|
+
component: string
|
|
25
|
+
summary: string
|
|
26
|
+
changes: FileChange[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PlanInput {
|
|
30
|
+
absolutePath: string
|
|
31
|
+
source: string
|
|
32
|
+
document: BeastDocument
|
|
33
|
+
suggestion: RefactorSuggestion
|
|
34
|
+
target: RefactorTarget
|
|
35
|
+
exists: (absolutePath: string) => boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface LineEdit {
|
|
39
|
+
/** 1-based line where the edit starts; insertions go before it. */
|
|
40
|
+
start: number
|
|
41
|
+
deleteCount: number
|
|
42
|
+
insert: string[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Turn a refactor suggestion into concrete file contents, either hoisting the
|
|
47
|
+
* section into a local `component` of the same file or moving it to a sibling
|
|
48
|
+
* `.btsx` file. The caller validates and writes the result.
|
|
49
|
+
*/
|
|
50
|
+
export function planRefactor(input: PlanInput): RefactorPlan {
|
|
51
|
+
const { suggestion, target } = input
|
|
52
|
+
if (suggestion.autoApply.blocked !== null) throw new RefactorError(suggestion.autoApply.blocked, 422)
|
|
53
|
+
if (target === 'file' && suggestion.autoApply.fileBlocked !== null) {
|
|
54
|
+
throw new RefactorError(suggestion.autoApply.fileBlocked, 422)
|
|
55
|
+
}
|
|
56
|
+
return target === 'inline' ? planInline(input) : planFile(input)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function planInline({ absolutePath, source, suggestion }: PlanInput): RefactorPlan {
|
|
60
|
+
const lines = source.split('\n')
|
|
61
|
+
const edits: LineEdit[] = [
|
|
62
|
+
...replaceOccurrences(lines, suggestion, suggestion.name),
|
|
63
|
+
{ start: suggestion.insertBeforeLine, deleteCount: 0, insert: [...suggestion.snippet.split('\n'), ''] },
|
|
64
|
+
]
|
|
65
|
+
return {
|
|
66
|
+
component: suggestion.name,
|
|
67
|
+
summary: `Hoisted ${suggestion.name} into ${basename(absolutePath)}`,
|
|
68
|
+
changes: [{ absolutePath, before: source, after: applyEdits(lines, edits).join('\n') }],
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function planFile({ absolutePath, source, document, suggestion, exists }: PlanInput): RefactorPlan {
|
|
73
|
+
const lines = source.split('\n')
|
|
74
|
+
const dir = dirname(absolutePath)
|
|
75
|
+
const name = availableName(suggestion.name, source, (candidate) => exists(join(dir, `${candidate}.btsx`)))
|
|
76
|
+
const newPath = join(dir, `${name}.btsx`)
|
|
77
|
+
const references = new Set(suggestion.references)
|
|
78
|
+
suggestion.props.forEach((prop) => references.delete(prop.name))
|
|
79
|
+
|
|
80
|
+
// Imports the section relies on are copied; both files sit in one directory,
|
|
81
|
+
// so relative specifiers stay valid.
|
|
82
|
+
const header: string[] = []
|
|
83
|
+
const imports = document.declarations.filter((d) => d.kind === 'import')
|
|
84
|
+
for (const declaration of imports) {
|
|
85
|
+
const parsed = parseImport(declaration.code)
|
|
86
|
+
if (parsed === null) continue
|
|
87
|
+
const used = parsed.specifiers.filter((specifier) => references.has(specifier.local))
|
|
88
|
+
if (used.length > 0) header.push(...renderImport(parsed, used))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Module-level types and values the section uses are exported from the
|
|
92
|
+
// source and imported back. Type imports are erased, so only values create
|
|
93
|
+
// a (render-time, cycle-safe) runtime import.
|
|
94
|
+
const exported = new Map<string, { declaration: Declaration; module: ModuleDeclaration }>()
|
|
95
|
+
const directives: string[] = []
|
|
96
|
+
for (const module of document.declarations) {
|
|
97
|
+
if (module.kind !== 'module') continue
|
|
98
|
+
directives.push(...leadingDirectives(module.code))
|
|
99
|
+
for (const declaration of topLevelDeclarations(module.code)) {
|
|
100
|
+
for (const binding of declaration.names) {
|
|
101
|
+
if (references.has(binding) && !exported.has(binding)) exported.set(binding, { declaration, module })
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const quote = imports.length > 0 ? (parseImport(imports[0]!.code)?.quote ?? "'") : "'"
|
|
106
|
+
const sourceSpecifier = `./${basename(absolutePath)}`
|
|
107
|
+
const specifiers: ImportSpecifier[] = [...exported].map(([binding, { declaration }]) => ({
|
|
108
|
+
local: binding,
|
|
109
|
+
imported: binding,
|
|
110
|
+
typeOnly: declaration.kind === 'type',
|
|
111
|
+
}))
|
|
112
|
+
if (specifiers.length > 0) {
|
|
113
|
+
header.push(...renderImport({ source: sourceSpecifier, quote, typeOnly: false, specifiers }, specifiers))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const [, ...rest] = suggestion.snippet.split('\n')
|
|
117
|
+
const body = rest.map((line) => line.replace(/^ {2}/, ''))
|
|
118
|
+
const content = [
|
|
119
|
+
...(directives.length > 0 ? ['module', ...directives.map((directive) => ` ${directive}`)] : []),
|
|
120
|
+
...header,
|
|
121
|
+
...(body[0]?.startsWith('props ') ? [body.shift()!] : []),
|
|
122
|
+
...(header.length > 0 || directives.length > 0 ? [''] : []),
|
|
123
|
+
...body,
|
|
124
|
+
'',
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
const edits: LineEdit[] = [
|
|
128
|
+
...replaceOccurrences(lines, suggestion, name),
|
|
129
|
+
...exportEdits(lines, [...exported.values()]),
|
|
130
|
+
{
|
|
131
|
+
start: importInsertLine(document),
|
|
132
|
+
deleteCount: 0,
|
|
133
|
+
insert: [`import ${name} from ${quote}./${name}.btsx${quote}`],
|
|
134
|
+
},
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
component: name,
|
|
139
|
+
summary: `Moved ${name} to ${name}.btsx`,
|
|
140
|
+
changes: [
|
|
141
|
+
{ absolutePath, before: source, after: applyEdits(lines, edits).join('\n') },
|
|
142
|
+
{ absolutePath: newPath, before: null, after: content.join('\n') },
|
|
143
|
+
],
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Replace every occurrence with a call, keeping each occurrence's indentation. */
|
|
148
|
+
function replaceOccurrences(lines: readonly string[], suggestion: RefactorSuggestion, name: string): LineEdit[] {
|
|
149
|
+
const call = suggestion.usage.trimStart().replace(suggestion.name, name)
|
|
150
|
+
return suggestion.occurrences.map((range: LineRange) => {
|
|
151
|
+
const indent = /^ */.exec(lines[range.startLine - 1] ?? '')![0]
|
|
152
|
+
return { start: range.startLine, deleteCount: range.endLine - range.startLine + 1, insert: [indent + call] }
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Add `export` to each module declaration another file now imports. */
|
|
157
|
+
function exportEdits(
|
|
158
|
+
lines: readonly string[],
|
|
159
|
+
targets: ReadonlyArray<{ declaration: Declaration; module: ModuleDeclaration }>,
|
|
160
|
+
): LineEdit[] {
|
|
161
|
+
const byLine = new Map<number, Declaration[]>()
|
|
162
|
+
const seen = new Set<Declaration>()
|
|
163
|
+
for (const { declaration, module } of targets) {
|
|
164
|
+
// One `const { a, b } = …` can supply several imported bindings.
|
|
165
|
+
if (declaration.exported || seen.has(declaration)) continue
|
|
166
|
+
seen.add(declaration)
|
|
167
|
+
const line = module.codeStart.line + module.code.slice(0, declaration.offset).split('\n').length - 1
|
|
168
|
+
byLine.set(line, [...(byLine.get(line) ?? []), declaration])
|
|
169
|
+
}
|
|
170
|
+
return [...byLine].map(([line, declarations]) => {
|
|
171
|
+
let text = lines[line - 1] ?? ''
|
|
172
|
+
for (const declaration of declarations) {
|
|
173
|
+
const keyword = declaration.keyword.replace(/\s+/g, '\\s+')
|
|
174
|
+
const pattern = new RegExp(`(^|[^\\w$.])(${keyword}\\s+)`)
|
|
175
|
+
if (!pattern.test(text)) throw new RefactorError(`Could not export ${declaration.names.join(', ')} on line ${line}.`, 422)
|
|
176
|
+
text = text.replace(pattern, '$1export $2')
|
|
177
|
+
}
|
|
178
|
+
return { start: line, deleteCount: 1, insert: [text] }
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** After the last import, or after a leading directive-only `module`, or at the top. */
|
|
183
|
+
function importInsertLine(document: BeastDocument): number {
|
|
184
|
+
const imports = document.declarations.filter((d) => d.kind === 'import')
|
|
185
|
+
if (imports.length > 0) return Math.max(...imports.map((d) => d.span.end.line)) + 1
|
|
186
|
+
const first = document.declarations[0]
|
|
187
|
+
if (first?.kind === 'module' && leadingDirectives(first.code).length > 0) {
|
|
188
|
+
return first.codeStart.line + first.code.trimEnd().split('\n').length
|
|
189
|
+
}
|
|
190
|
+
return 1
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** `"use strong";`-style directives at the start of a module block. */
|
|
194
|
+
function leadingDirectives(code: string): string[] {
|
|
195
|
+
const directives: string[] = []
|
|
196
|
+
for (const line of code.split('\n')) {
|
|
197
|
+
const text = line.trim()
|
|
198
|
+
if (text === '') continue
|
|
199
|
+
if (!/^(['"])use [\w ]+\1;?$/.test(text)) break
|
|
200
|
+
directives.push(text)
|
|
201
|
+
}
|
|
202
|
+
return directives
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The suggested name, suffixed until it is free as a file and as an identifier. */
|
|
206
|
+
function availableName(base: string, source: string, taken: (name: string) => boolean): string {
|
|
207
|
+
let name = base
|
|
208
|
+
for (let n = 2; taken(name) || (name !== base && new RegExp(`\\b${name}\\b`).test(source)); n++) name = `${base}${n}`
|
|
209
|
+
return name
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function applyEdits(lines: readonly string[], edits: readonly LineEdit[]): string[] {
|
|
213
|
+
// Bottom-up keeps earlier line numbers valid; at one line, rewrites go
|
|
214
|
+
// before insertions so an insertion never lands on a rewritten line.
|
|
215
|
+
const ordered = [...edits].sort((a, b) => b.start - a.start || b.deleteCount - a.deleteCount)
|
|
216
|
+
const next = [...lines]
|
|
217
|
+
let floor = Number.POSITIVE_INFINITY
|
|
218
|
+
for (const edit of ordered) {
|
|
219
|
+
if (edit.start + edit.deleteCount > floor) throw new RefactorError('Refactor edits overlap.', 422)
|
|
220
|
+
next.splice(edit.start - 1, edit.deleteCount, ...edit.insert)
|
|
221
|
+
floor = edit.start
|
|
222
|
+
}
|
|
223
|
+
return next
|
|
224
|
+
}
|