Skip to content

Commit 4b7e002

Browse files
authored
feat: better REPL autocomplete (#11530)
* feat: make autocomplete more robust * handle `$inspect(...).with(...)` special case * autocomplete imports * only allow $props at the top level of .svelte files * only autocomplete runes in svelte files
1 parent 59f4feb commit 4b7e002

File tree

4 files changed

+222
-51
lines changed

4 files changed

+222
-51
lines changed

pnpm-lock.yaml

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

sites/svelte-5-preview/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
},
1414
"devDependencies": {
1515
"@fontsource/fira-mono": "^5.0.8",
16+
"@lezer/common": "^1.2.1",
1617
"@sveltejs/adapter-static": "^3.0.1",
1718
"@sveltejs/adapter-vercel": "^5.0.0",
1819
"@sveltejs/kit": "^2.5.0",

sites/svelte-5-preview/src/lib/CodeMirror.svelte

Lines changed: 10 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,21 @@
77
import { EditorState, Range, StateEffect, StateEffectType, StateField } from '@codemirror/state';
88
import { Decoration, EditorView } from '@codemirror/view';
99
import { codemirror, withCodemirrorInstance } from '@neocodemirror/svelte';
10+
import { svelteLanguage } from '@replit/codemirror-lang-svelte';
11+
import { javascriptLanguage } from '@codemirror/lang-javascript';
1012
import { createEventDispatcher, tick } from 'svelte';
1113
import { writable } from 'svelte/store';
14+
import { get_repl_context } from '$lib/context.js';
1215
import Message from './Message.svelte';
1316
import { svelteTheme } from './theme.js';
17+
import { autocomplete } from './autocomplete.js';
1418
1519
/** @type {import('@codemirror/lint').LintSource | undefined} */
1620
export let diagnostics = undefined;
1721
1822
export let readonly = false;
1923
export let tab = true;
2024
21-
/** @type {boolean} */
22-
export let autocomplete = true;
23-
2425
/** @type {ReturnType<typeof createEventDispatcher<{ change: { value: string } }>>} */
2526
const dispatch = createEventDispatcher();
2627
@@ -192,57 +193,16 @@
192193
}
193194
});
194195
195-
import { svelteLanguage } from '@replit/codemirror-lang-svelte';
196-
import { javascriptLanguage } from '@codemirror/lang-javascript';
197-
import { snippetCompletion as snip } from '@codemirror/autocomplete';
198-
199-
/** @param {any} context */
200-
function complete_svelte_runes(context) {
201-
const word = context.matchBefore(/\w*/);
202-
if (word.from === word.to && context.state.sliceDoc(word.from - 1, word.to) !== '$') {
203-
return null;
204-
}
205-
return {
206-
from: word.from - 1,
207-
options: [
208-
{ label: '$state', type: 'keyword', boost: 12 },
209-
{ label: '$props', type: 'keyword', boost: 11 },
210-
{ label: '$derived', type: 'keyword', boost: 10 },
211-
snip('$derived.by(() => {\n\t${}\n});', {
212-
label: '$derived.by',
213-
type: 'keyword',
214-
boost: 9
215-
}),
216-
snip('$effect(() => {\n\t${}\n});', { label: '$effect', type: 'keyword', boost: 8 }),
217-
snip('$effect.pre(() => {\n\t${}\n});', {
218-
label: '$effect.pre',
219-
type: 'keyword',
220-
boost: 7
221-
}),
222-
{ label: '$state.frozen', type: 'keyword', boost: 6 },
223-
{ label: '$bindable', type: 'keyword', boost: 5 },
224-
snip('$effect.root(() => {\n\t${}\n});', {
225-
label: '$effect.root',
226-
type: 'keyword',
227-
boost: 4
228-
}),
229-
{ label: '$state.snapshot', type: 'keyword', boost: 3 },
230-
snip('$effect.active()', {
231-
label: '$effect.active',
232-
type: 'keyword',
233-
boost: 2
234-
}),
235-
{ label: '$inspect', type: 'keyword', boost: 1 }
236-
]
237-
};
238-
}
196+
const { files, selected } = get_repl_context();
239197
240198
const svelte_rune_completions = svelteLanguage.data.of({
241-
autocomplete: complete_svelte_runes
199+
/** @param {import('@codemirror/autocomplete').CompletionContext} context */
200+
autocomplete: (context) => autocomplete(context, $selected, $files)
242201
});
243202
244203
const js_rune_completions = javascriptLanguage.data.of({
245-
autocomplete: complete_svelte_runes
204+
/** @param {import('@codemirror/autocomplete').CompletionContext} context */
205+
autocomplete: (context) => autocomplete(context, $selected, $files)
246206
});
247207
</script>
248208
@@ -266,7 +226,7 @@
266226
},
267227
lint: diagnostics,
268228
lintOptions: { delay: 200 },
269-
autocomplete,
229+
autocomplete: true,
270230
extensions: [svelte_rune_completions, js_rune_completions, watcher],
271231
instanceStore: cmInstance
272232
}}
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { snippetCompletion } from '@codemirror/autocomplete';
2+
import { syntaxTree } from '@codemirror/language';
3+
4+
/** @typedef {(node: import('@lezer/common').SyntaxNode, context: import('@codemirror/autocomplete').CompletionContext, selected: import('./types').File) => boolean} Test */
5+
6+
/**
7+
* Returns `true` if `$bindable()` is valid
8+
* @type {Test}
9+
*/
10+
function is_bindable(node, context) {
11+
// disallow outside `let { x = $bindable }`
12+
if (node.parent?.name !== 'PatternProperty') return false;
13+
if (node.parent.parent?.name !== 'ObjectPattern') return false;
14+
if (node.parent.parent.parent?.name !== 'VariableDeclaration') return false;
15+
16+
let last = node.parent.parent.parent.lastChild;
17+
if (!last) return true;
18+
19+
// if the declaration is incomplete, assume the best
20+
if (last.name === 'ObjectPattern' || last.name === 'Equals' || last.name === '⚠') {
21+
return true;
22+
}
23+
24+
if (last.name === ';') {
25+
last = last.prevSibling;
26+
if (!last || last.name === '⚠') return true;
27+
}
28+
29+
// if the declaration is complete, only return true if it is a `$props()` declaration
30+
return (
31+
last.name === 'CallExpression' &&
32+
last.firstChild?.name === 'VariableName' &&
33+
context.state.sliceDoc(last.firstChild.from, last.firstChild.to) === '$props'
34+
);
35+
}
36+
37+
/**
38+
* Returns `true` if `$props()` is valid
39+
* TODO only allow in `.svelte` files, and only at the top level
40+
* @type {Test}
41+
*/
42+
function is_props(node, _, selected) {
43+
if (selected.type !== 'svelte') return false;
44+
45+
return (
46+
node.name === 'VariableName' &&
47+
node.parent?.name === 'VariableDeclaration' &&
48+
node.parent.parent?.name === 'Script'
49+
);
50+
}
51+
52+
/**
53+
* Returns `true` is this is a valid place to declare state
54+
* @type {Test}
55+
*/
56+
function is_state(node) {
57+
let parent = node.parent;
58+
59+
if (node.name === '.' || node.name === 'PropertyName') {
60+
if (parent?.name !== 'MemberExpression') return false;
61+
parent = parent.parent;
62+
}
63+
64+
if (!parent) return false;
65+
66+
return parent.name === 'VariableDeclaration' || parent.name === 'PropertyDeclaration';
67+
}
68+
69+
/**
70+
* Returns `true` if we're already in a valid call expression, e.g.
71+
* changing an existing `$state()` to `$state.frozen()`
72+
* @type {Test}
73+
*/
74+
function is_state_call(node) {
75+
let parent = node.parent;
76+
77+
if (node.name === '.' || node.name === 'PropertyName') {
78+
if (parent?.name !== 'MemberExpression') return false;
79+
parent = parent.parent;
80+
}
81+
82+
if (parent?.name !== 'CallExpression') {
83+
return false;
84+
}
85+
86+
parent = parent.parent;
87+
if (!parent) return false;
88+
89+
return parent.name === 'VariableDeclaration' || parent.name === 'PropertyDeclaration';
90+
}
91+
92+
/** @type {Test} */
93+
function is_statement(node) {
94+
if (node.name === 'VariableName') {
95+
return node.parent?.name === 'ExpressionStatement';
96+
}
97+
98+
if (node.name === '.' || node.name === 'PropertyName') {
99+
return node.parent?.parent?.name === 'ExpressionStatement';
100+
}
101+
102+
return false;
103+
}
104+
105+
/** @type {Array<{ snippet: string, test?: Test }>} */
106+
const runes = [
107+
{ snippet: '$state(${})', test: is_state },
108+
{ snippet: '$state', test: is_state_call },
109+
{ snippet: '$props()', test: is_props },
110+
{ snippet: '$derived(${});', test: is_state },
111+
{ snippet: '$derived', test: is_state_call },
112+
{ snippet: '$derived.by(() => {\n\t${}\n});', test: is_state },
113+
{ snippet: '$derived.by', test: is_state_call },
114+
{ snippet: '$effect(() => {\n\t${}\n});', test: is_statement },
115+
{ snippet: '$effect.pre(() => {\n\t${}\n});', test: is_statement },
116+
{ snippet: '$state.frozen(${});', test: is_state },
117+
{ snippet: '$state.frozen', test: is_state_call },
118+
{ snippet: '$bindable()', test: is_bindable },
119+
{ snippet: '$effect.root(() => {\n\t${}\n})' },
120+
{ snippet: '$state.snapshot(${})' },
121+
{ snippet: '$effect.active()' },
122+
{ snippet: '$inspect(${});', test: is_statement }
123+
];
124+
125+
const options = runes.map(({ snippet, test }, i) => ({
126+
option: snippetCompletion(snippet, {
127+
type: 'keyword',
128+
boost: runes.length - i,
129+
label: snippet.includes('(') ? snippet.slice(0, snippet.indexOf('(')) : snippet
130+
}),
131+
test
132+
}));
133+
134+
/**
135+
* @param {import('@codemirror/autocomplete').CompletionContext} context
136+
* @param {import('./types.js').File} selected
137+
* @param {import('./types.js').File[]} files
138+
*/
139+
export function autocomplete(context, selected, files) {
140+
let node = syntaxTree(context.state).resolveInner(context.pos, -1);
141+
142+
if (node.name === 'String' && node.parent?.name === 'ImportDeclaration') {
143+
const modules = [
144+
'svelte',
145+
'svelte/animate',
146+
'svelte/easing',
147+
'svelte/legacy',
148+
'svelte/motion',
149+
'svelte/reactivity',
150+
'svelte/store',
151+
'svelte/transition'
152+
];
153+
154+
for (const file of files) {
155+
if (file === selected) continue;
156+
modules.push(`./${file.name}.${file.type}`);
157+
}
158+
159+
return {
160+
from: node.from + 1,
161+
options: modules.map((label) => ({
162+
label,
163+
type: 'string'
164+
}))
165+
};
166+
}
167+
168+
if (
169+
selected.type !== 'svelte' &&
170+
(selected.type !== 'js' || !selected.name.endsWith('.svelte'))
171+
) {
172+
return false;
173+
}
174+
175+
if (node.name === 'VariableName' || node.name === 'PropertyName' || node.name === '.') {
176+
// special case — `$inspect(...).with(...)` is the only rune that 'returns'
177+
// an 'object' with a 'method'
178+
if (node.name === 'PropertyName' || node.name === '.') {
179+
if (
180+
node.parent?.name === 'MemberExpression' &&
181+
node.parent.firstChild?.name === 'CallExpression' &&
182+
node.parent.firstChild.firstChild?.name === 'VariableName' &&
183+
context.state.sliceDoc(
184+
node.parent.firstChild.firstChild.from,
185+
node.parent.firstChild.firstChild.to
186+
) === '$inspect'
187+
) {
188+
const open = context.matchBefore(/\.\w*/);
189+
if (!open) return null;
190+
191+
return {
192+
from: open.from,
193+
options: [snippetCompletion('.with(${})', { type: 'keyword', label: '.with' })]
194+
};
195+
}
196+
}
197+
198+
const open = context.matchBefore(/\$[\w\.]*/);
199+
if (!open) return null;
200+
201+
return {
202+
from: open.from,
203+
options: options
204+
.filter((option) => (option.test ? option.test(node, context, selected) : true))
205+
.map((option) => option.option)
206+
};
207+
}
208+
}

0 commit comments

Comments
 (0)