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
@@ -3,6 +3,73 @@ import { resolve } from 'node:path';
const packageDir = resolve(import.meta.dirname, '../../../packages/decodal-wasm');
const declarationPath = resolve(packageDir, 'decodal_wasm.d.ts');
let declarations = readFileSync(declarationPath, 'utf8');
const hostTypes = `
/** A concrete JavaScript value or Decodal schema descriptor supplied by the host. */
export type DecodalHostValue =
| string
| number
| boolean
| DecodalHostValue[]
| { [field: string]: DecodalHostValue }
| DecodalPrimitiveDescriptor
| DecodalArrayDescriptor
| DecodalAbstractDescriptor;
export type DecodalPrimitiveType = 'String' | 'Int' | 'Float' | 'Bool';
export type DecodalConstraint =
| { kind: 'type'; value: DecodalPrimitiveType }
| { kind: 'compare'; op: '>' | '>=' | '<' | '<='; value: string | number | boolean }
| { kind: 'regex'; value: string }
| { kind: 'predicate'; value: string };
export interface DecodalPrimitiveDescriptor {
$decodal: DecodalPrimitiveType;
constraints?: DecodalConstraint[];
default?: DecodalHostValue;
}
export interface DecodalArrayDescriptor {
$decodal: 'Array';
item: DecodalHostValue;
constraints?: DecodalConstraint[];
default?: DecodalHostValue;
}
export interface DecodalAbstractDescriptor {
$decodal: 'Abstract';
constraints?: DecodalConstraint[];
default?: DecodalHostValue;
}
export type DecodalLoadedImport =
| { kind: 'source'; key: string; name?: string; source: string }
| { kind: 'value'; key: string; value: DecodalHostValue };
export type DecodalImportCandidate =
| string
| { specifier: string; detail?: string };
/** Host-owned environment shared by evaluation and language tooling. */
export interface DecodalEnvironment {
globals?: Record<string, DecodalHostValue>;
/** Synchronous import callback. Preload or cache asynchronous resources first. */
loadImport?: (currentKey: string | null, specifier: string) => DecodalLoadedImport;
/** Synchronous import completion callback. */
completeImport?: (currentKey: string | null, prefix: string) => DecodalImportCandidate[];
}
`;
if (!declarations.includes('export interface DecodalEnvironment')) {
declarations = declarations.replace('/* eslint-disable */\n', `/* eslint-disable */\n${hostTypes}`);
}
declarations = declarations.replace(
'constructor(options: any);',
'constructor(options?: DecodalEnvironment);',
);
writeFileSync(declarationPath, declarations);
writeFileSync(
resolve(packageDir, '.gitignore'),
'# wasm-pack output is committed for the browser runtime package.\n',
@@ -19,7 +86,7 @@ writeFileSync(
WebAssembly runtime package for Decodal.
This package exposes the Decodal evaluator to browsers and other JavaScript runtimes that can load WebAssembly modules.
This package exposes the Decodal evaluator and shared language service to browsers and other JavaScript runtimes that can load WebAssembly modules.
It is generated from the Rust crate in \`crates/decodal-wasm\` with \`wasm-pack\` and is used by the official playground.
Use \`decodal-codemirror\` separately when you need CodeMirror 6 language support.
@@ -39,18 +106,51 @@ deno add jsr:@hare/decodal-wasm
## Usage
\`\`\`js
import init, { evaluateProject } from 'decodal-wasm';
import init, { DecodalLanguageService } from 'decodal-wasm';
await init();
const result = evaluateProject('main.dcdl', JSON.stringify({
'main.dcdl': 'Server = { port = Int default 8080; };',
}));
const files = {
'main.dcdl': 'let schema = import "./schema.dcdl"; in schema.Server',
'schema.dcdl': 'Server = App.Server & { port = 8080; };',
};
const service = new DecodalLanguageService({
globals: {
App: {
Server: {
port: { $decodal: 'Int' },
},
},
},
loadImport(_currentKey, specifier) {
const key = specifier.startsWith('./') ? specifier.slice(2) : specifier;
return { kind: 'source', key, name: key, source: files[key] };
},
completeImport() {
return ['./schema.dcdl'];
},
});
const result = service.evaluate('main.dcdl', 'main.dcdl', files['main.dcdl']);
console.log(JSON.parse(result));
const completion = service.complete(
'main.dcdl',
'App.Server.po',
'App.Server.po'.length,
false,
);
console.log(JSON.parse(completion));
\`\`\`
The exported functions return JSON strings so callers can handle diagnostics and successful results without depending on Rust data structures.
\`globals\`, \`loadImport\`, and \`completeImport\` are host-owned. The package does not assume a filesystem or virtual project model. Import callbacks are synchronous; preload or cache remote content before evaluation.
Plain strings, numbers, booleans, arrays, and objects are concrete host values. Use \`{ $decodal: 'String' | 'Int' | 'Float' | 'Bool' }\` for primitive schemas and \`{ $decodal: 'Array', item: value }\` for array schemas. Descriptors also accept \`constraints\` and \`default\`.
Methods return JSON strings so callers can handle diagnostics and successful results without depending on Rust data structures.
`,
);
@@ -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';