Add project-aware playground completion
This commit is contained in:
+1
@@ -10,6 +10,7 @@
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@codemirror/language": "^6.12.4",
|
||||
"@codemirror/state": "^6.7.1",
|
||||
"@codemirror/view": "^6.43.6",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@lezer/lr": "^1.4.10"
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
"url": "git+https://gitea.hareworks.net/Hare/Decodal.git",
|
||||
"directory": "packages/decodal-codemirror"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test src/*.test.mjs"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/decodal.d.ts",
|
||||
@@ -44,6 +47,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@codemirror/language": "^6.12.4",
|
||||
"@codemirror/state": "^6.7.1",
|
||||
"@codemirror/view": "^6.43.6",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@lezer/lr": "^1.4.10"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LRLanguage, LanguageSupport, HighlightStyle, syntaxHighlighting, foldNodeProp, indentNodeProp } from '@codemirror/language';
|
||||
import { LRLanguage, LanguageSupport, HighlightStyle, syntaxHighlighting, delimitedIndent, foldNodeProp, indentNodeProp } from '@codemirror/language';
|
||||
import { styleTags, tags as t } from '@lezer/highlight';
|
||||
import { parser } from './decodal-parser.js';
|
||||
|
||||
@@ -19,10 +19,10 @@ const parserWithMetadata = parser.configure({
|
||||
'Semicolon Comma Dot Colon': t.punctuation,
|
||||
}),
|
||||
indentNodeProp.add({
|
||||
Object: context => context.column(context.node.from) + context.unit,
|
||||
Array: context => context.column(context.node.from) + context.unit,
|
||||
ArrayConstraint: context => context.column(context.node.from) + context.unit,
|
||||
MatchExpression: context => context.column(context.node.from) + context.unit,
|
||||
Object: delimitedIndent({ closing: '}', align: false }),
|
||||
Array: delimitedIndent({ closing: ']', align: false }),
|
||||
ArrayConstraint: delimitedIndent({ closing: ']', align: false }),
|
||||
MatchExpression: delimitedIndent({ closing: '}', align: false }),
|
||||
LetExpression: context => context.column(context.node.from) + context.unit,
|
||||
}),
|
||||
foldNodeProp.add({
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { getIndentation, indentUnit } from '@codemirror/language';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
|
||||
import { decodal } from './decodal.js';
|
||||
|
||||
function indentationAt(doc, lineNumber) {
|
||||
const state = EditorState.create({
|
||||
doc,
|
||||
extensions: [decodal(), indentUnit.of(' ')],
|
||||
});
|
||||
return getIndentation(state, state.doc.line(lineNumber).from);
|
||||
}
|
||||
|
||||
test('indents an inline object from its containing line', () => {
|
||||
const doc = `hoge = fuga & piyo & {
|
||||
|
||||
}`;
|
||||
|
||||
assert.equal(indentationAt(doc, 2), 2);
|
||||
assert.equal(indentationAt(doc, 3), 0);
|
||||
});
|
||||
|
||||
test('preserves the surrounding indentation for a nested inline object', () => {
|
||||
const doc = `root = {
|
||||
hoge = fuga & piyo & {
|
||||
|
||||
};
|
||||
};`;
|
||||
|
||||
assert.equal(indentationAt(doc, 3), 4);
|
||||
assert.equal(indentationAt(doc, 4), 2);
|
||||
});
|
||||
@@ -8,7 +8,7 @@
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
import { HighlightStyle, LRLanguage, LanguageSupport, foldNodeProp, indentNodeProp, syntaxHighlighting } from 'npm:@codemirror/language@^6.12.4';
|
||||
import { HighlightStyle, LRLanguage, LanguageSupport, delimitedIndent, foldNodeProp, indentNodeProp, syntaxHighlighting } from 'npm:@codemirror/language@^6.12.4';
|
||||
import { styleTags, tags as t } from 'npm:@lezer/highlight@^1.2.3';
|
||||
import { parser } from './decodal-parser-jsr.js';
|
||||
|
||||
@@ -29,10 +29,10 @@ const parserWithMetadata = parser.configure({
|
||||
'Semicolon Comma Dot Colon': t.punctuation,
|
||||
}),
|
||||
indentNodeProp.add({
|
||||
Object: context => context.column(context.node.from) + context.unit,
|
||||
Array: context => context.column(context.node.from) + context.unit,
|
||||
ArrayConstraint: context => context.column(context.node.from) + context.unit,
|
||||
MatchExpression: context => context.column(context.node.from) + context.unit,
|
||||
Object: delimitedIndent({ closing: '}', align: false }),
|
||||
Array: delimitedIndent({ closing: ']', align: false }),
|
||||
ArrayConstraint: delimitedIndent({ closing: ']', align: false }),
|
||||
MatchExpression: delimitedIndent({ closing: '}', align: false }),
|
||||
LetExpression: context => context.column(context.node.from) + context.unit,
|
||||
}),
|
||||
foldNodeProp.add({
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "astro dev --host 0.0.0.0",
|
||||
"build": "astro build",
|
||||
"test": "node --test src/scripts/*.test.mjs",
|
||||
"preview": "astro preview --host 0.0.0.0",
|
||||
"build:runtime": "wasm-pack build ../../crates/decodal-wasm --target web --out-dir ../../packages/decodal-wasm --release && node scripts/prepare-wasm-package.mjs",
|
||||
"build:tools": "wasm-pack build ../../crates/decodal-language-tools --target web --out-dir ../../packages/decodal-codemirror/wasm --release && node scripts/prepare-codemirror-wasm.mjs",
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { parser } from 'decodal-codemirror/parser';
|
||||
|
||||
const identifierPattern = /[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
const memberPattern = /([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\.([A-Za-z_][A-Za-z0-9_]*)?$/;
|
||||
const importPattern = /\bimport\s+"([^"\n]*)$/;
|
||||
|
||||
const builtinCompletions = [
|
||||
{ label: 'let', type: 'keyword', detail: 'local bindings', boost: 5 },
|
||||
{ label: 'in', type: 'keyword', detail: 'let body' },
|
||||
{ label: 'match', type: 'keyword', detail: 'pattern matching', boost: 5 },
|
||||
{ label: 'import', type: 'keyword', detail: 'load a module', boost: 5 },
|
||||
{ label: 'default', type: 'keyword', detail: 'fallback value' },
|
||||
{ label: 'true', type: 'constant', detail: 'Bool' },
|
||||
{ label: 'false', type: 'constant', detail: 'Bool' },
|
||||
{ label: 'String', type: 'type', detail: 'string constraint', boost: 5 },
|
||||
{ label: 'Int', type: 'type', detail: 'integer constraint', boost: 5 },
|
||||
{ label: 'Float', type: 'type', detail: 'float constraint', boost: 5 },
|
||||
{ label: 'Bool', type: 'type', detail: 'boolean constraint', boost: 5 },
|
||||
];
|
||||
|
||||
export function createProjectCompletionSource({ getFiles, getActivePath }) {
|
||||
return (context) => getProjectCompletions({
|
||||
source: context.state.doc.toString(),
|
||||
position: context.pos,
|
||||
explicit: context.explicit,
|
||||
files: getFiles(),
|
||||
activePath: getActivePath(),
|
||||
});
|
||||
}
|
||||
|
||||
export function getProjectCompletions({
|
||||
source,
|
||||
position,
|
||||
explicit = false,
|
||||
files = {},
|
||||
activePath = '',
|
||||
}) {
|
||||
const before = source.slice(0, position);
|
||||
const projectFiles = new Map(Object.entries(files));
|
||||
const normalizedActivePath = normalizePath(activePath);
|
||||
if (normalizedActivePath) projectFiles.set(normalizedActivePath, source);
|
||||
|
||||
const importMatch = before.match(importPattern);
|
||||
if (importMatch) {
|
||||
return completeImportPath(position, importMatch[1], projectFiles, normalizedActivePath);
|
||||
}
|
||||
|
||||
if (isLiteralOrComment(source, position)) return null;
|
||||
|
||||
const memberMatch = before.match(memberPattern);
|
||||
if (memberMatch) {
|
||||
return completeMemberPath({
|
||||
source,
|
||||
position,
|
||||
basePath: memberMatch[1],
|
||||
prefix: memberMatch[2] ?? '',
|
||||
files: projectFiles,
|
||||
activePath: normalizedActivePath,
|
||||
});
|
||||
}
|
||||
|
||||
const word = before.match(identifierPattern);
|
||||
if (!word && !explicit) return null;
|
||||
const options = [...builtinCompletions];
|
||||
const currentFields = collectFieldTree(source);
|
||||
for (const [label, children] of currentFields) {
|
||||
options.push({
|
||||
label,
|
||||
type: children.size ? 'namespace' : 'variable',
|
||||
detail: children.size ? 'local object' : 'local value',
|
||||
boost: 20,
|
||||
});
|
||||
}
|
||||
for (const label of collectParameters(source)) {
|
||||
options.push({ label, type: 'variable', detail: 'parameter', boost: 20 });
|
||||
}
|
||||
for (const [label, specifier] of collectImportBindings(source)) {
|
||||
options.push({ label, type: 'namespace', detail: specifier, boost: 30 });
|
||||
}
|
||||
|
||||
return {
|
||||
from: word ? position - word[0].length : position,
|
||||
options: uniqueOptions(options),
|
||||
validFor: /^[A-Za-z_][A-Za-z0-9_]*$/,
|
||||
};
|
||||
}
|
||||
|
||||
function completeImportPath(position, prefix, files, activePath) {
|
||||
const options = [];
|
||||
for (const path of [...files.keys()].sort()) {
|
||||
if (!path || path === activePath) continue;
|
||||
const label = prefix.startsWith('/')
|
||||
? `/${path}`
|
||||
: relativeImportPath(activePath, path);
|
||||
options.push({
|
||||
label,
|
||||
apply: label,
|
||||
type: 'text',
|
||||
detail: 'project file',
|
||||
});
|
||||
}
|
||||
return {
|
||||
from: position - prefix.length,
|
||||
options,
|
||||
validFor: /^[^"\n]*$/,
|
||||
};
|
||||
}
|
||||
|
||||
function completeMemberPath({ source, position, basePath, prefix, files, activePath }) {
|
||||
const parts = basePath.split('.');
|
||||
const imports = collectImportBindings(source);
|
||||
let fields;
|
||||
let detail = basePath;
|
||||
|
||||
const importSpecifier = imports.get(parts[0]);
|
||||
if (importSpecifier) {
|
||||
const importedPath = resolveImportPath(activePath, importSpecifier);
|
||||
const importedSource = importedPath ? files.get(importedPath) : undefined;
|
||||
if (importedSource === undefined) return null;
|
||||
fields = collectFieldTree(importedSource);
|
||||
parts.shift();
|
||||
detail = importedPath;
|
||||
} else {
|
||||
fields = collectFieldTree(source);
|
||||
}
|
||||
|
||||
for (const part of parts) {
|
||||
fields = fields.get(part);
|
||||
if (!fields) return null;
|
||||
}
|
||||
|
||||
return {
|
||||
from: position - prefix.length,
|
||||
options: [...fields.entries()].map(([label, children]) => ({
|
||||
label,
|
||||
type: children.size ? 'namespace' : 'property',
|
||||
detail,
|
||||
boost: 30,
|
||||
})),
|
||||
validFor: /^[A-Za-z_][A-Za-z0-9_]*$/,
|
||||
};
|
||||
}
|
||||
|
||||
function collectFieldTree(source) {
|
||||
const fields = new Map();
|
||||
collectDefinitions(parser.parse(source).topNode, fields, source);
|
||||
return fields;
|
||||
}
|
||||
|
||||
function collectDefinitions(node, target, source) {
|
||||
for (let child = node.firstChild; child; child = child.nextSibling) {
|
||||
if (child.name === 'FieldDefinition') {
|
||||
collectDefinition(child, target, source);
|
||||
} else {
|
||||
collectDefinitions(child, target, source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectDefinition(node, target, source) {
|
||||
const fieldPath = findDirectChild(node, 'FieldPath');
|
||||
if (!fieldPath) return;
|
||||
const parts = source.slice(fieldPath.from, fieldPath.to).match(/[A-Za-z_][A-Za-z0-9_]*/g);
|
||||
if (!parts?.length) return;
|
||||
let nested = target;
|
||||
for (const part of parts) {
|
||||
if (!nested.has(part)) nested.set(part, new Map());
|
||||
nested = nested.get(part);
|
||||
}
|
||||
for (let child = node.firstChild; child; child = child.nextSibling) {
|
||||
if (child !== fieldPath) collectDefinitions(child, nested, source);
|
||||
}
|
||||
}
|
||||
|
||||
function collectParameters(source) {
|
||||
const parameters = new Set();
|
||||
const tree = parser.parse(source);
|
||||
const visit = (node) => {
|
||||
for (let child = node.firstChild; child; child = child.nextSibling) {
|
||||
if (child.name === 'Parameter') {
|
||||
const identifier = findDirectChild(child, 'Identifier');
|
||||
if (identifier) parameters.add(source.slice(identifier.from, identifier.to));
|
||||
}
|
||||
visit(child);
|
||||
}
|
||||
};
|
||||
visit(tree.topNode);
|
||||
return parameters;
|
||||
}
|
||||
|
||||
function collectImportBindings(source) {
|
||||
const bindings = new Map();
|
||||
const pattern = /\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*import\s+"([^"\n]+)"/g;
|
||||
for (const match of source.matchAll(pattern)) bindings.set(match[1], match[2]);
|
||||
return bindings;
|
||||
}
|
||||
|
||||
function isLiteralOrComment(source, position) {
|
||||
const tree = parser.parse(source);
|
||||
let node = tree.resolve(Math.max(0, position - 1), -1);
|
||||
while (node) {
|
||||
if (node.name === 'String' || node.name === 'Regex' || node.name === 'Comment') return true;
|
||||
node = node.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function findDirectChild(node, name) {
|
||||
for (let child = node.firstChild; child; child = child.nextSibling) {
|
||||
if (child.name === name) return child;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveImportPath(currentPath, specifier) {
|
||||
const base = specifier.startsWith('/')
|
||||
? []
|
||||
: currentPath.split('/').slice(0, -1);
|
||||
return normalizePath([...base, ...specifier.split('/')].join('/'));
|
||||
}
|
||||
|
||||
function relativeImportPath(currentPath, targetPath) {
|
||||
const from = currentPath.split('/').slice(0, -1);
|
||||
const to = targetPath.split('/');
|
||||
while (from.length && to.length && from[0] === to[0]) {
|
||||
from.shift();
|
||||
to.shift();
|
||||
}
|
||||
const relative = [...from.map(() => '..'), ...to].join('/');
|
||||
return relative.startsWith('.') ? relative : `./${relative}`;
|
||||
}
|
||||
|
||||
function normalizePath(path) {
|
||||
const parts = [];
|
||||
for (const part of String(path).replaceAll('\\', '/').split('/')) {
|
||||
if (!part || part === '.') continue;
|
||||
if (part === '..') {
|
||||
if (!parts.length) return '';
|
||||
parts.pop();
|
||||
} else {
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
function uniqueOptions(options) {
|
||||
const unique = new Map();
|
||||
for (const option of options) {
|
||||
const previous = unique.get(option.label);
|
||||
if (!previous || (option.boost ?? 0) > (previous.boost ?? 0)) {
|
||||
unique.set(option.label, option);
|
||||
}
|
||||
}
|
||||
return [...unique.values()];
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { getProjectCompletions } from './playground-completion.js';
|
||||
|
||||
const files = {
|
||||
'main.dcdl': '',
|
||||
'schemas/service.dcdl': `Service = {
|
||||
name = String;
|
||||
port = Int default 8080;
|
||||
resources = { cpu = Int; memory = Int; };
|
||||
};`,
|
||||
'env/production.dcdl': `capacity = {
|
||||
base_replicas = 6;
|
||||
cpu_milli = 2000;
|
||||
};`,
|
||||
};
|
||||
|
||||
function complete(source) {
|
||||
return getProjectCompletions({
|
||||
source,
|
||||
position: source.length,
|
||||
files,
|
||||
activePath: 'main.dcdl',
|
||||
});
|
||||
}
|
||||
|
||||
test('completes virtual import paths', () => {
|
||||
const result = complete('schema = import "./sch');
|
||||
assert.ok(result.options.some((option) => option.label === './schemas/service.dcdl'));
|
||||
});
|
||||
|
||||
test('completes imported module fields', () => {
|
||||
const result = complete('let schema = import "./schemas/service.dcdl"; in schema.');
|
||||
assert.deepEqual(
|
||||
result.options.map((option) => option.label),
|
||||
['Service'],
|
||||
);
|
||||
});
|
||||
|
||||
test('completes nested schema fields', () => {
|
||||
const result = complete('let schema = import "./schemas/service.dcdl"; in schema.Service.');
|
||||
assert.deepEqual(
|
||||
result.options.map((option) => option.label),
|
||||
['name', 'port', 'resources'],
|
||||
);
|
||||
});
|
||||
|
||||
test('replaces only the partial member name', () => {
|
||||
const source = 'let schema = import "./schemas/service.dcdl"; in schema.Service.po';
|
||||
const result = complete(source);
|
||||
assert.ok(result.options.some((option) => option.label === 'port'));
|
||||
assert.equal(result.from, source.length - 2);
|
||||
});
|
||||
|
||||
test('completes nested values from another virtual file', () => {
|
||||
const result = complete('let env = import "./env/production.dcdl"; in env.capacity.');
|
||||
assert.deepEqual(
|
||||
result.options.map((option) => option.label),
|
||||
['base_replicas', 'cpu_milli'],
|
||||
);
|
||||
});
|
||||
|
||||
test('includes language and local binding completions', () => {
|
||||
const source = 'let service = { port = 8080; }; in ser';
|
||||
const result = complete(source);
|
||||
assert.ok(result.options.some((option) => option.label === 'service'));
|
||||
assert.ok(result.options.some((option) => option.label === 'String'));
|
||||
assert.equal(result.from, source.length - 3);
|
||||
});
|
||||
|
||||
test('resolves imports relative to nested active files', () => {
|
||||
const source = 'schema = import "../schemas/serv';
|
||||
const result = getProjectCompletions({
|
||||
source,
|
||||
position: source.length,
|
||||
files,
|
||||
activePath: 'env/main.dcdl',
|
||||
});
|
||||
assert.ok(result.options.some((option) => option.label === '../schemas/service.dcdl'));
|
||||
});
|
||||
@@ -2,10 +2,11 @@ import initRuntime, { evaluateProject } from 'decodal-wasm';
|
||||
import runtimeWasmUrl from 'decodal-wasm/decodal_wasm_bg.wasm?url';
|
||||
import { EditorView, basicSetup } from 'codemirror';
|
||||
import { keymap } from '@codemirror/view';
|
||||
import { decodal } from 'decodal-codemirror';
|
||||
import { decodal, decodalLanguage } from 'decodal-codemirror';
|
||||
import { formatDecodal, initDecodalFormatter } from 'decodal-codemirror/format';
|
||||
import toolsWasmUrl from 'decodal-codemirror/wasm/decodal_language_tools_bg.wasm?url';
|
||||
import { playgroundExamples } from './playground-examples.js';
|
||||
import { createProjectCompletionSource } from './playground-completion.js';
|
||||
|
||||
const STORAGE_KEY = 'decodal-playground-project-v2';
|
||||
const starterProject = playgroundExamples[0];
|
||||
@@ -56,14 +57,39 @@ const editorTheme = EditorView.theme({
|
||||
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection': {
|
||||
backgroundColor: 'rgb(59 130 246 / 0.35)',
|
||||
},
|
||||
'.cm-tooltip-autocomplete': {
|
||||
backgroundColor: '#111827',
|
||||
border: '1px solid #334155',
|
||||
borderRadius: '8px',
|
||||
color: '#e5e7eb',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
'.cm-tooltip-autocomplete > ul > li': {
|
||||
padding: '3px 8px',
|
||||
},
|
||||
'.cm-tooltip-autocomplete > ul > li[aria-selected]': {
|
||||
backgroundColor: '#1d4ed8',
|
||||
color: '#fff',
|
||||
},
|
||||
'.cm-completionDetail': {
|
||||
color: '#94a3b8',
|
||||
fontStyle: 'normal',
|
||||
marginLeft: '1.5em',
|
||||
},
|
||||
}, { dark: true });
|
||||
|
||||
const completionSource = createProjectCompletionSource({
|
||||
getFiles: () => project.files,
|
||||
getActivePath: () => project.activePath,
|
||||
});
|
||||
|
||||
const editor = new EditorView({
|
||||
doc: project.files[project.activePath] ?? '',
|
||||
parent: editorHost,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
decodal(),
|
||||
decodalLanguage.data.of({ autocomplete: completionSource }),
|
||||
editorTheme,
|
||||
keymap.of([
|
||||
{
|
||||
@@ -288,7 +314,7 @@ try {
|
||||
await Promise.all([initRuntime(runtimeWasmUrl), initDecodalFormatter(toolsWasmUrl)]);
|
||||
run.disabled = false;
|
||||
formatButton.disabled = false;
|
||||
status.textContent = '';
|
||||
status.textContent = 'Ctrl+Space: complete';
|
||||
execute();
|
||||
} catch (error) {
|
||||
status.textContent = `Failed to load WASM: ${error?.message ?? error}`;
|
||||
|
||||
Reference in New Issue
Block a user