From e862e53f3e2f8d53b66c34b355e53e7b01b8d08e Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 13 Aug 2026 18:07:24 +0900 Subject: [PATCH] Add project-aware playground completion --- packages/decodal-codemirror/package-lock.json | 1 + packages/decodal-codemirror/package.json | 4 + packages/decodal-codemirror/src/decodal.js | 10 +- .../decodal-codemirror/src/decodal.test.mjs | 35 +++ packages/decodal-codemirror/src/mod.ts | 10 +- site/decodal-site/package.json | 1 + .../src/scripts/playground-completion.js | 256 ++++++++++++++++++ .../scripts/playground-completion.test.mjs | 81 ++++++ site/decodal-site/src/scripts/playground.js | 30 +- 9 files changed, 416 insertions(+), 12 deletions(-) create mode 100644 packages/decodal-codemirror/src/decodal.test.mjs create mode 100644 site/decodal-site/src/scripts/playground-completion.js create mode 100644 site/decodal-site/src/scripts/playground-completion.test.mjs diff --git a/packages/decodal-codemirror/package-lock.json b/packages/decodal-codemirror/package-lock.json index 979ef01..aa73efa 100644 --- a/packages/decodal-codemirror/package-lock.json +++ b/packages/decodal-codemirror/package-lock.json @@ -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" diff --git a/packages/decodal-codemirror/package.json b/packages/decodal-codemirror/package.json index f3a7648..118da64 100644 --- a/packages/decodal-codemirror/package.json +++ b/packages/decodal-codemirror/package.json @@ -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" diff --git a/packages/decodal-codemirror/src/decodal.js b/packages/decodal-codemirror/src/decodal.js index 22cb9e3..37107bf 100644 --- a/packages/decodal-codemirror/src/decodal.js +++ b/packages/decodal-codemirror/src/decodal.js @@ -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({ diff --git a/packages/decodal-codemirror/src/decodal.test.mjs b/packages/decodal-codemirror/src/decodal.test.mjs new file mode 100644 index 0000000..d315f6c --- /dev/null +++ b/packages/decodal-codemirror/src/decodal.test.mjs @@ -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); +}); diff --git a/packages/decodal-codemirror/src/mod.ts b/packages/decodal-codemirror/src/mod.ts index 49a75e7..6ca1b04 100644 --- a/packages/decodal-codemirror/src/mod.ts +++ b/packages/decodal-codemirror/src/mod.ts @@ -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({ diff --git a/site/decodal-site/package.json b/site/decodal-site/package.json index 88ea20d..448dd7c 100644 --- a/site/decodal-site/package.json +++ b/site/decodal-site/package.json @@ -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", diff --git a/site/decodal-site/src/scripts/playground-completion.js b/site/decodal-site/src/scripts/playground-completion.js new file mode 100644 index 0000000..97dea80 --- /dev/null +++ b/site/decodal-site/src/scripts/playground-completion.js @@ -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()]; +} diff --git a/site/decodal-site/src/scripts/playground-completion.test.mjs b/site/decodal-site/src/scripts/playground-completion.test.mjs new file mode 100644 index 0000000..997cb71 --- /dev/null +++ b/site/decodal-site/src/scripts/playground-completion.test.mjs @@ -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')); +}); diff --git a/site/decodal-site/src/scripts/playground.js b/site/decodal-site/src/scripts/playground.js index 7beebab..f93f2c5 100644 --- a/site/decodal-site/src/scripts/playground.js +++ b/site/decodal-site/src/scripts/playground.js @@ -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}`;