@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 CHANGED
@@ -8,9 +8,10 @@ framework.
8
8
  [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
9
9
  [![svelte](https://img.shields.io/badge/Svelte-5-ff3e00.svg)](https://svelte.dev)
10
10
 
11
- Built from scratch with Svelte 5 runes and zero runtime UI dependencies. Use a
12
- single `<CustomEditor>` for a textarea-grade upgrade, or compose the editor,
13
- LSP, collaboration, AI, and plugin pieces into a full IDE experience.
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
- - **Zero external UI dependencies**; collaboration deps are optional peers.
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
- if (usedAfterB.has(decl.name) && decl.depth > 0) {
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
- const assigned = declarations.map((decl) => ({
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 &gt; 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';
@@ -22,3 +22,6 @@ export * from './quick-actions';
22
22
  export * from './diagnostics';
23
23
  export * from './breakpoints';
24
24
  export * from './extract-function';
25
+ export * from './apply-extract-plan';
26
+ export * from './extract-variable';
27
+ export * from './organize-imports';
@@ -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.2.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",