Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
- Loading branch information
1 parent
59f4feb
commit 4b7e002
Showing
4 changed files
with
222 additions
and
51 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
}; | ||
} | ||
} |