Share host-aware completion across runtimes

This commit is contained in:
2026-08-13 18:55:03 +09:00
parent e862e53f3e
commit d28edcf041
25 changed files with 2836 additions and 578 deletions
@@ -1,256 +1,46 @@
import { parser } from 'decodal-codemirror/parser';
const completionTypes = {
keyword: 'keyword',
constant: 'constant',
type: 'type',
variable: 'variable',
namespace: 'namespace',
property: 'property',
file: 'text',
};
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_]*$/,
export function createLanguageServiceCompletionSource({ complete, getActivePath }) {
return (context) => {
const activePath = getActivePath();
let response;
try {
response = complete({
key: activePath,
source: context.state.doc.toString(),
position: context.pos,
explicit: context.explicit,
});
if (!response) return null;
if (typeof response === 'string') response = JSON.parse(response);
} catch (_error) {
// The editor can become interactive just before WASM initialization
// finishes. A later completion request will use the initialized service.
return null;
}
if (!response.ok || !response.completion) return null;
return completionResultToCodeMirror(response.completion);
};
}
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',
});
}
export function completionResultToCodeMirror(completion) {
const fileCompletion = completion.options.some((option) => option.kind === '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,
from: completion.from,
options: completion.options.map((option) => ({
label: option.label,
type: completionTypes[option.kind] ?? 'text',
detail: option.detail ?? undefined,
boost: option.priority ?? 0,
})),
validFor: /^[A-Za-z_][A-Za-z0-9_]*$/,
validFor: fileCompletion ? /^[^"\n]*$/ : /^[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()];
}
@@ -1,81 +1,72 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { getProjectCompletions } from './playground-completion.js';
import {
completionResultToCodeMirror,
createLanguageServiceCompletionSource,
} 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('passes the active document to an injected language service', () => {
const calls = [];
const source = createLanguageServiceCompletionSource({
getActivePath: () => 'main.dcdl',
complete(request) {
calls.push(request);
return JSON.stringify({ ok: true, completion: null });
},
});
}
test('completes virtual import paths', () => {
const result = complete('schema = import "./sch');
assert.ok(result.options.some((option) => option.label === './schemas/service.dcdl'));
assert.equal(source({
state: { doc: { toString: () => 'Post.ti' } },
pos: 7,
explicit: true,
}), null);
assert.deepEqual(calls, [{
key: 'main.dcdl',
source: 'Post.ti',
position: 7,
explicit: true,
}]);
});
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',
test('adapts shared language-service completions for CodeMirror', () => {
const result = completionResultToCodeMirror({
from: 12,
options: [
{
label: 'port',
kind: 'property',
detail: 'schemas/service.dcdl',
priority: 30,
},
],
});
assert.ok(result.options.some((option) => option.label === '../schemas/service.dcdl'));
assert.equal(result.from, 12);
assert.deepEqual(result.options, [
{
label: 'port',
type: 'property',
detail: 'schemas/service.dcdl',
boost: 30,
},
]);
assert.ok(result.validFor.test('partial_name'));
});
test('uses import-path filtering for file completions', () => {
const result = completionResultToCodeMirror({
from: 8,
options: [
{
label: './schemas/service.dcdl',
kind: 'file',
detail: 'project file',
priority: 30,
},
],
});
assert.ok(result.validFor.test('./schemas/serv'));
assert.equal(result.options[0].type, 'text');
});
@@ -0,0 +1,67 @@
export function createPlaygroundEnvironment(getFiles) {
return {
globals: {},
loadImport(currentKey, specifier) {
const key = resolveImportPath(currentKey, specifier);
const source = getFiles()[key];
if (source === undefined) {
throw new Error(`import ${JSON.stringify(specifier)} resolved to ${JSON.stringify(key)}, but that file does not exist`);
}
return { kind: 'source', key, name: key, source };
},
completeImport(currentKey, prefix) {
return Object.keys(getFiles())
.filter((path) => path !== currentKey)
.map((path) => ({
specifier: prefix.startsWith('/')
? `/${path}`
: relativeImportPath(currentKey, path),
detail: 'project file',
}))
.filter((candidate) => candidate.specifier.startsWith(prefix))
.sort((left, right) => left.specifier.localeCompare(right.specifier));
},
};
}
export function resolveImportPath(currentKey, specifier) {
if (specifier.startsWith('/')) return normalizeVirtualPath(specifier);
const parent = currentKey?.includes('/')
? currentKey.slice(0, currentKey.lastIndexOf('/'))
: '';
return normalizeVirtualPath(parent ? `${parent}/${specifier}` : specifier);
}
export function relativeImportPath(currentKey, target) {
const from = currentKey?.includes('/')
? currentKey.slice(0, currentKey.lastIndexOf('/')).split('/').filter(Boolean)
: [];
const to = target.split('/').filter(Boolean);
let common = 0;
while (common < from.length && common < to.length && from[common] === to[common]) {
common += 1;
}
const parts = [
...Array(from.length - common).fill('..'),
...to.slice(common),
];
const relative = parts.join('/');
return relative.startsWith('.') ? relative : `./${relative}`;
}
function normalizeVirtualPath(path) {
const parts = [];
for (const part of String(path).replaceAll('\\', '/').split('/')) {
if (!part || part === '.') continue;
if (part === '..') {
if (parts.length === 0) throw new Error(`invalid virtual path ${JSON.stringify(path)}`);
parts.pop();
} else {
parts.push(part);
}
}
if (parts.length === 0) throw new Error(`invalid virtual path ${JSON.stringify(path)}`);
return parts.join('/');
}
@@ -0,0 +1,37 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createPlaygroundEnvironment,
relativeImportPath,
resolveImportPath,
} from './playground-environment.js';
test('resolves virtual imports in the playground host', () => {
assert.equal(
resolveImportPath('content/pages/home.dcdl', '../schemas/page.dcdl'),
'content/schemas/page.dcdl',
);
assert.equal(
relativeImportPath('content/pages/home.dcdl', 'content/schemas/page.dcdl'),
'../schemas/page.dcdl',
);
});
test('loads source and completes paths from playground-owned files', () => {
const files = {
'main.dcdl': 'import "./schemas/page.dcdl"',
'schemas/page.dcdl': 'Page = { title = String; };',
};
const environment = createPlaygroundEnvironment(() => files);
assert.deepEqual(environment.loadImport('main.dcdl', './schemas/page.dcdl'), {
kind: 'source',
key: 'schemas/page.dcdl',
name: 'schemas/page.dcdl',
source: files['schemas/page.dcdl'],
});
assert.deepEqual(environment.completeImport('main.dcdl', './sch'), [
{ specifier: './schemas/page.dcdl', detail: 'project file' },
]);
});
@@ -0,0 +1,57 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import initRuntime, { DecodalLanguageService } from 'decodal-wasm';
const wasmPath = new URL('../../../../packages/decodal-wasm/decodal_wasm_bg.wasm', import.meta.url);
test('injects one JavaScript host environment into evaluation and completion', async () => {
await initRuntime({ module_or_path: await readFile(wasmPath) });
const files = {
'schema.dcdl': 'Server = { port = Int; };',
};
const service = new DecodalLanguageService({
globals: {
App: {
enabled: { $decodal: 'Bool', default: true },
},
},
loadImport(_currentKey, specifier) {
if (specifier === './post.md') {
return {
kind: 'value',
key: 'post.md',
value: { frontmatter: { draft: false }, body: '# Hello' },
};
}
return {
kind: 'source',
key: 'schema.dcdl',
source: files['schema.dcdl'],
};
},
completeImport() {
return [{ specifier: './schema.dcdl', detail: 'test source' }];
},
});
const evaluated = JSON.parse(service.evaluate(
'main.dcdl',
'main.dcdl',
'let s = import "./schema.dcdl"; in { server = s.Server & { port = 8080; }; enabled = App.enabled; post = import "./post.md"; }',
));
assert.equal(evaluated.ok, true, evaluated.error);
assert.match(evaluated.output, /"port": 8080/);
assert.match(evaluated.output, /"body": "# Hello"/);
const member = JSON.parse(service.complete('main.dcdl', 'App.en', 6, false));
assert.equal(member.ok, true, member.error);
assert.ok(member.completion.options.some((item) => item.label === 'enabled'));
const imported = JSON.parse(service.complete('main.dcdl', 'import "./sch', 13, false));
assert.equal(imported.ok, true, imported.error);
assert.ok(imported.completion.options.some((item) => item.label === './schema.dcdl'));
service.free();
});
+19 -5
View File
@@ -1,4 +1,4 @@
import initRuntime, { evaluateProject } from 'decodal-wasm';
import initRuntime, { DecodalLanguageService } from 'decodal-wasm';
import runtimeWasmUrl from 'decodal-wasm/decodal_wasm_bg.wasm?url';
import { EditorView, basicSetup } from 'codemirror';
import { keymap } from '@codemirror/view';
@@ -6,7 +6,8 @@ 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';
import { createLanguageServiceCompletionSource } from './playground-completion.js';
import { createPlaygroundEnvironment } from './playground-environment.js';
const STORAGE_KEY = 'decodal-playground-project-v2';
const starterProject = playgroundExamples[0];
@@ -25,6 +26,7 @@ const entrySelect = document.getElementById('entry-select');
const loadExample = document.getElementById('load-example');
const project = loadProject();
let languageService;
const editorTheme = EditorView.theme({
'&': {
@@ -78,9 +80,14 @@ const editorTheme = EditorView.theme({
},
}, { dark: true });
const completionSource = createProjectCompletionSource({
getFiles: () => project.files,
const completionSource = createLanguageServiceCompletionSource({
getActivePath: () => project.activePath,
complete: ({ key, source, position, explicit }) => languageService?.complete(
key,
source,
position,
explicit,
),
});
const editor = new EditorView({
@@ -241,7 +248,11 @@ function execute() {
project.entryPath = entryPath;
updateRunLabel();
saveProject();
const result = JSON.parse(evaluateProject(entryPath, JSON.stringify(project.files)));
const result = JSON.parse(languageService.evaluate(
entryPath,
entryPath,
project.files[entryPath],
));
output.textContent = result.ok ? result.output : result.error;
output.classList.toggle('error', !result.ok);
}
@@ -312,6 +323,9 @@ function compareNodes(a, b) {
try {
await Promise.all([initRuntime(runtimeWasmUrl), initDecodalFormatter(toolsWasmUrl)]);
languageService = new DecodalLanguageService(
createPlaygroundEnvironment(() => project.files),
);
run.disabled = false;
formatButton.disabled = false;
status.textContent = 'Ctrl+Space: complete';