Add project-aware playground completion
This commit is contained in:
@@ -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