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
pull/11539/head
Rich Harris 8 months ago committed by GitHub
parent 59f4feb4d8
commit 4b7e0025a8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -250,6 +250,9 @@ importers:
'@fontsource/fira-mono':
specifier: ^5.0.8
version: 5.0.8
'@lezer/common':
specifier: ^1.2.1
version: 1.2.1
'@sveltejs/adapter-static':
specifier: ^3.0.1
version: 3.0.1(@sveltejs/kit@2.5.2)
@ -1769,7 +1772,6 @@ packages:
/@lezer/common@1.2.1:
resolution: {integrity: sha512-yemX0ZD2xS/73llMZIK6KplkjIjf2EvAHcinDi/TfJ9hS25G0388+ClHt6/3but0oOxinTcQHJLDXh6w1crzFQ==}
dev: false
/@lezer/css@1.1.7:
resolution: {integrity: sha512-7BlFFAKNn/b39jJLrhdLSX5A2k56GIJvyLqdmm7UU+7XvequY084iuKDMAEhAmAzHnwDE8FK4OQtsIUssW91tg==}

@ -13,6 +13,7 @@
},
"devDependencies": {
"@fontsource/fira-mono": "^5.0.8",
"@lezer/common": "^1.2.1",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/adapter-vercel": "^5.0.0",
"@sveltejs/kit": "^2.5.0",

@ -7,10 +7,14 @@
import { EditorState, Range, StateEffect, StateEffectType, StateField } from '@codemirror/state';
import { Decoration, EditorView } from '@codemirror/view';
import { codemirror, withCodemirrorInstance } from '@neocodemirror/svelte';
import { svelteLanguage } from '@replit/codemirror-lang-svelte';
import { javascriptLanguage } from '@codemirror/lang-javascript';
import { createEventDispatcher, tick } from 'svelte';
import { writable } from 'svelte/store';
import { get_repl_context } from '$lib/context.js';
import Message from './Message.svelte';
import { svelteTheme } from './theme.js';
import { autocomplete } from './autocomplete.js';
/** @type {import('@codemirror/lint').LintSource | undefined} */
export let diagnostics = undefined;
@ -18,9 +22,6 @@
export let readonly = false;
export let tab = true;
/** @type {boolean} */
export let autocomplete = true;
/** @type {ReturnType<typeof createEventDispatcher<{ change: { value: string } }>>} */
const dispatch = createEventDispatcher();
@ -192,57 +193,16 @@
}
});
import { svelteLanguage } from '@replit/codemirror-lang-svelte';
import { javascriptLanguage } from '@codemirror/lang-javascript';
import { snippetCompletion as snip } from '@codemirror/autocomplete';
/** @param {any} context */
function complete_svelte_runes(context) {
const word = context.matchBefore(/\w*/);
if (word.from === word.to && context.state.sliceDoc(word.from - 1, word.to) !== '$') {
return null;
}
return {
from: word.from - 1,
options: [
{ label: '$state', type: 'keyword', boost: 12 },
{ label: '$props', type: 'keyword', boost: 11 },
{ label: '$derived', type: 'keyword', boost: 10 },
snip('$derived.by(() => {\n\t${}\n});', {
label: '$derived.by',
type: 'keyword',
boost: 9
}),
snip('$effect(() => {\n\t${}\n});', { label: '$effect', type: 'keyword', boost: 8 }),
snip('$effect.pre(() => {\n\t${}\n});', {
label: '$effect.pre',
type: 'keyword',
boost: 7
}),
{ label: '$state.frozen', type: 'keyword', boost: 6 },
{ label: '$bindable', type: 'keyword', boost: 5 },
snip('$effect.root(() => {\n\t${}\n});', {
label: '$effect.root',
type: 'keyword',
boost: 4
}),
{ label: '$state.snapshot', type: 'keyword', boost: 3 },
snip('$effect.active()', {
label: '$effect.active',
type: 'keyword',
boost: 2
}),
{ label: '$inspect', type: 'keyword', boost: 1 }
]
};
}
const { files, selected } = get_repl_context();
const svelte_rune_completions = svelteLanguage.data.of({
autocomplete: complete_svelte_runes
/** @param {import('@codemirror/autocomplete').CompletionContext} context */
autocomplete: (context) => autocomplete(context, $selected, $files)
});
const js_rune_completions = javascriptLanguage.data.of({
autocomplete: complete_svelte_runes
/** @param {import('@codemirror/autocomplete').CompletionContext} context */
autocomplete: (context) => autocomplete(context, $selected, $files)
});
</script>
@ -266,7 +226,7 @@
},
lint: diagnostics,
lintOptions: { delay: 200 },
autocomplete,
autocomplete: true,
extensions: [svelte_rune_completions, js_rune_completions, watcher],
instanceStore: cmInstance
}}

@ -0,0 +1,208 @@
import { snippetCompletion } from '@codemirror/autocomplete';
import { syntaxTree } from '@codemirror/language';
/** @typedef {(node: import('@lezer/common').SyntaxNode, context: import('@codemirror/autocomplete').CompletionContext, selected: import('./types').File) => boolean} Test */
/**
* Returns `true` if `$bindable()` is valid
* @type {Test}
*/
function is_bindable(node, context) {
// disallow outside `let { x = $bindable }`
if (node.parent?.name !== 'PatternProperty') return false;
if (node.parent.parent?.name !== 'ObjectPattern') return false;
if (node.parent.parent.parent?.name !== 'VariableDeclaration') return false;
let last = node.parent.parent.parent.lastChild;
if (!last) return true;
// if the declaration is incomplete, assume the best
if (last.name === 'ObjectPattern' || last.name === 'Equals' || last.name === '⚠') {
return true;
}
if (last.name === ';') {
last = last.prevSibling;
if (!last || last.name === '⚠') return true;
}
// if the declaration is complete, only return true if it is a `$props()` declaration
return (
last.name === 'CallExpression' &&
last.firstChild?.name === 'VariableName' &&
context.state.sliceDoc(last.firstChild.from, last.firstChild.to) === '$props'
);
}
/**
* Returns `true` if `$props()` is valid
* TODO only allow in `.svelte` files, and only at the top level
* @type {Test}
*/
function is_props(node, _, selected) {
if (selected.type !== 'svelte') return false;
return (
node.name === 'VariableName' &&
node.parent?.name === 'VariableDeclaration' &&
node.parent.parent?.name === 'Script'
);
}
/**
* Returns `true` is this is a valid place to declare state
* @type {Test}
*/
function is_state(node) {
let parent = node.parent;
if (node.name === '.' || node.name === 'PropertyName') {
if (parent?.name !== 'MemberExpression') return false;
parent = parent.parent;
}
if (!parent) return false;
return parent.name === 'VariableDeclaration' || parent.name === 'PropertyDeclaration';
}
/**
* Returns `true` if we're already in a valid call expression, e.g.
* changing an existing `$state()` to `$state.frozen()`
* @type {Test}
*/
function is_state_call(node) {
let parent = node.parent;
if (node.name === '.' || node.name === 'PropertyName') {
if (parent?.name !== 'MemberExpression') return false;
parent = parent.parent;
}
if (parent?.name !== 'CallExpression') {
return false;
}
parent = parent.parent;
if (!parent) return false;
return parent.name === 'VariableDeclaration' || parent.name === 'PropertyDeclaration';
}
/** @type {Test} */
function is_statement(node) {
if (node.name === 'VariableName') {
return node.parent?.name === 'ExpressionStatement';
}
if (node.name === '.' || node.name === 'PropertyName') {
return node.parent?.parent?.name === 'ExpressionStatement';
}
return false;
}
/** @type {Array<{ snippet: string, test?: Test }>} */
const runes = [
{ snippet: '$state(${})', test: is_state },
{ snippet: '$state', test: is_state_call },
{ snippet: '$props()', test: is_props },
{ snippet: '$derived(${});', test: is_state },
{ snippet: '$derived', test: is_state_call },
{ snippet: '$derived.by(() => {\n\t${}\n});', test: is_state },
{ snippet: '$derived.by', test: is_state_call },
{ snippet: '$effect(() => {\n\t${}\n});', test: is_statement },
{ snippet: '$effect.pre(() => {\n\t${}\n});', test: is_statement },
{ snippet: '$state.frozen(${});', test: is_state },
{ snippet: '$state.frozen', test: is_state_call },
{ snippet: '$bindable()', test: is_bindable },
{ snippet: '$effect.root(() => {\n\t${}\n})' },
{ snippet: '$state.snapshot(${})' },
{ snippet: '$effect.active()' },
{ snippet: '$inspect(${});', test: is_statement }
];
const options = runes.map(({ snippet, test }, i) => ({
option: snippetCompletion(snippet, {
type: 'keyword',
boost: runes.length - i,
label: snippet.includes('(') ? snippet.slice(0, snippet.indexOf('(')) : snippet
}),
test
}));
/**
* @param {import('@codemirror/autocomplete').CompletionContext} context
* @param {import('./types.js').File} selected
* @param {import('./types.js').File[]} files
*/
export function autocomplete(context, selected, files) {
let node = syntaxTree(context.state).resolveInner(context.pos, -1);
if (node.name === 'String' && node.parent?.name === 'ImportDeclaration') {
const modules = [
'svelte',
'svelte/animate',
'svelte/easing',
'svelte/legacy',
'svelte/motion',
'svelte/reactivity',
'svelte/store',
'svelte/transition'
];
for (const file of files) {
if (file === selected) continue;
modules.push(`./${file.name}.${file.type}`);
}
return {
from: node.from + 1,
options: modules.map((label) => ({
label,
type: 'string'
}))
};
}
if (
selected.type !== 'svelte' &&
(selected.type !== 'js' || !selected.name.endsWith('.svelte'))
) {
return false;
}
if (node.name === 'VariableName' || node.name === 'PropertyName' || node.name === '.') {
// special case — `$inspect(...).with(...)` is the only rune that 'returns'
// an 'object' with a 'method'
if (node.name === 'PropertyName' || node.name === '.') {
if (
node.parent?.name === 'MemberExpression' &&
node.parent.firstChild?.name === 'CallExpression' &&
node.parent.firstChild.firstChild?.name === 'VariableName' &&
context.state.sliceDoc(
node.parent.firstChild.firstChild.from,
node.parent.firstChild.firstChild.to
) === '$inspect'
) {
const open = context.matchBefore(/\.\w*/);
if (!open) return null;
return {
from: open.from,
options: [snippetCompletion('.with(${})', { type: 'keyword', label: '.with' })]
};
}
}
const open = context.matchBefore(/\$[\w\.]*/);
if (!open) return null;
return {
from: open.from,
options: options
.filter((option) => (option.test ? option.test(node, context, selected) : true))
.map((option) => option.option)
};
}
}
Loading…
Cancel
Save