Share host-aware completion across runtimes
This commit is contained in:
@@ -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();
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user