Skip to content

Commit ff4258c

Browse files
committed
feat: make autocomplete more robust
1 parent 59f4feb commit ff4258c

File tree

4 files changed

+158
-52
lines changed

4 files changed

+158
-52
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: 6 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,20 @@
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';
1214
import Message from './Message.svelte';
1315
import { svelteTheme } from './theme.js';
16+
import { autocomplete } from './autocomplete.js';
1417
1518
/** @type {import('@codemirror/lint').LintSource | undefined} */
1619
export let diagnostics = undefined;
1720
1821
export let readonly = false;
1922
export let tab = true;
2023
21-
/** @type {boolean} */
22-
export let autocomplete = true;
23-
2424
/** @type {ReturnType<typeof createEventDispatcher<{ change: { value: string } }>>} */
2525
const dispatch = createEventDispatcher();
2626
@@ -192,57 +192,12 @@
192192
}
193193
});
194194
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-
}
239-
240195
const svelte_rune_completions = svelteLanguage.data.of({
241-
autocomplete: complete_svelte_runes
196+
autocomplete
242197
});
243198
244199
const js_rune_completions = javascriptLanguage.data.of({
245-
autocomplete: complete_svelte_runes
200+
autocomplete
246201
});
247202
</script>
248203
@@ -266,7 +221,7 @@
266221
},
267222
lint: diagnostics,
268223
lintOptions: { delay: 200 },
269-
autocomplete,
224+
autocomplete: true,
270225
extensions: [svelte_rune_completions, js_rune_completions, watcher],
271226
instanceStore: cmInstance
272227
}}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
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) => 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) {
43+
return node.name === 'VariableName' && node.parent?.name === 'VariableDeclaration';
44+
}
45+
46+
/**
47+
* Returns `true` is this is a valid place to declare state
48+
* @type {Test}
49+
*/
50+
function is_state(node) {
51+
let parent = node.parent;
52+
53+
if (node.name === '.' || node.name === 'PropertyName') {
54+
if (parent?.name !== 'MemberExpression') return false;
55+
parent = parent.parent;
56+
}
57+
58+
if (!parent) return false;
59+
60+
return parent.name === 'VariableDeclaration' || parent.name === 'PropertyDeclaration';
61+
}
62+
63+
/**
64+
* Returns `true` if we're already in a valid call expression, e.g.
65+
* changing an existing `$state()` to `$state.frozen()`
66+
* @type {Test}
67+
*/
68+
function is_state_call(node) {
69+
let parent = node.parent;
70+
71+
if (node.name === '.' || node.name === 'PropertyName') {
72+
if (parent?.name !== 'MemberExpression') return false;
73+
parent = parent.parent;
74+
}
75+
76+
if (parent?.name !== 'CallExpression') {
77+
return false;
78+
}
79+
80+
parent = parent.parent;
81+
if (!parent) return false;
82+
83+
return parent.name === 'VariableDeclaration' || parent.name === 'PropertyDeclaration';
84+
}
85+
86+
/** @type {Test} */
87+
function is_statement(node) {
88+
if (node.name === 'VariableName') {
89+
return node.parent?.name === 'ExpressionStatement';
90+
}
91+
92+
if (node.name === '.' || node.name === 'PropertyName') {
93+
return node.parent?.parent?.name === 'ExpressionStatement';
94+
}
95+
96+
return false;
97+
}
98+
99+
/** @type {Array<{ snippet: string, test?: Test }>} */
100+
const runes = [
101+
{ snippet: '$state(${})', test: is_state },
102+
{ snippet: '$state', test: is_state_call },
103+
{ snippet: '$props()', test: is_props },
104+
{ snippet: '$derived(${});', test: is_state },
105+
{ snippet: '$derived', test: is_state_call },
106+
{ snippet: '$derived.by(() => {\n\t${}\n});', test: is_state },
107+
{ snippet: '$derived.by', test: is_state_call },
108+
{ snippet: '$effect(() => {\n\t${}\n});', test: is_statement },
109+
{ snippet: '$effect.pre(() => {\n\t${}\n});', test: is_statement },
110+
{ snippet: '$state.frozen(${});', test: is_state },
111+
{ snippet: '$state.frozen', test: is_state_call },
112+
{ snippet: '$bindable()', test: is_bindable },
113+
{ snippet: '$effect.root(() => {\n\t${}\n})' },
114+
{ snippet: '$state.snapshot(${})' },
115+
{ snippet: '$effect.active()' },
116+
{ snippet: '$inspect(${});', test: is_statement }
117+
];
118+
119+
const options = runes.map(({ snippet, test }, i) => ({
120+
option: snippetCompletion(snippet, {
121+
type: 'keyword',
122+
boost: runes.length - i,
123+
label: snippet.includes('(') ? snippet.slice(0, snippet.indexOf('(')) : snippet
124+
}),
125+
test
126+
}));
127+
128+
/** @param {import('@codemirror/autocomplete').CompletionContext} context */
129+
export function autocomplete(context) {
130+
let node = syntaxTree(context.state).resolveInner(context.pos, -1);
131+
132+
if (node.name === 'String' && node.parent?.name === 'ImportDeclaration') {
133+
// TODO autocomplete `svelte` and any files in the REPL that aren't this one
134+
return;
135+
}
136+
137+
if (node.name === 'VariableName' || node.name === 'PropertyName' || node.name === '.') {
138+
const open = context.matchBefore(/\$[\w\.]*/);
139+
if (!open) return null;
140+
141+
return {
142+
from: open.from,
143+
options: options
144+
.filter((option) => (option.test ? option.test(node, context) : true))
145+
.map((option) => option.option)
146+
};
147+
}
148+
}

0 commit comments

Comments
 (0)