Skip to content

Commit

Permalink
feat: better REPL autocomplete (#11530)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Rich-Harris committed May 10, 2024
1 parent 59f4feb commit 4b7e002
Show file tree
Hide file tree
Showing 4 changed files with 222 additions and 51 deletions.
4 changes: 3 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions sites/svelte-5-preview/package.json
Expand Up @@ -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",
Expand Down
60 changes: 10 additions & 50 deletions sites/svelte-5-preview/src/lib/CodeMirror.svelte
Expand Up @@ -7,20 +7,21 @@
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;
export let readonly = false;
export let tab = true;
/** @type {boolean} */
export let autocomplete = true;
/** @type {ReturnType<typeof createEventDispatcher<{ change: { value: string } }>>} */
const dispatch = createEventDispatcher();
Expand Down Expand Up @@ -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>
Expand All @@ -266,7 +226,7 @@
},
lint: diagnostics,
lintOptions: { delay: 200 },
autocomplete,
autocomplete: true,
extensions: [svelte_rune_completions, js_rune_completions, watcher],
instanceStore: cmInstance
}}
Expand Down
208 changes: 208 additions & 0 deletions sites/svelte-5-preview/src/lib/autocomplete.js
@@ -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)
};
}
}

0 comments on commit 4b7e002

Please sign in to comment.