@nocturnium/svelte-ide 1.2.1 → 1.4.0
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/README.md +26 -4
- package/dist/components/editor/CustomEditor.svelte +51 -0
- package/dist/components/editor/CustomEditor.svelte.d.ts +6 -1
- package/dist/components/editor/core/apply-extract-plan.d.ts +26 -0
- package/dist/components/editor/core/apply-extract-plan.js +151 -0
- package/dist/components/editor/core/extract-function.js +35 -2
- package/dist/components/editor/core/extract-variable.d.ts +48 -0
- package/dist/components/editor/core/extract-variable.js +457 -0
- package/dist/components/editor/core/index.d.ts +3 -0
- package/dist/components/editor/core/index.js +3 -0
- package/dist/components/editor/core/organize-imports.d.ts +38 -0
- package/dist/components/editor/core/organize-imports.js +249 -0
- package/dist/components/editor/core/state.d.ts +19 -0
- package/dist/components/editor/core/state.js +24 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -8,9 +8,10 @@ framework.
|
|
|
8
8
|
[](./LICENSE)
|
|
9
9
|
[](https://svelte.dev)
|
|
10
10
|
|
|
11
|
-
Built from scratch with Svelte 5 runes and zero runtime
|
|
12
|
-
single `<CustomEditor>` for a textarea-grade
|
|
13
|
-
LSP, collaboration, AI, and plugin pieces into a
|
|
11
|
+
Built from scratch with Svelte 5 runes and **zero required runtime dependencies
|
|
12
|
+
beyond the Svelte 5 peer**. Use a single `<CustomEditor>` for a textarea-grade
|
|
13
|
+
upgrade, or compose the editor, LSP, collaboration, AI, and plugin pieces into a
|
|
14
|
+
full IDE experience.
|
|
14
15
|
|
|
15
16
|
---
|
|
16
17
|
|
|
@@ -26,7 +27,9 @@ LSP, collaboration, AI, and plugin pieces into a full IDE experience.
|
|
|
26
27
|
- **AI panel & agent presence** layers for assistant UI and presence patterns.
|
|
27
28
|
- **Plugin system** with a proposal-based lifecycle (bring your own backend).
|
|
28
29
|
- **Themeable** — every color/size is a CSS custom property you can override.
|
|
29
|
-
- **
|
|
30
|
+
- **Minimal footprint** — no required runtime dependencies beyond the Svelte 5
|
|
31
|
+
peer; styling is plain CSS variables (no CSS framework). The Yjs collaboration
|
|
32
|
+
stack is the only other runtime dependency, and it's an optional peer.
|
|
30
33
|
|
|
31
34
|
## Install
|
|
32
35
|
|
|
@@ -43,6 +46,25 @@ Collaboration is optional and tree-shakeable — install these only if you use t
|
|
|
43
46
|
npm install yjs y-websocket y-protocols
|
|
44
47
|
```
|
|
45
48
|
|
|
49
|
+
## Dependencies
|
|
50
|
+
|
|
51
|
+
The published package carries **no top-level `dependencies`** — it ships with
|
|
52
|
+
zero required runtime dependencies beyond the Svelte 5 peer.
|
|
53
|
+
|
|
54
|
+
- **`svelte` `^5.0.0`** — the one required peer. Your app already has it.
|
|
55
|
+
- **`yjs`, `y-protocols`, `y-websocket`** — _optional_ peers, marked
|
|
56
|
+
`optional: true`. They are imported only by the realtime-collaboration code
|
|
57
|
+
(`<CollaborativeEditor>` and the `./crdt` entry), so they're pulled in only if
|
|
58
|
+
you actually use collaboration. The rest of the library never touches them.
|
|
59
|
+
- **No CSS framework at runtime.** Styling is plain CSS custom properties
|
|
60
|
+
shipped in `@nocturnium/svelte-ide/theme.css` — no Tailwind, no `@apply`, no
|
|
61
|
+
utility-class runtime. (Tailwind is used only to build this repo's demo site.)
|
|
62
|
+
|
|
63
|
+
Everything else in `package.json` — Vite, ESLint, TypeScript, Vitest,
|
|
64
|
+
Playwright, semantic-release, Tailwind, Prettier, and the rest — is a
|
|
65
|
+
`devDependency` used to build, test, and lint the library. None of it is
|
|
66
|
+
published: only the `dist/` folder ships (`"files": ["dist"]`).
|
|
67
|
+
|
|
46
68
|
## Quick start
|
|
47
69
|
|
|
48
70
|
Import the component **and** the theme stylesheet (components are unstyled
|
|
@@ -7,7 +7,13 @@
|
|
|
7
7
|
createKeyboardHandler,
|
|
8
8
|
createDefaultKeybindings,
|
|
9
9
|
createFoldManager,
|
|
10
|
+
extractFunctionAt,
|
|
11
|
+
extractVariableAt,
|
|
12
|
+
organizeImportsAt,
|
|
10
13
|
type EditorState,
|
|
14
|
+
type ExtractFunctionResult,
|
|
15
|
+
type ExtractVariableResult,
|
|
16
|
+
type OrganizeImportsResult,
|
|
11
17
|
type Selection,
|
|
12
18
|
type SearchMatch,
|
|
13
19
|
type FoldManager,
|
|
@@ -590,6 +596,51 @@
|
|
|
590
596
|
hiddenInput?.focus();
|
|
591
597
|
}
|
|
592
598
|
|
|
599
|
+
/**
|
|
600
|
+
* Extract the current selection into a new function (Track H). Analyzes the
|
|
601
|
+
* current content fresh (not the debounced complexityMetrics, which can lag a
|
|
602
|
+
* recent edit) so the enclosing region is always correct, regardless of the
|
|
603
|
+
* complexityHighlighting prop. The edit is applied as a single undo step; on
|
|
604
|
+
* refusal the editor is untouched. Returns the result so callers can surface
|
|
605
|
+
* the reason.
|
|
606
|
+
*/
|
|
607
|
+
export function extractFunction(): ExtractFunctionResult {
|
|
608
|
+
const metrics = complexityAnalyzer.analyze(editorState.lines, language);
|
|
609
|
+
const result = extractFunctionAt(editorState, metrics);
|
|
610
|
+
announce(result.ok ? 'Extracted function' : result.reason);
|
|
611
|
+
return result;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Extract the current selection into a hoisted `const` (Track H). Single undo
|
|
616
|
+
* step; on refusal the editor is untouched and the reason is returned.
|
|
617
|
+
*/
|
|
618
|
+
export function extractVariable(): ExtractVariableResult {
|
|
619
|
+
const result = extractVariableAt(editorState);
|
|
620
|
+
announce(result.ok ? 'Extracted variable' : result.reason);
|
|
621
|
+
return result;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Sort, de-duplicate, and tidy the leading import block (Track H). Single undo
|
|
626
|
+
* step; on refusal the editor is untouched and the reason is returned.
|
|
627
|
+
*/
|
|
628
|
+
export function organizeImports(): OrganizeImportsResult {
|
|
629
|
+
const result = organizeImportsAt(editorState);
|
|
630
|
+
announce(result.ok ? 'Organized imports' : result.reason);
|
|
631
|
+
return result;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Current primary selection (anchor/head), for callers driving code actions. */
|
|
635
|
+
export function getSelection(): Selection {
|
|
636
|
+
return editorState.selection;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Text covered by the current primary selection ('' when collapsed). */
|
|
640
|
+
export function getSelectedText(): string {
|
|
641
|
+
return editorState.getSelectedText();
|
|
642
|
+
}
|
|
643
|
+
|
|
593
644
|
function handleFoldIndicatorClick(lineNumber: number, e: MouseEvent) {
|
|
594
645
|
e.preventDefault();
|
|
595
646
|
e.stopPropagation();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { EditorPreferences } from '../../types';
|
|
2
|
-
import { type Cursor } from './core';
|
|
2
|
+
import { type ExtractFunctionResult, type ExtractVariableResult, type OrganizeImportsResult, type Selection, type Cursor } from './core';
|
|
3
3
|
import { type ComplexityMetrics, type ComplexityRegion } from './core/complexity-analyzer';
|
|
4
4
|
import { type FoldPreset } from './core/semantic-analyzer';
|
|
5
5
|
import type { AIAwareness } from './core/ai-awareness';
|
|
@@ -38,6 +38,11 @@ declare const CustomEditor: import("svelte").Component<Props, {
|
|
|
38
38
|
unfoldAll: () => void;
|
|
39
39
|
flashComplexityRegion: (region: ComplexityRegion) => void;
|
|
40
40
|
scrollToLine: (line: number, region?: ComplexityRegion) => Promise<void>;
|
|
41
|
+
extractFunction: () => ExtractFunctionResult;
|
|
42
|
+
extractVariable: () => ExtractVariableResult;
|
|
43
|
+
organizeImports: () => OrganizeImportsResult;
|
|
44
|
+
getSelection: () => Selection;
|
|
45
|
+
getSelectedText: () => string;
|
|
41
46
|
}, "content">;
|
|
42
47
|
type CustomEditor = ReturnType<typeof CustomEditor>;
|
|
43
48
|
export default CustomEditor;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { EditorState } from './state';
|
|
2
|
+
import { type ExtractPlan, type ExtractRefusal } from './extract-function';
|
|
3
|
+
import type { ComplexityMetrics, ComplexityRegion } from './complexity-analyzer';
|
|
4
|
+
export type ExtractFunctionResult = {
|
|
5
|
+
ok: true;
|
|
6
|
+
} | ExtractRefusal;
|
|
7
|
+
/**
|
|
8
|
+
* Find the innermost enclosing region whose [startLine, endLine] (0-based,
|
|
9
|
+
* inclusive) contains the block. Prefers `function` regions; ties are broken by
|
|
10
|
+
* the smallest span. Returns undefined when no region encloses the block.
|
|
11
|
+
*/
|
|
12
|
+
export declare function findEnclosingFunctionRegion(metrics: ComplexityMetrics | null, blockStart: number, blockEnd: number): ComplexityRegion | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* Apply a successful ExtractPlan to a live editor as a SINGLE undo step. Edits
|
|
15
|
+
* are issued bottom-up: the new function is inserted AFTER the enclosing
|
|
16
|
+
* function first (it sits below the block, so the block's line numbers stay
|
|
17
|
+
* valid), then the block is replaced by the call at its original indentation.
|
|
18
|
+
*/
|
|
19
|
+
export declare function applyExtractPlan(editor: EditorState, plan: ExtractPlan, blockStart: number, blockEnd: number): void;
|
|
20
|
+
/**
|
|
21
|
+
* Extract the editor's current line selection into a new function. Finds the
|
|
22
|
+
* enclosing function from the complexity metrics, runs the (pure) planner, and
|
|
23
|
+
* either applies the plan or returns the planner's refusal so the caller can
|
|
24
|
+
* surface the reason to the user.
|
|
25
|
+
*/
|
|
26
|
+
export declare function extractFunctionAt(editor: EditorState, metrics: ComplexityMetrics | null): ExtractFunctionResult;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { planExtractFunction } from './extract-function';
|
|
2
|
+
import { tokenize } from '../tokenizer';
|
|
3
|
+
// Tokens after which a `{` opens an object literal (a value position) rather than
|
|
4
|
+
// a statement block.
|
|
5
|
+
const OBJECT_LITERAL_PRECEDERS = new Set([
|
|
6
|
+
'=',
|
|
7
|
+
'(',
|
|
8
|
+
'[',
|
|
9
|
+
',',
|
|
10
|
+
':',
|
|
11
|
+
'return',
|
|
12
|
+
'=>',
|
|
13
|
+
'?',
|
|
14
|
+
'&&',
|
|
15
|
+
'||',
|
|
16
|
+
'??',
|
|
17
|
+
'!'
|
|
18
|
+
]);
|
|
19
|
+
/**
|
|
20
|
+
* Would the new function — inserted as a sibling after `insertAfterLine` — land
|
|
21
|
+
* inside a container where a `function` declaration is NOT valid (a class body,
|
|
22
|
+
* or an object/array literal)? The applier writes a bare `function extracted()`
|
|
23
|
+
* there, which is a SyntaxError, so the orchestration refuses these. Scans the
|
|
24
|
+
* brace nesting through the insertion line, classifying each `{` as a class
|
|
25
|
+
* body, object literal, or statement block.
|
|
26
|
+
*/
|
|
27
|
+
function insertionInNonFunctionContainer(lines, language, insertAfterLine) {
|
|
28
|
+
const source = lines
|
|
29
|
+
.slice(0, insertAfterLine + 1)
|
|
30
|
+
.map((line) => line.text)
|
|
31
|
+
.join('\n');
|
|
32
|
+
const tokens = tokenize(source, language)
|
|
33
|
+
.flatMap((line) => line.tokens)
|
|
34
|
+
.filter((t) => {
|
|
35
|
+
if (t.type === 'text')
|
|
36
|
+
return t.text.trim().length > 0;
|
|
37
|
+
if (t.type === 'comment' || t.type.startsWith('comment.'))
|
|
38
|
+
return false;
|
|
39
|
+
if (t.type === 'string' || t.type.startsWith('string.'))
|
|
40
|
+
return false;
|
|
41
|
+
return true;
|
|
42
|
+
});
|
|
43
|
+
const stack = [];
|
|
44
|
+
let prev = '';
|
|
45
|
+
let pendingClass = false;
|
|
46
|
+
for (const t of tokens) {
|
|
47
|
+
const text = t.text;
|
|
48
|
+
if (text === 'class' && t.type.startsWith('keyword')) {
|
|
49
|
+
pendingClass = true;
|
|
50
|
+
}
|
|
51
|
+
else if (text === '{') {
|
|
52
|
+
stack.push(pendingClass ? 'class' : OBJECT_LITERAL_PRECEDERS.has(prev) ? 'object' : 'block');
|
|
53
|
+
pendingClass = false;
|
|
54
|
+
}
|
|
55
|
+
else if (text === '}') {
|
|
56
|
+
stack.pop();
|
|
57
|
+
}
|
|
58
|
+
else if (text === '[') {
|
|
59
|
+
stack.push('object'); // array/computed literal is not a statement container
|
|
60
|
+
}
|
|
61
|
+
else if (text === ']') {
|
|
62
|
+
stack.pop();
|
|
63
|
+
}
|
|
64
|
+
prev = text;
|
|
65
|
+
}
|
|
66
|
+
const top = stack[stack.length - 1];
|
|
67
|
+
return top === 'object' || top === 'class';
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Find the innermost enclosing region whose [startLine, endLine] (0-based,
|
|
71
|
+
* inclusive) contains the block. Prefers `function` regions; ties are broken by
|
|
72
|
+
* the smallest span. Returns undefined when no region encloses the block.
|
|
73
|
+
*/
|
|
74
|
+
export function findEnclosingFunctionRegion(metrics, blockStart, blockEnd) {
|
|
75
|
+
if (!metrics)
|
|
76
|
+
return undefined;
|
|
77
|
+
const containing = metrics.regions.filter((r) => r.startLine <= blockStart && blockEnd <= r.endLine);
|
|
78
|
+
if (containing.length === 0)
|
|
79
|
+
return undefined;
|
|
80
|
+
const functions = containing.filter((r) => r.type === 'function');
|
|
81
|
+
const pool = functions.length > 0 ? functions : containing;
|
|
82
|
+
return pool.reduce((best, r) => r.endLine - r.startLine < best.endLine - best.startLine ? r : best);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Apply a successful ExtractPlan to a live editor as a SINGLE undo step. Edits
|
|
86
|
+
* are issued bottom-up: the new function is inserted AFTER the enclosing
|
|
87
|
+
* function first (it sits below the block, so the block's line numbers stay
|
|
88
|
+
* valid), then the block is replaced by the call at its original indentation.
|
|
89
|
+
*/
|
|
90
|
+
export function applyExtractPlan(editor, plan, blockStart, blockEnd) {
|
|
91
|
+
// Geometry captured from the pre-edit document.
|
|
92
|
+
const indent = editor.getLine(blockStart)?.text.match(/^[ \t]*/)?.[0] ?? '';
|
|
93
|
+
const insertLine = plan.insertAfterLine;
|
|
94
|
+
const insertCol = editor.getLine(insertLine)?.text.length ?? 0;
|
|
95
|
+
const blockEndCol = editor.getLine(blockEnd)?.text.length ?? 0;
|
|
96
|
+
const callText = indent + plan.callText;
|
|
97
|
+
editor.transact((tx) => {
|
|
98
|
+
// 1. New function after the enclosing function's last line.
|
|
99
|
+
tx.insert({ line: insertLine, column: insertCol }, `\n${plan.functionText}`);
|
|
100
|
+
// 2. Remove the block's text — the lines collapse to one empty line at blockStart.
|
|
101
|
+
tx.delete({ line: blockStart, column: 0 }, { line: blockEnd, column: blockEndCol });
|
|
102
|
+
// 3. Drop in the call at the block's original indent.
|
|
103
|
+
tx.insert({ line: blockStart, column: 0 }, callText);
|
|
104
|
+
});
|
|
105
|
+
// Caret at the end of the new call.
|
|
106
|
+
editor.setCursor({ line: blockStart, column: callText.length });
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Extract the editor's current line selection into a new function. Finds the
|
|
110
|
+
* enclosing function from the complexity metrics, runs the (pure) planner, and
|
|
111
|
+
* either applies the plan or returns the planner's refusal so the caller can
|
|
112
|
+
* surface the reason to the user.
|
|
113
|
+
*/
|
|
114
|
+
export function extractFunctionAt(editor, metrics) {
|
|
115
|
+
if (!editor.hasSelection) {
|
|
116
|
+
return { ok: false, reason: 'Select a block of statements to extract.' };
|
|
117
|
+
}
|
|
118
|
+
const { start, end } = editor.normalizedSelection;
|
|
119
|
+
const blockStart = start.line;
|
|
120
|
+
// A selection ending at column 0 of a later line covers the full lines above it.
|
|
121
|
+
const blockEnd = end.column === 0 && end.line > start.line ? end.line - 1 : end.line;
|
|
122
|
+
const region = findEnclosingFunctionRegion(metrics, blockStart, blockEnd);
|
|
123
|
+
if (!region) {
|
|
124
|
+
return { ok: false, reason: 'Place the selection inside a function to extract from.' };
|
|
125
|
+
}
|
|
126
|
+
// The applier inserts the new function as a sibling after the enclosing
|
|
127
|
+
// function's last line. The analyzer types class/object methods as `function`
|
|
128
|
+
// too, so that insertion can land inside a class body or object/array literal,
|
|
129
|
+
// where a bare `function extracted()` is a SyntaxError. Refuse rather than
|
|
130
|
+
// miswrite until method extraction is supported. (The region-based class check
|
|
131
|
+
// is kept as defense-in-depth alongside the brace-context scan.)
|
|
132
|
+
const inClassRegion = metrics?.regions.some((r) => r !== region &&
|
|
133
|
+
r.type === 'class' &&
|
|
134
|
+
r.startLine <= region.startLine &&
|
|
135
|
+
region.endLine <= r.endLine);
|
|
136
|
+
if (inClassRegion ||
|
|
137
|
+
insertionInNonFunctionContainer(editor.lines, editor.language, region.endLine)) {
|
|
138
|
+
return { ok: false, reason: 'Extracting from a class or object method is not supported yet.' };
|
|
139
|
+
}
|
|
140
|
+
const plan = planExtractFunction({
|
|
141
|
+
lines: editor.lines.map((line) => ({ text: line.text })),
|
|
142
|
+
language: editor.language,
|
|
143
|
+
region: { startLine: region.startLine, endLine: region.endLine, type: region.type },
|
|
144
|
+
blockStart,
|
|
145
|
+
blockEnd
|
|
146
|
+
});
|
|
147
|
+
if (!plan.ok)
|
|
148
|
+
return plan;
|
|
149
|
+
applyExtractPlan(editor, plan, blockStart, blockEnd);
|
|
150
|
+
return { ok: true };
|
|
151
|
+
}
|
|
@@ -123,7 +123,16 @@ function planExtractFunctionUnsafe(input) {
|
|
|
123
123
|
return { ok: false, reason: 'Could not safely determine the block inputs and outputs.' };
|
|
124
124
|
}
|
|
125
125
|
for (const decl of insideDeclarations) {
|
|
126
|
-
|
|
126
|
+
// A name declared at depth > 0 inside the block and used after it is only
|
|
127
|
+
// conditionally defined — extracting it would drop the after-use's binding.
|
|
128
|
+
// The ONE safe exception is a nested arrow/catch/function PARAM that merely
|
|
129
|
+
// shadows an outer var (params are excluded from outputs, and the after-use
|
|
130
|
+
// resolves to the always-defined outer binding). A real lexical
|
|
131
|
+
// let/const/for-of declaration that shadows an outer name is NOT safe: it
|
|
132
|
+
// becomes a declared return and the call site emits a duplicate `const`.
|
|
133
|
+
if (usedAfterB.has(decl.name) &&
|
|
134
|
+
decl.depth > 0 &&
|
|
135
|
+
!(decl.kind === 'param' && declaredBeforeB.has(decl.name))) {
|
|
127
136
|
return {
|
|
128
137
|
ok: false,
|
|
129
138
|
reason: 'A variable used after the block is only conditionally defined inside it.'
|
|
@@ -142,6 +151,18 @@ function planExtractFunctionUnsafe(input) {
|
|
|
142
151
|
const returnNames = returns.map((item) => item.name);
|
|
143
152
|
const declaredReturnNames = new Set(returnNames.filter((name) => declaredInsideB.has(name)));
|
|
144
153
|
const outerReturnNames = returnNames.filter((name) => !declaredInsideB.has(name));
|
|
154
|
+
// A `const`-emitted return (a name declared inside the block) that ALSO has an
|
|
155
|
+
// outer binding before the block would write a duplicate `const <name>` at the
|
|
156
|
+
// call site (SyntaxError). This is depth-independent, so it covers the
|
|
157
|
+
// for-/while-header lexical declarations the brace-depth guard above cannot see
|
|
158
|
+
// (a `for (const v of …)` binding sits before the loop body brace).
|
|
159
|
+
const duplicateConstReturn = [...declaredReturnNames].find((name) => declaredBeforeB.has(name));
|
|
160
|
+
if (duplicateConstReturn) {
|
|
161
|
+
return {
|
|
162
|
+
ok: false,
|
|
163
|
+
reason: 'A variable declared in the block shadows an outer variable used after it.'
|
|
164
|
+
};
|
|
165
|
+
}
|
|
145
166
|
if (returnNames.length > 1 && outerReturnNames.length > 0) {
|
|
146
167
|
return {
|
|
147
168
|
ok: false,
|
|
@@ -633,7 +654,13 @@ function isIdentifierUse(tokens, index) {
|
|
|
633
654
|
return true;
|
|
634
655
|
}
|
|
635
656
|
function getAssignments(tokens, declarations) {
|
|
636
|
-
|
|
657
|
+
// Seed with the block's own let/const/var/function declarations (a declared
|
|
658
|
+
// value used after the block is a return). Nested fn/arrow/catch params are
|
|
659
|
+
// NOT outputs — they're bindings scoped to their construct; a real assignment
|
|
660
|
+
// to one is still picked up by the operator scan below.
|
|
661
|
+
const assigned = declarations
|
|
662
|
+
.filter((decl) => decl.kind !== 'param')
|
|
663
|
+
.map((decl) => ({
|
|
637
664
|
name: decl.name,
|
|
638
665
|
line: decl.line,
|
|
639
666
|
col: decl.col,
|
|
@@ -677,6 +704,12 @@ function identifierInIncrement(tokens, index) {
|
|
|
677
704
|
if (!areAdjacent(first, second))
|
|
678
705
|
return undefined;
|
|
679
706
|
const previous = tokens[index - 1];
|
|
707
|
+
// A run of 3+ identical operators (e.g. `a+++b` is `a++ + b`) yields overlapping
|
|
708
|
+
// pairs; only the run's leading pair is a real increment. Skip a pair whose
|
|
709
|
+
// previous token is the same operator adjacent to it, so the trailing operand
|
|
710
|
+
// (`b`) is never spuriously modeled as a mutation target.
|
|
711
|
+
if (previous && previous.text === first.text && areAdjacent(previous, first))
|
|
712
|
+
return undefined;
|
|
680
713
|
const next = tokens[index + 2];
|
|
681
714
|
const target = isIdentifierToken(previous)
|
|
682
715
|
? previous
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { EditorState, Position } from './state';
|
|
2
|
+
export type ExtractVariablePlan = {
|
|
3
|
+
ok: true;
|
|
4
|
+
/** Name of the hoisted constant (always `extracted` in this build). */
|
|
5
|
+
varName: string;
|
|
6
|
+
/** Full text of the new `const` line, including the statement's indentation. */
|
|
7
|
+
declarationLine: string;
|
|
8
|
+
/** 0-based line the declaration is inserted ABOVE (the selection's line). */
|
|
9
|
+
insertLine: number;
|
|
10
|
+
/** The trimmed selection range to overwrite with {@link varName}. */
|
|
11
|
+
replaceRange: {
|
|
12
|
+
start: Position;
|
|
13
|
+
end: Position;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
export type ExtractVariableRefusal = {
|
|
17
|
+
ok: false;
|
|
18
|
+
reason: string;
|
|
19
|
+
};
|
|
20
|
+
export type ExtractVariableResult = {
|
|
21
|
+
ok: true;
|
|
22
|
+
} | ExtractVariableRefusal;
|
|
23
|
+
type PlanInput = {
|
|
24
|
+
lines: readonly {
|
|
25
|
+
text: string;
|
|
26
|
+
}[];
|
|
27
|
+
language: string;
|
|
28
|
+
selection: {
|
|
29
|
+
start: Position;
|
|
30
|
+
end: Position;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Plan the extraction of a selected expression into a hoisted `const`. PURE: no
|
|
35
|
+
* editor mutation. Returns a refusal (with a human reason) whenever the selection
|
|
36
|
+
* is anything other than a single, complete, single-line value expression — the
|
|
37
|
+
* safe-or-refuse contract. The token heuristics here are the runtime gate; the
|
|
38
|
+
* unit suite additionally parses every accepted result with acorn to prove the
|
|
39
|
+
* rewrite is valid JS (the parser oracle is test-only, never shipped).
|
|
40
|
+
*/
|
|
41
|
+
export declare function planExtractVariable(input: PlanInput): ExtractVariablePlan | ExtractVariableRefusal;
|
|
42
|
+
/**
|
|
43
|
+
* Extract the editor's current selection into a hoisted `const` as a SINGLE undo
|
|
44
|
+
* step. On refusal the editor is untouched and the reason is returned so the
|
|
45
|
+
* caller can surface it.
|
|
46
|
+
*/
|
|
47
|
+
export declare function extractVariableAt(editor: EditorState): ExtractVariableResult;
|
|
48
|
+
export {};
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import { resolveLanguage, tokenize } from '../tokenizer';
|
|
2
|
+
const SUPPORTED_LANGUAGES = new Set(['javascript', 'typescript', 'jsx', 'tsx']);
|
|
3
|
+
const VAR_NAME = 'extracted';
|
|
4
|
+
const IDENTIFIER_TYPES = new Set(['variable', 'function.call', 'type.class']);
|
|
5
|
+
// Keywords whose presence (at the selection's top nesting level) means the
|
|
6
|
+
// selection is a statement, not a value expression. `new`/`typeof`/`instanceof`/
|
|
7
|
+
// `in`/`of`/`void`/`delete`/`as` are deliberately ABSENT — they are valid inside
|
|
8
|
+
// an expression. `function`/`class` ARE refused: a function/class expression is
|
|
9
|
+
// valid JS but introduces a body we don't want to hoist blindly in this build.
|
|
10
|
+
const STATEMENT_KEYWORDS = new Set([
|
|
11
|
+
'const',
|
|
12
|
+
'let',
|
|
13
|
+
'var',
|
|
14
|
+
'function',
|
|
15
|
+
'class',
|
|
16
|
+
'return',
|
|
17
|
+
'if',
|
|
18
|
+
'else',
|
|
19
|
+
'for',
|
|
20
|
+
'while',
|
|
21
|
+
'do',
|
|
22
|
+
'switch',
|
|
23
|
+
'case',
|
|
24
|
+
'default',
|
|
25
|
+
'break',
|
|
26
|
+
'continue',
|
|
27
|
+
'throw',
|
|
28
|
+
'try',
|
|
29
|
+
'catch',
|
|
30
|
+
'finally',
|
|
31
|
+
'import',
|
|
32
|
+
'export',
|
|
33
|
+
'with',
|
|
34
|
+
'debugger'
|
|
35
|
+
]);
|
|
36
|
+
const ASSIGNMENT_OPERATORS = new Set([
|
|
37
|
+
'=',
|
|
38
|
+
'+=',
|
|
39
|
+
'-=',
|
|
40
|
+
'*=',
|
|
41
|
+
'/=',
|
|
42
|
+
'%=',
|
|
43
|
+
'**=',
|
|
44
|
+
'&=',
|
|
45
|
+
'|=',
|
|
46
|
+
'^=',
|
|
47
|
+
'&&=',
|
|
48
|
+
'||=',
|
|
49
|
+
'??=',
|
|
50
|
+
'<<=',
|
|
51
|
+
'>>=',
|
|
52
|
+
'>>>='
|
|
53
|
+
]);
|
|
54
|
+
/**
|
|
55
|
+
* Plan the extraction of a selected expression into a hoisted `const`. PURE: no
|
|
56
|
+
* editor mutation. Returns a refusal (with a human reason) whenever the selection
|
|
57
|
+
* is anything other than a single, complete, single-line value expression — the
|
|
58
|
+
* safe-or-refuse contract. The token heuristics here are the runtime gate; the
|
|
59
|
+
* unit suite additionally parses every accepted result with acorn to prove the
|
|
60
|
+
* rewrite is valid JS (the parser oracle is test-only, never shipped).
|
|
61
|
+
*/
|
|
62
|
+
export function planExtractVariable(input) {
|
|
63
|
+
try {
|
|
64
|
+
return planExtractVariableUnsafe(input);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return { ok: false, reason: 'Could not safely analyze the selected expression.' };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function planExtractVariableUnsafe(input) {
|
|
71
|
+
const language = resolveLanguage(input.language);
|
|
72
|
+
if (!SUPPORTED_LANGUAGES.has(language)) {
|
|
73
|
+
return { ok: false, reason: 'Extract variable supports JavaScript/TypeScript only.' };
|
|
74
|
+
}
|
|
75
|
+
const { start, end } = input.selection;
|
|
76
|
+
if (start.line !== end.line) {
|
|
77
|
+
return { ok: false, reason: 'Select a single-line expression to extract.' };
|
|
78
|
+
}
|
|
79
|
+
const lineText = input.lines[start.line]?.text;
|
|
80
|
+
if (lineText === undefined) {
|
|
81
|
+
return { ok: false, reason: 'Select an expression to extract.' };
|
|
82
|
+
}
|
|
83
|
+
const rawStart = Math.max(0, Math.min(start.column, lineText.length));
|
|
84
|
+
const rawEnd = Math.max(rawStart, Math.min(end.column, lineText.length));
|
|
85
|
+
const raw = lineText.slice(rawStart, rawEnd);
|
|
86
|
+
const exprText = raw.trim();
|
|
87
|
+
if (exprText.length === 0) {
|
|
88
|
+
return { ok: false, reason: 'Select an expression to extract.' };
|
|
89
|
+
}
|
|
90
|
+
// Tighten the replace range to the trimmed span so surrounding whitespace is
|
|
91
|
+
// preserved exactly (a leading space before the selection stays put).
|
|
92
|
+
const leadWs = raw.length - raw.trimStart().length;
|
|
93
|
+
const trailWs = raw.length - raw.trimEnd().length;
|
|
94
|
+
const adjStart = { line: start.line, column: rawStart + leadWs };
|
|
95
|
+
const adjEnd = { line: start.line, column: rawEnd - trailWs };
|
|
96
|
+
const lineTokens = tokenize(lineText, language)[0]?.tokens ?? [];
|
|
97
|
+
// Boundary integrity: refuse when either edge of the selection falls STRICTLY
|
|
98
|
+
// inside a string, template, or comment token. Such a boundary slices a
|
|
99
|
+
// lexical token in half — the half-token is dropped by the column filter
|
|
100
|
+
// below (hiding it from every later gate) while the RAW text still carries the
|
|
101
|
+
// broken fragment (an unterminated string, or a `/*` that swallows the next
|
|
102
|
+
// line and silently deletes a binding). This single check closes that class.
|
|
103
|
+
if (splitsLexicalToken(lineTokens, adjStart.column) ||
|
|
104
|
+
splitsLexicalToken(lineTokens, adjEnd.column)) {
|
|
105
|
+
return { ok: false, reason: 'Selection splits a string, template, or comment.' };
|
|
106
|
+
}
|
|
107
|
+
const inRange = lineTokens.filter((token) => token.start >= adjStart.column && token.end <= adjEnd.column);
|
|
108
|
+
if (inRange.some(isCommentToken)) {
|
|
109
|
+
return { ok: false, reason: 'Selection contains a comment, not a pure expression.' };
|
|
110
|
+
}
|
|
111
|
+
const codeTokens = inRange.filter(isCodeToken);
|
|
112
|
+
// A string/number literal selection has no "code" tokens but is a fine
|
|
113
|
+
// expression. Only refuse when there is genuinely nothing of substance.
|
|
114
|
+
const meaningful = inRange.filter((token) => !isWhitespaceText(token) && !isCommentToken(token));
|
|
115
|
+
if (meaningful.length === 0) {
|
|
116
|
+
return { ok: false, reason: 'Select an expression to extract.' };
|
|
117
|
+
}
|
|
118
|
+
// Refuse trivially-simple selections (a lone identifier/number) — extracting
|
|
119
|
+
// `foo` to `const extracted = foo` is noise, not a refactor.
|
|
120
|
+
if (meaningful.length === 1 &&
|
|
121
|
+
(IDENTIFIER_TYPES.has(meaningful[0].type) ||
|
|
122
|
+
meaningful[0].type.startsWith('number') ||
|
|
123
|
+
meaningful[0].type.startsWith('constant'))) {
|
|
124
|
+
return { ok: false, reason: 'Selection is already a simple value; nothing to extract.' };
|
|
125
|
+
}
|
|
126
|
+
// Balance runs over `meaningful` (templates kept) so a template interpolation
|
|
127
|
+
// `${ … }` balances — its `${` is a string.template token while the closing
|
|
128
|
+
// `}` is a real code brace; counting only the brace would falsely reject every
|
|
129
|
+
// interpolated template.
|
|
130
|
+
const balanceRefusal = checkDelimiterBalance(meaningful);
|
|
131
|
+
if (balanceRefusal)
|
|
132
|
+
return balanceRefusal;
|
|
133
|
+
const statementRefusal = checkStatementShape(codeTokens);
|
|
134
|
+
if (statementRefusal)
|
|
135
|
+
return statementRefusal;
|
|
136
|
+
// Completeness runs over `meaningful` (literals kept) — not `codeTokens` —
|
|
137
|
+
// so a leading/trailing string or number literal reads as an operand, not as
|
|
138
|
+
// a missing one (e.g. `'Total: ' + total` must not look like a leading `+`).
|
|
139
|
+
const completenessRefusal = checkExpressionCompleteness(meaningful);
|
|
140
|
+
if (completenessRefusal)
|
|
141
|
+
return completenessRefusal;
|
|
142
|
+
// A side-effecting expression is only safe to hoist when the selection is the
|
|
143
|
+
// WHOLE value of its statement (`x = <sel>;` / `return <sel>;`). Pulling one
|
|
144
|
+
// out of a short-circuit (`a && f()`), a ternary branch, or a sibling-operand
|
|
145
|
+
// position (`g() + f()`) would change whether/when it runs — valid JS, wrong
|
|
146
|
+
// behavior. `meaningful` (templates kept) is passed so tagged templates count.
|
|
147
|
+
const callRefusal = checkCallContext(lineTokens.filter(isCodeToken), meaningful, adjStart, adjEnd);
|
|
148
|
+
if (callRefusal)
|
|
149
|
+
return callRefusal;
|
|
150
|
+
if (hasIdentifierNamed(allCodeTokens(input.lines, language), VAR_NAME)) {
|
|
151
|
+
return { ok: false, reason: `A variable named ${VAR_NAME} already exists.` };
|
|
152
|
+
}
|
|
153
|
+
const indent = lineText.match(/^[\t ]*/)?.[0] ?? '';
|
|
154
|
+
const declarationLine = `${indent}const ${VAR_NAME} = ${exprText};`;
|
|
155
|
+
return {
|
|
156
|
+
ok: true,
|
|
157
|
+
varName: VAR_NAME,
|
|
158
|
+
declarationLine,
|
|
159
|
+
insertLine: start.line,
|
|
160
|
+
replaceRange: { start: adjStart, end: adjEnd }
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Extract the editor's current selection into a hoisted `const` as a SINGLE undo
|
|
165
|
+
* step. On refusal the editor is untouched and the reason is returned so the
|
|
166
|
+
* caller can surface it.
|
|
167
|
+
*/
|
|
168
|
+
export function extractVariableAt(editor) {
|
|
169
|
+
if (!editor.hasSelection) {
|
|
170
|
+
return { ok: false, reason: 'Select an expression to extract.' };
|
|
171
|
+
}
|
|
172
|
+
const { start, end } = editor.normalizedSelection;
|
|
173
|
+
const plan = planExtractVariable({
|
|
174
|
+
lines: editor.lines.map((line) => ({ text: line.text })),
|
|
175
|
+
language: editor.language,
|
|
176
|
+
selection: { start, end }
|
|
177
|
+
});
|
|
178
|
+
if (!plan.ok)
|
|
179
|
+
return plan;
|
|
180
|
+
applyExtractVariablePlan(editor, plan);
|
|
181
|
+
return { ok: true };
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Apply a successful plan. Order matters: the in-line edits (remove the
|
|
185
|
+
* expression, drop in the name) run first against the original line, then the
|
|
186
|
+
* declaration is prepended at column 0 of that same line — a position the
|
|
187
|
+
* earlier edits never touched — so it lands as a new line directly above.
|
|
188
|
+
*/
|
|
189
|
+
function applyExtractVariablePlan(editor, plan) {
|
|
190
|
+
editor.transact((tx) => {
|
|
191
|
+
tx.delete(plan.replaceRange.start, plan.replaceRange.end);
|
|
192
|
+
tx.insert(plan.replaceRange.start, plan.varName);
|
|
193
|
+
tx.insert({ line: plan.insertLine, column: 0 }, `${plan.declarationLine}\n`);
|
|
194
|
+
});
|
|
195
|
+
// Caret on the new declaration so the user sees what was hoisted.
|
|
196
|
+
editor.setCursor({ line: plan.insertLine, column: plan.declarationLine.length });
|
|
197
|
+
}
|
|
198
|
+
function isCommentToken(token) {
|
|
199
|
+
return token.type === 'comment' || token.type.startsWith('comment.');
|
|
200
|
+
}
|
|
201
|
+
function isStringToken(token) {
|
|
202
|
+
return token.type === 'string' || token.type.startsWith('string.');
|
|
203
|
+
}
|
|
204
|
+
function isWhitespaceText(token) {
|
|
205
|
+
return token.type === 'text' && token.text.trim().length === 0;
|
|
206
|
+
}
|
|
207
|
+
function isCodeToken(token) {
|
|
208
|
+
return !isCommentToken(token) && !isStringToken(token) && !isWhitespaceText(token);
|
|
209
|
+
}
|
|
210
|
+
// Operates over `meaningful` tokens (code + strings/templates, no whitespace or
|
|
211
|
+
// comments). Strings and regexes are opaque — their inner brackets/backticks
|
|
212
|
+
// don't count. Template interpolation is handled symmetrically: a `${` template
|
|
213
|
+
// token opens a brace that the interpolation's closing code `}` balances, and
|
|
214
|
+
// the literal's backtick delimiters must pair up (an odd count = a cut template).
|
|
215
|
+
function checkDelimiterBalance(tokens) {
|
|
216
|
+
const incomplete = {
|
|
217
|
+
ok: false,
|
|
218
|
+
reason: 'Selection is not a complete expression.'
|
|
219
|
+
};
|
|
220
|
+
let paren = 0;
|
|
221
|
+
let bracket = 0;
|
|
222
|
+
let brace = 0;
|
|
223
|
+
let backticks = 0;
|
|
224
|
+
for (const token of tokens) {
|
|
225
|
+
if (isTemplateToken(token)) {
|
|
226
|
+
backticks += (token.text.match(/`/g) ?? []).length;
|
|
227
|
+
if (token.text.endsWith('${'))
|
|
228
|
+
brace++; // interpolation open → matched by the code `}`
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (!isCodeToken(token))
|
|
232
|
+
continue; // ordinary strings / regexes are opaque
|
|
233
|
+
if (token.text === '(')
|
|
234
|
+
paren++;
|
|
235
|
+
else if (token.text === ')')
|
|
236
|
+
paren--;
|
|
237
|
+
else if (token.text === '[')
|
|
238
|
+
bracket++;
|
|
239
|
+
else if (token.text === ']')
|
|
240
|
+
bracket--;
|
|
241
|
+
else if (token.text === '{')
|
|
242
|
+
brace++;
|
|
243
|
+
else if (token.text === '}')
|
|
244
|
+
brace--;
|
|
245
|
+
if (paren < 0 || bracket < 0 || brace < 0)
|
|
246
|
+
return incomplete;
|
|
247
|
+
}
|
|
248
|
+
if (paren !== 0 || bracket !== 0 || brace !== 0)
|
|
249
|
+
return incomplete;
|
|
250
|
+
if (backticks % 2 === 1)
|
|
251
|
+
return incomplete; // a template literal cut mid-string
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
function isTemplateToken(token) {
|
|
255
|
+
return token.type === 'string.template';
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Reject anything that isn't a single value expression: statement keywords,
|
|
259
|
+
* assignments, `await`/`yield`, statement terminators, or a top-level comma
|
|
260
|
+
* (a sequence that would change meaning when hoisted). Nesting is tracked so a
|
|
261
|
+
* comma INSIDE a call/array (depth > 0) stays allowed.
|
|
262
|
+
*/
|
|
263
|
+
function checkStatementShape(tokens) {
|
|
264
|
+
let depth = 0;
|
|
265
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
266
|
+
const token = tokens[i];
|
|
267
|
+
const text = token.text;
|
|
268
|
+
if (text === '(' || text === '[' || text === '{')
|
|
269
|
+
depth++;
|
|
270
|
+
else if (text === ')' || text === ']' || text === '}')
|
|
271
|
+
depth--;
|
|
272
|
+
if (text === ';') {
|
|
273
|
+
return { ok: false, reason: 'Selection must be a single expression, not a statement.' };
|
|
274
|
+
}
|
|
275
|
+
if (text === 'await' || text === 'yield') {
|
|
276
|
+
return { ok: false, reason: 'Selection uses await/yield and cannot be safely extracted.' };
|
|
277
|
+
}
|
|
278
|
+
if (depth === 0 && text === ',') {
|
|
279
|
+
return { ok: false, reason: 'Selection spans multiple expressions.' };
|
|
280
|
+
}
|
|
281
|
+
if (depth === 0 && text === '...') {
|
|
282
|
+
return { ok: false, reason: 'Selection is not a complete expression.' };
|
|
283
|
+
}
|
|
284
|
+
// Assignments and ++/-- are MUTATIONS — refuse at ANY nesting depth, since a
|
|
285
|
+
// parenthesized `(x = next())` or `(o.n += 1)` would otherwise slip past a
|
|
286
|
+
// depth-0-only check and get hoisted out of the position it mutates from.
|
|
287
|
+
if (isAssignmentOperator(token)) {
|
|
288
|
+
return { ok: false, reason: 'Selection contains an assignment, not a pure expression.' };
|
|
289
|
+
}
|
|
290
|
+
if (isIncrementHere(tokens, i)) {
|
|
291
|
+
return { ok: false, reason: 'Selection mutates a variable; not a pure value.' };
|
|
292
|
+
}
|
|
293
|
+
if (depth === 0 && STATEMENT_KEYWORDS.has(text) && token.type.startsWith('keyword')) {
|
|
294
|
+
return { ok: false, reason: 'Selection must be an expression, not a statement.' };
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
// `++`/`--` may arrive as one token or as two adjacent `+`/`-` tokens depending
|
|
300
|
+
// on the tokenizer path — detect both. A mutation is never a pure value.
|
|
301
|
+
function isIncrementHere(tokens, i) {
|
|
302
|
+
const t = tokens[i];
|
|
303
|
+
if (t.text === '++' || t.text === '--')
|
|
304
|
+
return true;
|
|
305
|
+
const next = tokens[i + 1];
|
|
306
|
+
return (t.text === '+' || t.text === '-') && next?.text === t.text && t.end === next.start;
|
|
307
|
+
}
|
|
308
|
+
function isAssignmentOperator(token) {
|
|
309
|
+
return token.type === 'operator' && ASSIGNMENT_OPERATORS.has(token.text);
|
|
310
|
+
}
|
|
311
|
+
// Operators valid as the FIRST token of an expression (prefix/unary). Anything
|
|
312
|
+
// else leading (a binary `*`, `&&`, a stray `.`) means the selection starts
|
|
313
|
+
// mid-expression.
|
|
314
|
+
const VALID_LEADING_OPERATORS = new Set(['!', '~', '+', '-', '++', '--']);
|
|
315
|
+
const POSTFIX_OPERATORS = new Set(['++', '--']);
|
|
316
|
+
// Keyword operators the tokenizer types as `keyword` (or, for `satisfies`, as a
|
|
317
|
+
// bare identifier) rather than `operator`, so the operator-typed first/last
|
|
318
|
+
// checks miss them. INFIX need a LEFT operand (can't lead); NEEDS_RIGHT need a
|
|
319
|
+
// RIGHT operand (can't trail). Matched by TEXT to cover the misclassification.
|
|
320
|
+
const INFIX_KEYWORDS = new Set(['in', 'instanceof', 'as', 'satisfies']);
|
|
321
|
+
const NEEDS_RIGHT_KEYWORDS = new Set([
|
|
322
|
+
'typeof',
|
|
323
|
+
'void',
|
|
324
|
+
'delete',
|
|
325
|
+
'new',
|
|
326
|
+
'keyof',
|
|
327
|
+
'in',
|
|
328
|
+
'instanceof',
|
|
329
|
+
'as',
|
|
330
|
+
'satisfies'
|
|
331
|
+
]);
|
|
332
|
+
/**
|
|
333
|
+
* Reject selections that start or end mid-expression — a dangling binary/member
|
|
334
|
+
* operator, a keyword operator missing an operand, or a mis-nested ternary —
|
|
335
|
+
* which the delimiter/statement gates miss but which would produce a SyntaxError
|
|
336
|
+
* like `const extracted = in registry;`. Conservative: a false refusal is safe,
|
|
337
|
+
* a false accept is not.
|
|
338
|
+
*/
|
|
339
|
+
function checkExpressionCompleteness(tokens) {
|
|
340
|
+
if (tokens.length === 0)
|
|
341
|
+
return undefined; // a bare string/number literal
|
|
342
|
+
const incomplete = {
|
|
343
|
+
ok: false,
|
|
344
|
+
reason: 'Selection is not a complete expression.'
|
|
345
|
+
};
|
|
346
|
+
const first = tokens[0];
|
|
347
|
+
if (first.text === '.')
|
|
348
|
+
return incomplete;
|
|
349
|
+
if (first.type === 'operator' && !VALID_LEADING_OPERATORS.has(first.text))
|
|
350
|
+
return incomplete;
|
|
351
|
+
if (INFIX_KEYWORDS.has(first.text))
|
|
352
|
+
return incomplete; // leading `in`/`as`/… has no left operand
|
|
353
|
+
const last = tokens[tokens.length - 1];
|
|
354
|
+
if (last.text === '.' || last.text === '?' || last.text === ':')
|
|
355
|
+
return incomplete;
|
|
356
|
+
if (last.type === 'operator' && !POSTFIX_OPERATORS.has(last.text))
|
|
357
|
+
return incomplete;
|
|
358
|
+
if (NEEDS_RIGHT_KEYWORDS.has(last.text))
|
|
359
|
+
return incomplete; // trailing `typeof`/`as`/… has no right operand
|
|
360
|
+
// Ternary balance at the selection's top level — a STACK, not a count, so a
|
|
361
|
+
// mis-ordered `b : c ? d` (colon before its `?`) is rejected. A `?` is a
|
|
362
|
+
// ternary head only when it is NOT optional chaining (`?.`) and NOT half of a
|
|
363
|
+
// nullish `??` (the tokenizer emits `??` and `?.` as two adjacent operators).
|
|
364
|
+
let depth = 0;
|
|
365
|
+
let openTernaries = 0;
|
|
366
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
367
|
+
const token = tokens[i];
|
|
368
|
+
const text = token.text;
|
|
369
|
+
if (text === '(' || text === '[' || text === '{')
|
|
370
|
+
depth++;
|
|
371
|
+
else if (text === ')' || text === ']' || text === '}')
|
|
372
|
+
depth--;
|
|
373
|
+
else if (depth === 0 &&
|
|
374
|
+
text === '?' &&
|
|
375
|
+
!isPartOfPair(tokens, i, '?') &&
|
|
376
|
+
tokens[i + 1]?.text !== '.') {
|
|
377
|
+
openTernaries++;
|
|
378
|
+
}
|
|
379
|
+
else if (depth === 0 && text === ':') {
|
|
380
|
+
openTernaries--;
|
|
381
|
+
if (openTernaries < 0)
|
|
382
|
+
return incomplete; // a `:` with no preceding `?`
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (openTernaries !== 0)
|
|
386
|
+
return incomplete;
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
389
|
+
/** Strictly-inside test for the boundary-integrity gate. */
|
|
390
|
+
function splitsLexicalToken(tokens, column) {
|
|
391
|
+
return tokens.some((t) => (isStringToken(t) || isCommentToken(t)) && t.start < column && column < t.end);
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Half of an adjacent identical-operator pair, e.g. either `?` in `??` (which the
|
|
395
|
+
* tokenizer emits as two separate `?` operators). Used so a nullish `??` is not
|
|
396
|
+
* mistaken for a ternary head.
|
|
397
|
+
*/
|
|
398
|
+
function isPartOfPair(tokens, i, char) {
|
|
399
|
+
const t = tokens[i];
|
|
400
|
+
const prev = tokens[i - 1];
|
|
401
|
+
const next = tokens[i + 1];
|
|
402
|
+
return ((next?.text === char && t.end === next.start) || (prev?.text === char && prev.end === t.start));
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Could the selection have a side effect when evaluated? Detecting every call
|
|
406
|
+
* FORM precisely (paren calls, optional calls `fn?.()`, keyword-named property
|
|
407
|
+
* calls `arr.in()`, tagged templates `` tag`x` ``, JSX with embedded calls) is a
|
|
408
|
+
* losing game against the tokenizer, so in the dangerous operand position we are
|
|
409
|
+
* deliberately conservative: ANY paren, `new`, or tagged template counts. A pure
|
|
410
|
+
* value (identifiers, member access, literals, operators) does not.
|
|
411
|
+
*/
|
|
412
|
+
function hasImpureConstruct(tokens) {
|
|
413
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
414
|
+
const t = tokens[i];
|
|
415
|
+
if (t.text === '(')
|
|
416
|
+
return true; // a call, or a grouped paren — refuse either, conservatively
|
|
417
|
+
if (t.text === 'new' && t.type.startsWith('keyword'))
|
|
418
|
+
return true;
|
|
419
|
+
if (t.text === 'delete')
|
|
420
|
+
return true; // a `delete` expression mutates its target
|
|
421
|
+
if (isTemplateToken(t) && i > 0) {
|
|
422
|
+
const prev = tokens[i - 1];
|
|
423
|
+
// A template preceded by a value-producing token is a TAGGED template — a call.
|
|
424
|
+
if (IDENTIFIER_TYPES.has(prev.type) || prev.text === ')' || prev.text === ']')
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* When the selection is the COMPLETE right-hand value of its statement
|
|
432
|
+
* (`x = <sel>;` / `return <sel>;`), hoisting is always safe — the value is
|
|
433
|
+
* computed in the same place, one line up. When it is a strict sub-expression
|
|
434
|
+
* operand, refuse anything that could carry a side effect.
|
|
435
|
+
*/
|
|
436
|
+
function checkCallContext(lineCodeTokens, selTokens, adjStart, adjEnd) {
|
|
437
|
+
const prev = lineCodeTokens.filter((t) => t.end <= adjStart.column).at(-1);
|
|
438
|
+
const next = lineCodeTokens.find((t) => t.start >= adjEnd.column);
|
|
439
|
+
const isCompleteRhs = (!prev || prev.text === '=' || prev.text === 'return') && (!next || next.text === ';');
|
|
440
|
+
if (isCompleteRhs)
|
|
441
|
+
return undefined;
|
|
442
|
+
if (!hasImpureConstruct(selTokens))
|
|
443
|
+
return undefined;
|
|
444
|
+
return {
|
|
445
|
+
ok: false,
|
|
446
|
+
reason: 'Extracting a call or side-effecting expression from inside a larger expression could change when it runs.'
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
function allCodeTokens(lines, language) {
|
|
450
|
+
const source = lines.map((line) => line.text).join('\n');
|
|
451
|
+
return tokenize(source, language)
|
|
452
|
+
.flatMap((line) => line.tokens)
|
|
453
|
+
.filter(isCodeToken);
|
|
454
|
+
}
|
|
455
|
+
function hasIdentifierNamed(tokens, name) {
|
|
456
|
+
return tokens.some((token) => token.text === name && IDENTIFIER_TYPES.has(token.type));
|
|
457
|
+
}
|
|
@@ -18,5 +18,8 @@ export * from './quick-actions';
|
|
|
18
18
|
export * from './diagnostics';
|
|
19
19
|
export * from './breakpoints';
|
|
20
20
|
export * from './extract-function';
|
|
21
|
+
export * from './apply-extract-plan';
|
|
22
|
+
export * from './extract-variable';
|
|
23
|
+
export * from './organize-imports';
|
|
21
24
|
export type { Position } from './state';
|
|
22
25
|
export type { Diagnostic, Range } from './quick-actions';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { EditorState } from './state';
|
|
2
|
+
export type OrganizeImportsPlan = {
|
|
3
|
+
ok: true;
|
|
4
|
+
/** 0-based first line of the import block being replaced. */
|
|
5
|
+
startLine: number;
|
|
6
|
+
/** 0-based last line of the import block being replaced (inclusive). */
|
|
7
|
+
endLine: number;
|
|
8
|
+
/** The reorganized import block (no trailing newline). */
|
|
9
|
+
newText: string;
|
|
10
|
+
};
|
|
11
|
+
export type OrganizeImportsRefusal = {
|
|
12
|
+
ok: false;
|
|
13
|
+
reason: string;
|
|
14
|
+
};
|
|
15
|
+
export type OrganizeImportsResult = {
|
|
16
|
+
ok: true;
|
|
17
|
+
} | OrganizeImportsRefusal;
|
|
18
|
+
/**
|
|
19
|
+
* Plan an "organize imports" rewrite of the leading import block: sort the
|
|
20
|
+
* statements by module path, sort the named specifiers within each, and drop
|
|
21
|
+
* exact-duplicate statements. PURE — no editor mutation.
|
|
22
|
+
*
|
|
23
|
+
* Safe-or-refuse: this build only touches a contiguous run of single-line
|
|
24
|
+
* `import … from '…'` statements at the top of the file. It refuses (rather than
|
|
25
|
+
* risk changing behavior) on side-effect imports, multi-line imports, comments
|
|
26
|
+
* interleaved in the block, or any statement it cannot fully parse.
|
|
27
|
+
*/
|
|
28
|
+
export declare function planOrganizeImports(input: {
|
|
29
|
+
lines: readonly {
|
|
30
|
+
text: string;
|
|
31
|
+
}[];
|
|
32
|
+
language: string;
|
|
33
|
+
}): OrganizeImportsPlan | OrganizeImportsRefusal;
|
|
34
|
+
/**
|
|
35
|
+
* Organize the editor's leading import block as a SINGLE undo step. On refusal
|
|
36
|
+
* the editor is untouched and the reason is returned.
|
|
37
|
+
*/
|
|
38
|
+
export declare function organizeImportsAt(editor: EditorState): OrganizeImportsResult;
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { resolveLanguage } from '../tokenizer';
|
|
2
|
+
const SUPPORTED_LANGUAGES = new Set(['javascript', 'typescript', 'jsx', 'tsx']);
|
|
3
|
+
const IDENT = '[A-Za-z_$][\\w$]*';
|
|
4
|
+
/**
|
|
5
|
+
* Plan an "organize imports" rewrite of the leading import block: sort the
|
|
6
|
+
* statements by module path, sort the named specifiers within each, and drop
|
|
7
|
+
* exact-duplicate statements. PURE — no editor mutation.
|
|
8
|
+
*
|
|
9
|
+
* Safe-or-refuse: this build only touches a contiguous run of single-line
|
|
10
|
+
* `import … from '…'` statements at the top of the file. It refuses (rather than
|
|
11
|
+
* risk changing behavior) on side-effect imports, multi-line imports, comments
|
|
12
|
+
* interleaved in the block, or any statement it cannot fully parse.
|
|
13
|
+
*/
|
|
14
|
+
export function planOrganizeImports(input) {
|
|
15
|
+
try {
|
|
16
|
+
return planOrganizeImportsUnsafe(input);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return { ok: false, reason: 'Could not safely parse the imports; leaving them untouched.' };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function planOrganizeImportsUnsafe(input) {
|
|
23
|
+
const language = resolveLanguage(input.language);
|
|
24
|
+
if (!SUPPORTED_LANGUAGES.has(language)) {
|
|
25
|
+
return { ok: false, reason: 'Organize imports supports JavaScript/TypeScript only.' };
|
|
26
|
+
}
|
|
27
|
+
const lines = input.lines.map((line) => line.text);
|
|
28
|
+
// Find the first import statement; everything above it (license headers,
|
|
29
|
+
// blank lines, leading comments) is left untouched.
|
|
30
|
+
const firstImport = lines.findIndex((text) => /^\s*import\b/.test(text));
|
|
31
|
+
if (firstImport === -1) {
|
|
32
|
+
return { ok: false, reason: 'No imports to organize.' };
|
|
33
|
+
}
|
|
34
|
+
// Walk the contiguous leading run: import lines and blank lines. A comment
|
|
35
|
+
// ENDS the run unless another import follows it — only a comment BETWEEN two
|
|
36
|
+
// imports is the unsupported "interleaved" case. A trailing body comment (a
|
|
37
|
+
// section header, the first JSDoc) is just the end of the block and must not
|
|
38
|
+
// make organize refuse.
|
|
39
|
+
let lastImport = firstImport;
|
|
40
|
+
const importLineIndices = [];
|
|
41
|
+
let sawCommentInRun = false;
|
|
42
|
+
let commentBetweenImports = false;
|
|
43
|
+
for (let i = firstImport; i < lines.length; i++) {
|
|
44
|
+
const text = lines[i];
|
|
45
|
+
if (text.trim().length === 0)
|
|
46
|
+
continue; // blank lines tolerated inside the run
|
|
47
|
+
if (isCommentLine(text)) {
|
|
48
|
+
sawCommentInRun = true;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (!/^\s*import\b/.test(text))
|
|
52
|
+
break; // a code line ends the run
|
|
53
|
+
if (sawCommentInRun)
|
|
54
|
+
commentBetweenImports = true; // a comment preceded THIS import
|
|
55
|
+
importLineIndices.push(i);
|
|
56
|
+
lastImport = i;
|
|
57
|
+
}
|
|
58
|
+
if (commentBetweenImports) {
|
|
59
|
+
return { ok: false, reason: 'Comments between imports are not supported yet.' };
|
|
60
|
+
}
|
|
61
|
+
const indent = lines[firstImport].match(/^[\t ]*/)?.[0] ?? '';
|
|
62
|
+
const parsed = [];
|
|
63
|
+
for (const i of importLineIndices) {
|
|
64
|
+
const text = lines[i];
|
|
65
|
+
if (!hasBalancedBraces(text)) {
|
|
66
|
+
return { ok: false, reason: 'Multi-line imports are not supported yet.' };
|
|
67
|
+
}
|
|
68
|
+
if (isSideEffectImport(text)) {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
reason: 'Side-effect imports present; not reordering to preserve evaluation order.'
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const imp = parseImport(text);
|
|
75
|
+
if (!imp) {
|
|
76
|
+
return { ok: false, reason: 'Could not safely parse the imports; leaving them untouched.' };
|
|
77
|
+
}
|
|
78
|
+
parsed.push(imp);
|
|
79
|
+
}
|
|
80
|
+
const emitted = parsed.map((imp) => `${indent}${emitImport(imp)}`);
|
|
81
|
+
const deduped = dedupe([...emitted.keys()]
|
|
82
|
+
.sort((a, b) => compareImport(parsed[a], parsed[b], emitted[a], emitted[b]))
|
|
83
|
+
.map((index) => emitted[index]));
|
|
84
|
+
const newText = deduped.join('\n');
|
|
85
|
+
const originalText = lines.slice(firstImport, lastImport + 1).join('\n');
|
|
86
|
+
if (newText === originalText) {
|
|
87
|
+
return { ok: false, reason: 'Imports are already organized.' };
|
|
88
|
+
}
|
|
89
|
+
return { ok: true, startLine: firstImport, endLine: lastImport, newText };
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Organize the editor's leading import block as a SINGLE undo step. On refusal
|
|
93
|
+
* the editor is untouched and the reason is returned.
|
|
94
|
+
*/
|
|
95
|
+
export function organizeImportsAt(editor) {
|
|
96
|
+
const plan = planOrganizeImports({
|
|
97
|
+
lines: editor.lines.map((line) => ({ text: line.text })),
|
|
98
|
+
language: editor.language
|
|
99
|
+
});
|
|
100
|
+
if (!plan.ok)
|
|
101
|
+
return plan;
|
|
102
|
+
const endCol = editor.getLine(plan.endLine)?.text.length ?? 0;
|
|
103
|
+
editor.transact((tx) => {
|
|
104
|
+
tx.delete({ line: plan.startLine, column: 0 }, { line: plan.endLine, column: endCol });
|
|
105
|
+
tx.insert({ line: plan.startLine, column: 0 }, plan.newText);
|
|
106
|
+
});
|
|
107
|
+
const lastLineLength = plan.newText.split('\n').at(-1)?.length ?? 0;
|
|
108
|
+
const lastLine = plan.startLine + plan.newText.split('\n').length - 1;
|
|
109
|
+
editor.setCursor({ line: lastLine, column: lastLineLength });
|
|
110
|
+
return { ok: true };
|
|
111
|
+
}
|
|
112
|
+
function isCommentLine(text) {
|
|
113
|
+
const t = text.trim();
|
|
114
|
+
return t.startsWith('//') || t.startsWith('/*') || t.startsWith('*');
|
|
115
|
+
}
|
|
116
|
+
function hasBalancedBraces(text) {
|
|
117
|
+
let brace = 0;
|
|
118
|
+
for (const ch of text) {
|
|
119
|
+
if (ch === '{')
|
|
120
|
+
brace++;
|
|
121
|
+
else if (ch === '}')
|
|
122
|
+
brace--;
|
|
123
|
+
if (brace < 0)
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
return brace === 0;
|
|
127
|
+
}
|
|
128
|
+
function isSideEffectImport(text) {
|
|
129
|
+
return new RegExp(`^\\s*import\\s*(['"])[^'"]+\\1\\s*;?\\s*$`).test(text);
|
|
130
|
+
}
|
|
131
|
+
function parseImport(text) {
|
|
132
|
+
const trimmed = text.trim().replace(/;\s*$/, '');
|
|
133
|
+
const sourceMatch = trimmed.match(/\bfrom\s*(['"])([^'"]+)\1$/);
|
|
134
|
+
if (!sourceMatch)
|
|
135
|
+
return null;
|
|
136
|
+
const quote = sourceMatch[1];
|
|
137
|
+
const source = sourceMatch[2];
|
|
138
|
+
let clause = trimmed.slice('import'.length, trimmed.length - sourceMatch[0].length).trim();
|
|
139
|
+
if (clause.length === 0)
|
|
140
|
+
return null; // `import from 'x'` is invalid
|
|
141
|
+
// `import type { … }` / `import type Foo from …` / `import type * as ns` is
|
|
142
|
+
// type-only. But `import type from 'x'` and `import type, { a }` are DEFAULT
|
|
143
|
+
// imports named `type`, so only treat it as type-only when `type` is followed
|
|
144
|
+
// by a real clause start (`{`, `*`, or an identifier) — NOT a comma.
|
|
145
|
+
let typeOnly = false;
|
|
146
|
+
if (/^type\s+[{*A-Za-z_$]/.test(clause)) {
|
|
147
|
+
typeOnly = true;
|
|
148
|
+
clause = clause.replace(/^type\s+/, '').trim();
|
|
149
|
+
}
|
|
150
|
+
let named;
|
|
151
|
+
const braceMatch = clause.match(/\{([^}]*)\}/);
|
|
152
|
+
if (braceMatch) {
|
|
153
|
+
const parsedNamed = parseNamed(braceMatch[1]);
|
|
154
|
+
if (!parsedNamed)
|
|
155
|
+
return null;
|
|
156
|
+
named = parsedNamed;
|
|
157
|
+
const at = braceMatch.index ?? 0;
|
|
158
|
+
clause = (clause.slice(0, at) + clause.slice(at + braceMatch[0].length)).trim();
|
|
159
|
+
clause = clause.replace(/^,|,$/g, '').trim();
|
|
160
|
+
}
|
|
161
|
+
let defaultImport;
|
|
162
|
+
let namespace;
|
|
163
|
+
const remainders = clause
|
|
164
|
+
.split(',')
|
|
165
|
+
.map((part) => part.trim())
|
|
166
|
+
.filter(Boolean);
|
|
167
|
+
for (const part of remainders) {
|
|
168
|
+
const nsMatch = part.match(new RegExp(`^\\*\\s+as\\s+(${IDENT})$`));
|
|
169
|
+
if (nsMatch) {
|
|
170
|
+
if (namespace)
|
|
171
|
+
return null; // two namespace clauses is invalid
|
|
172
|
+
namespace = nsMatch[1];
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (new RegExp(`^${IDENT}$`).test(part)) {
|
|
176
|
+
if (defaultImport)
|
|
177
|
+
return null; // `import A, B from …` is invalid JS
|
|
178
|
+
defaultImport = part;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
return null; // unrecognized clause fragment
|
|
182
|
+
}
|
|
183
|
+
if (!defaultImport && !namespace && !named)
|
|
184
|
+
return null;
|
|
185
|
+
// A namespace import cannot be combined with a named clause (`* as ns, { a }`
|
|
186
|
+
// and `d, * as ns, { a }` are SyntaxErrors). Refuse rather than re-emit them.
|
|
187
|
+
if (namespace && named)
|
|
188
|
+
return null;
|
|
189
|
+
return { source, quote, typeOnly, defaultImport, namespace, named };
|
|
190
|
+
}
|
|
191
|
+
function parseNamed(inner) {
|
|
192
|
+
const specs = [];
|
|
193
|
+
for (const raw of inner.split(',')) {
|
|
194
|
+
const spec = raw.trim();
|
|
195
|
+
if (spec.length === 0)
|
|
196
|
+
continue;
|
|
197
|
+
let body = spec;
|
|
198
|
+
let typeOnly = false;
|
|
199
|
+
const typeMatch = body.match(new RegExp(`^type\\s+(.*)$`));
|
|
200
|
+
if (typeMatch) {
|
|
201
|
+
typeOnly = true;
|
|
202
|
+
body = typeMatch[1].trim();
|
|
203
|
+
}
|
|
204
|
+
const aliasMatch = body.match(new RegExp(`^(${IDENT})\\s+as\\s+(${IDENT})$`));
|
|
205
|
+
if (aliasMatch) {
|
|
206
|
+
specs.push({ name: aliasMatch[1], alias: aliasMatch[2], typeOnly });
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (new RegExp(`^${IDENT}$`).test(body)) {
|
|
210
|
+
specs.push({ name: body, typeOnly });
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
return specs;
|
|
216
|
+
}
|
|
217
|
+
function emitImport(imp) {
|
|
218
|
+
const parts = [];
|
|
219
|
+
if (imp.defaultImport)
|
|
220
|
+
parts.push(imp.defaultImport);
|
|
221
|
+
if (imp.namespace)
|
|
222
|
+
parts.push(`* as ${imp.namespace}`);
|
|
223
|
+
if (imp.named) {
|
|
224
|
+
const specs = [...imp.named]
|
|
225
|
+
.sort((a, b) => compareName(a.name, b.name))
|
|
226
|
+
.map((n) => `${n.typeOnly ? 'type ' : ''}${n.name}${n.alias ? ` as ${n.alias}` : ''}`);
|
|
227
|
+
// An import with ONLY an empty `{}` is preserved as `{}` (valid, rare).
|
|
228
|
+
parts.push(`{ ${specs.join(', ')} }`);
|
|
229
|
+
}
|
|
230
|
+
const clause = parts.join(', ');
|
|
231
|
+
return `import ${imp.typeOnly ? 'type ' : ''}${clause} from ${imp.quote}${imp.source}${imp.quote};`;
|
|
232
|
+
}
|
|
233
|
+
function compareName(a, b) {
|
|
234
|
+
return a.toLowerCase().localeCompare(b.toLowerCase()) || a.localeCompare(b);
|
|
235
|
+
}
|
|
236
|
+
function compareImport(a, b, aLine, bLine) {
|
|
237
|
+
return compareName(a.source, b.source) || aLine.localeCompare(bLine);
|
|
238
|
+
}
|
|
239
|
+
function dedupe(sortedLines) {
|
|
240
|
+
const seen = new Set();
|
|
241
|
+
const out = [];
|
|
242
|
+
for (const line of sortedLines) {
|
|
243
|
+
if (seen.has(line))
|
|
244
|
+
continue;
|
|
245
|
+
seen.add(line);
|
|
246
|
+
out.push(line);
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
@@ -25,6 +25,16 @@ export interface Selection {
|
|
|
25
25
|
/** End of selection (head/cursor position) */
|
|
26
26
|
head: Position;
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Primitive edits available inside {@link EditorState.transact}. Every insert
|
|
30
|
+
* and delete is folded into a single undo step.
|
|
31
|
+
*/
|
|
32
|
+
export interface EditorTransaction {
|
|
33
|
+
/** Insert text at a position; returns the position after the inserted text. */
|
|
34
|
+
insert(position: Position, text: string): Position;
|
|
35
|
+
/** Delete the range [from, to) (end exclusive). */
|
|
36
|
+
delete(from: Position, to: Position): void;
|
|
37
|
+
}
|
|
28
38
|
/**
|
|
29
39
|
* A single line in the document
|
|
30
40
|
*/
|
|
@@ -271,6 +281,15 @@ export declare class EditorState {
|
|
|
271
281
|
* Insert text at all cursor positions
|
|
272
282
|
*/
|
|
273
283
|
insert(text: string): void;
|
|
284
|
+
/**
|
|
285
|
+
* Run several primitive edits as ONE undoable transaction. Inserts/deletes
|
|
286
|
+
* performed via the provided `tx` are folded into a single history entry, so
|
|
287
|
+
* one undo reverts the whole change and one redo reapplies it. Used by
|
|
288
|
+
* multi-edit refactors such as extract-function. Emits a single content +
|
|
289
|
+
* selection + cursor change after the body so subscribers (and `bind:content`)
|
|
290
|
+
* update once.
|
|
291
|
+
*/
|
|
292
|
+
transact(fn: (tx: EditorTransaction) => void): void;
|
|
274
293
|
/**
|
|
275
294
|
* Insert text at a specific position (internal, doesn't update cursor)
|
|
276
295
|
* @returns New position after insert
|
|
@@ -376,6 +376,30 @@ export class EditorState {
|
|
|
376
376
|
this.emitCursorChange();
|
|
377
377
|
this.commitHistory(history, 'insert');
|
|
378
378
|
}
|
|
379
|
+
/**
|
|
380
|
+
* Run several primitive edits as ONE undoable transaction. Inserts/deletes
|
|
381
|
+
* performed via the provided `tx` are folded into a single history entry, so
|
|
382
|
+
* one undo reverts the whole change and one redo reapplies it. Used by
|
|
383
|
+
* multi-edit refactors such as extract-function. Emits a single content +
|
|
384
|
+
* selection + cursor change after the body so subscribers (and `bind:content`)
|
|
385
|
+
* update once.
|
|
386
|
+
*/
|
|
387
|
+
transact(fn) {
|
|
388
|
+
// Force a fresh, isolated history entry: don't merge into prior typing, and
|
|
389
|
+
// (after commit) don't let the next keystroke merge into this one.
|
|
390
|
+
this.lastHistoryType = null;
|
|
391
|
+
const history = this.beginHistory('insert');
|
|
392
|
+
fn({
|
|
393
|
+
insert: (position, text) => this.insertAtInternal(position, text, history.entry.changes),
|
|
394
|
+
delete: (from, to) => this.deleteRangeInternal(from, to, history.entry.changes)
|
|
395
|
+
});
|
|
396
|
+
// The internal primitives don't move cursors; clamp the primary so it stays
|
|
397
|
+
// valid against the new content (callers may set a more precise position).
|
|
398
|
+
this.setCursor(this.clampPosition(this.selection.head));
|
|
399
|
+
this.emitChange({ type: 'replace', from: { line: 0, column: 0 } });
|
|
400
|
+
this.commitHistory(history, 'insert');
|
|
401
|
+
this.lastHistoryType = null;
|
|
402
|
+
}
|
|
379
403
|
/**
|
|
380
404
|
* Insert text at a specific position (internal, doesn't update cursor)
|
|
381
405
|
* @returns New position after insert
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocturnium/svelte-ide",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Svelte 5 code editor and IDE building blocks — custom editor, syntax highlighting, code folding, multi-cursor, LSP client, and optional realtime collaboration.",
|
|
5
5
|
"author": "Nocturnium & Jordan Dziat <hello@nocturnium.ai> (https://nocturnium.ai)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -142,6 +142,7 @@
|
|
|
142
142
|
"@sveltejs/vite-plugin-svelte": "^5.1.0",
|
|
143
143
|
"@tailwindcss/vite": "^4.1.0",
|
|
144
144
|
"@types/node": "^22.15.0",
|
|
145
|
+
"acorn": "^8.16.0",
|
|
145
146
|
"eslint": "^9.39.0",
|
|
146
147
|
"eslint-plugin-svelte": "^3.9.0",
|
|
147
148
|
"globals": "^16.2.0",
|