Add project-aware playground completion

This commit is contained in:
2026-08-13 18:07:24 +09:00
parent 610358110f
commit e862e53f3e
9 changed files with 416 additions and 12 deletions
+1
View File
@@ -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"
+4
View File
@@ -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"
+5 -5
View File
@@ -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);
});
+5 -5
View File
@@ -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({
+1
View File
@@ -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'));
});
+28 -2
View File
@@ -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}`;