@nocturnium/svelte-ide 1.2.1 → 1.3.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.
@@ -7,7 +7,9 @@
7
7
  createKeyboardHandler,
8
8
  createDefaultKeybindings,
9
9
  createFoldManager,
10
+ extractFunctionAt,
10
11
  type EditorState,
12
+ type ExtractFunctionResult,
11
13
  type Selection,
12
14
  type SearchMatch,
13
15
  type FoldManager,
@@ -590,6 +592,21 @@
590
592
  hiddenInput?.focus();
591
593
  }
592
594
 
595
+ /**
596
+ * Extract the current selection into a new function (Track H). Analyzes the
597
+ * current content fresh (not the debounced complexityMetrics, which can lag a
598
+ * recent edit) so the enclosing region is always correct, regardless of the
599
+ * complexityHighlighting prop. The edit is applied as a single undo step; on
600
+ * refusal the editor is untouched. Returns the result so callers can surface
601
+ * the reason.
602
+ */
603
+ export function extractFunction(): ExtractFunctionResult {
604
+ const metrics = complexityAnalyzer.analyze(editorState.lines, language);
605
+ const result = extractFunctionAt(editorState, metrics);
606
+ announce(result.ok ? 'Extracted function' : result.reason);
607
+ return result;
608
+ }
609
+
593
610
  function handleFoldIndicatorClick(lineNumber: number, e: MouseEvent) {
594
611
  e.preventDefault();
595
612
  e.stopPropagation();
@@ -1,5 +1,5 @@
1
1
  import type { EditorPreferences } from '../../types';
2
- import { type Cursor } from './core';
2
+ import { type ExtractFunctionResult, 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,7 @@ 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;
41
42
  }, "content">;
42
43
  type CustomEditor = ReturnType<typeof CustomEditor>;
43
44
  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
@@ -18,5 +18,6 @@ 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';
21
22
  export type { Position } from './state';
22
23
  export type { Diagnostic, Range } from './quick-actions';
@@ -22,3 +22,4 @@ 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';
@@ -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.3.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",