@nocturnium/svelte-ide 1.3.0 → 1.5.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/ai/AIEditPreview.svelte +36 -6
- package/dist/components/ai/AIPanel.svelte +42 -14
- package/dist/components/core/Button.svelte +11 -4
- package/dist/components/core/Icon.svelte +19 -1
- package/dist/components/core/ResizeHandle.svelte +90 -6
- package/dist/components/core/ResizeHandle.svelte.d.ts +6 -0
- package/dist/components/core/Tooltip.svelte +13 -2
- package/dist/components/editor/CustomEditor.svelte +34 -0
- package/dist/components/editor/CustomEditor.svelte.d.ts +5 -1
- package/dist/components/editor/EchoCursorLayer.svelte +4 -1
- package/dist/components/editor/GhostBracketLayer.svelte +17 -7
- package/dist/components/editor/GitBlameLayer.svelte +10 -3
- package/dist/components/editor/InlineDiagnosticsLayer.svelte +226 -13
- package/dist/components/editor/InlineDiagnosticsLayer.svelte.d.ts +7 -0
- package/dist/components/editor/InlineDiffLayer.svelte +8 -2
- package/dist/components/editor/PluginPreviewSandbox.svelte +10 -2
- package/dist/components/editor/ProblemsPanel.svelte +40 -5
- package/dist/components/editor/SnippetPalette.svelte +63 -20
- package/dist/components/editor/core/diagnostics.js +4 -1
- 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 +2 -0
- package/dist/components/editor/core/index.js +2 -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/snippet-manager.js +3 -3
- package/dist/components/plugins/PluginCard.svelte +21 -1
- package/dist/components/plugins/PluginPanel.svelte +17 -3
- package/dist/styles/theme.css +8 -1
- package/package.json +1 -1
|
@@ -226,7 +226,10 @@ export function getSeverityIcon(severity) {
|
|
|
226
226
|
case 'info':
|
|
227
227
|
return 'ℹ';
|
|
228
228
|
case 'hint':
|
|
229
|
-
|
|
229
|
+
// Monochrome glyph (not the 💡 emoji): a color emoji ignores `color` and
|
|
230
|
+
// would render the LOWEST severity louder than a warning, inverting the
|
|
231
|
+
// severity hierarchy.
|
|
232
|
+
return '◇';
|
|
230
233
|
}
|
|
231
234
|
}
|
|
232
235
|
/**
|
|
@@ -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
|
+
}
|
|
@@ -19,5 +19,7 @@ export * from './diagnostics';
|
|
|
19
19
|
export * from './breakpoints';
|
|
20
20
|
export * from './extract-function';
|
|
21
21
|
export * from './apply-extract-plan';
|
|
22
|
+
export * from './extract-variable';
|
|
23
|
+
export * from './organize-imports';
|
|
22
24
|
export type { Position } from './state';
|
|
23
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;
|