The extension contributes breakpoints for OpenQASM and a `wai-quantum` debugger whose launch settings (program, stopOnEntry, inputs, noise, method, outcomes) are the ones `wai-quantum dap` reads; it starts the adapter as the command line's dap verb, and "Debug the program" debugs the file in the editor. The Lua configuration registers the adapter with the debug adapter plugin, and languages.toml declares it with a launch template. test/check.mjs holds the manifest to the adapter's source and checks the client starts it; the editors workflow runs the debugger's module tests and tests/dap.rs on Linux and macOS. Co-Authored-By: Claude Opus 5.5 <[email protected]>
327 lines
17 KiB
JavaScript
327 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
|
// The editor extension and the other editors' configurations, checked
|
|
// without an editor:
|
|
//
|
|
// - the manifest names the language, the grammar, the settings and the
|
|
// commands, and its commands are the language server's;
|
|
// - the TextMate grammar's every pattern compiles, and it highlights a
|
|
// program as it should, token by token, and every corpus program without
|
|
// failing;
|
|
// - the client, loaded with the editor's interface stubbed, says how to
|
|
// install the command line when it is missing, with the commands the
|
|
// command line's README gives;
|
|
// - the debugger: the manifest contributes breakpoints for OpenQASM and a
|
|
// debugger whose launch settings are the ones the debug adapter
|
|
// (`wai-quantum dap`) reads, and the client starts the adapter as
|
|
// `wai-quantum dap`;
|
|
// - the other editors' configurations parse (TOML, Lua), name the server
|
|
// and the grammar, and their query copies are the grammar's, in each
|
|
// editor's capture names;
|
|
// - with --vsix FILE, the packaged extension holds exactly the files it should.
|
|
//
|
|
// node test/check.mjs [--vsix FILE] [--write-queries]
|
|
//
|
|
// With WAI_QUANTUM_BIN set to a built `wai-quantum`, the client's probe of it
|
|
// is checked too.
|
|
import assert from 'node:assert/strict';
|
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
import Module, { createRequire } from 'node:module';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import luaparse from 'luaparse';
|
|
import { parse as parseToml } from 'smol-toml';
|
|
|
|
// Plain objects, so that they compare with deepEqual.
|
|
const toml = (text) => JSON.parse(JSON.stringify(parseToml(text)));
|
|
|
|
const here = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const repo = resolve(here, '../../..');
|
|
const read = (p) => readFileSync(join(here, p), 'utf8');
|
|
const readRepo = (p) => readFileSync(join(repo, p), 'utf8');
|
|
let checks = 0;
|
|
const ok = (cond, msg) => {
|
|
assert.ok(cond, msg);
|
|
checks += 1;
|
|
};
|
|
const eq = (a, b, msg) => {
|
|
assert.deepEqual(a, b, msg);
|
|
checks += 1;
|
|
};
|
|
|
|
// The manifest.
|
|
const pkg = JSON.parse(read('package.json'));
|
|
eq(pkg.contributes.languages.map((l) => l.id), ['openqasm'], 'one language');
|
|
eq(pkg.contributes.languages[0].extensions, ['.qasm'], 'the .qasm files');
|
|
ok(existsSync(join(here, pkg.contributes.languages[0].configuration)), 'the language configuration exists');
|
|
eq(pkg.contributes.grammars[0].scopeName, 'source.openqasm', 'the grammar scope');
|
|
const settings = Object.keys(pkg.contributes.configuration.properties).sort();
|
|
eq(settings, ['waiQuantum.inputs', 'waiQuantum.path', 'waiQuantum.serverArgs', 'waiQuantum.shots', 'waiQuantum.trace.server'], 'the settings');
|
|
for (const [k, v] of Object.entries({ ...pkg.dependencies, ...pkg.devDependencies })) ok(/^\d+\.\d+\.\d+$/.test(v), `${k} is pinned to one version (${v})`);
|
|
JSON.parse(read('language-configuration.json'));
|
|
|
|
// The server's commands, from the language service's source, and the extension's.
|
|
const service = readRepo('wai/wai-quantum/src/engine/editor/mod.rs');
|
|
const serverCommands = [...service.matchAll(/\("(wai-quantum\.[a-z]+)", "([A-Za-z]+)"\)/g)].map((m) => m[1]);
|
|
eq(serverCommands, ['wai-quantum.run', 'wai-quantum.plan', 'wai-quantum.critique', 'wai-quantum.shortcuts', 'wai-quantum.receipt'], 'the server lists its commands');
|
|
const declared = pkg.contributes.commands.map((c) => c.command);
|
|
|
|
// The client, with the editor's interface stubbed, and the command line missing.
|
|
const dist = join(here, 'dist/extension.js');
|
|
ok(existsSync(dist), 'the extension is built (npm run build)');
|
|
const seen = { errors: [], lines: [], commands: [], factories: [], providers: [] };
|
|
const stub = {
|
|
window: {
|
|
createOutputChannel: () => ({ appendLine: (l) => seen.lines.push(l), show() {}, dispose() {} }),
|
|
showErrorMessage: async (m) => {
|
|
seen.errors.push(m);
|
|
return undefined;
|
|
},
|
|
showWarningMessage: async () => undefined,
|
|
showInformationMessage: async () => undefined,
|
|
activeTextEditor: undefined,
|
|
},
|
|
workspace: {
|
|
getConfiguration: () => ({ get: (k, d) => (k === 'path' ? '/nowhere/wai-quantum' : d) }),
|
|
onDidChangeConfiguration: () => ({ dispose() {} }),
|
|
getWorkspaceFolder: () => undefined,
|
|
},
|
|
debug: {
|
|
registerDebugAdapterDescriptorFactory: (type, factory) => {
|
|
seen.factories.push([type, factory]);
|
|
return { dispose() {} };
|
|
},
|
|
registerDebugConfigurationProvider: (type, provider) => {
|
|
seen.providers.push([type, provider]);
|
|
return { dispose() {} };
|
|
},
|
|
startDebugging: async () => true,
|
|
},
|
|
DebugAdapterExecutable: class {
|
|
constructor(command, args) {
|
|
this.command = command;
|
|
this.args = args;
|
|
}
|
|
},
|
|
commands: {
|
|
registerCommand: (name) => {
|
|
seen.commands.push(name);
|
|
return { dispose() {} };
|
|
},
|
|
executeCommand: async () => undefined,
|
|
},
|
|
};
|
|
const load = Module._load;
|
|
Module._load = function (request, ...rest) {
|
|
return request === 'vscode' ? stub : load.call(this, request, ...rest);
|
|
};
|
|
const ext = createRequire(import.meta.url)(dist);
|
|
await ext.activate({ subscriptions: [] });
|
|
Module._load = load;
|
|
eq(seen.commands.sort(), [...declared].sort(), 'the client registers exactly the commands the manifest declares');
|
|
eq(Object.values(ext.COMMANDS), serverCommands, 'each of the client\'s commands runs the server\'s command of the same name');
|
|
eq(seen.errors.length, 1, 'one message when the command line is missing');
|
|
ok(seen.errors[0].includes('/nowhere/wai-quantum --version') && seen.errors[0].includes('waiQuantum.path'), `the message names the command and the setting: ${seen.errors[0]}`);
|
|
// The install commands are the command line's README's.
|
|
const readme = readRepo('wai/wai-quantum-cli/README.md');
|
|
const table = readme.split('## Installing it')[1].split('\n\n')[1];
|
|
const fromReadme = [...table.matchAll(/`((?:cargo|brew|scoop) [^`]+)`/g)].map((m) => m[1]);
|
|
eq(ext.INSTALL, fromReadme, 'the install commands are the README\'s');
|
|
for (const line of fromReadme) ok(seen.lines.includes(` ${line}`), `the panel shows ${line}`);
|
|
ok(seen.lines.some((l) => l.includes('/releases')), 'the panel points at the prebuilt archives');
|
|
// The debugger, as the manifest contributes it and the client starts it.
|
|
eq(pkg.contributes.breakpoints, [{ language: 'openqasm' }], 'breakpoints in OpenQASM programs');
|
|
const dbg = pkg.contributes.debuggers;
|
|
eq(dbg.map((d) => [d.type, d.languages]), [[ext.DEBUG_TYPE, ['openqasm']]], 'one debugger, for OpenQASM');
|
|
const adapter = readRepo('wai/wai-quantum-cli/src/dap.rs');
|
|
const passed = [...adapter.match(/let mut req = json!\(\{ "qasm": qasm \}\);\s*for k in \[([^\]]+)\]/)[1].matchAll(/"([a-zA-Z]+)"/g)].map((m) => m[1]);
|
|
ok(adapter.includes('get("program")') && adapter.includes('get("stopOnEntry")'), 'the adapter reads program and stopOnEntry');
|
|
eq(Object.keys(dbg[0].configurationAttributes.launch.properties).sort(), ['program', 'stopOnEntry', ...passed].sort(), 'the launch settings are the ones the adapter reads');
|
|
eq(dbg[0].initialConfigurations, [ext.launchFor('${file}')], 'the first launch configuration is the one the command starts');
|
|
eq(seen.factories.map((f) => f[0]), [ext.DEBUG_TYPE], 'the client registers the adapter');
|
|
const exe = seen.factories[0][1].createDebugAdapterDescriptor({});
|
|
eq([exe.command, exe.args], ['/nowhere/wai-quantum', ['dap']], 'the adapter is the command line\'s dap verb, at the configured path');
|
|
const provider = seen.providers[0][1];
|
|
eq(provider.resolveDebugConfiguration(undefined, {}), {}, 'with no program open, an empty configuration is left as it is');
|
|
eq(provider.resolveDebugConfiguration(undefined, { type: 'wai-quantum', request: 'launch', name: 'x' }), undefined, 'a configuration with no program is refused');
|
|
eq(provider.resolveDebugConfiguration(undefined, ext.launchFor('/p.qasm')), ext.launchFor('/p.qasm'), 'a configuration with a program is kept');
|
|
|
|
if (process.env.WAI_QUANTUM_BIN) {
|
|
const v = ext.probe(process.env.WAI_QUANTUM_BIN);
|
|
ok(/^wai-quantum \d+\.\d+\.\d+/.test(v || ''), `the probe reads the version of ${process.env.WAI_QUANTUM_BIN}: ${v}`);
|
|
eq(ext.probe('/nowhere/wai-quantum'), null, 'the probe of a missing command is null');
|
|
}
|
|
|
|
// The TextMate grammar: a small engine of its own (match, begin/end, captures).
|
|
const tm = JSON.parse(read('syntaxes/openqasm.tmLanguage.json'));
|
|
function rules(list) {
|
|
const out = [];
|
|
for (const r of list) {
|
|
if (r.include) out.push(...rules(tm.repository[r.include.slice(1)].patterns));
|
|
else out.push(r);
|
|
}
|
|
return out;
|
|
}
|
|
const all = rules(tm.patterns).map((r) => ({ ...r, re: new RegExp(r.match ?? r.begin, 'dgu'), endRe: r.end ? new RegExp(r.end, 'dgu') : null }));
|
|
ok(all.length > 30, 'the grammar has its patterns');
|
|
function tokenize(text) {
|
|
const tokens = [];
|
|
let open = null;
|
|
for (const line of text.split('\n')) {
|
|
let pos = 0;
|
|
const push = (from, to, scopes) => {
|
|
if (to > from) tokens.push({ text: line.slice(from, to), scopes });
|
|
};
|
|
while (pos <= line.length) {
|
|
if (open) {
|
|
open.endRe.lastIndex = pos;
|
|
const m = open.endRe.exec(line);
|
|
const end = m ? m.index + m[0].length : line.length;
|
|
push(pos, end, [open.name]);
|
|
pos = end;
|
|
if (m) open = null;
|
|
else break;
|
|
continue;
|
|
}
|
|
let best = null;
|
|
for (const r of all) {
|
|
r.re.lastIndex = pos;
|
|
const m = r.re.exec(line);
|
|
if (m && (!best || m.index < best.m.index)) best = { r, m };
|
|
}
|
|
if (!best) {
|
|
push(pos, line.length, []);
|
|
break;
|
|
}
|
|
const { r, m } = best;
|
|
push(pos, m.index, []);
|
|
const end = m.index + m[0].length;
|
|
if (r.begin) {
|
|
push(m.index, end, [r.name]);
|
|
open = r;
|
|
pos = Math.max(end, m.index + 1);
|
|
continue;
|
|
}
|
|
let at = m.index;
|
|
for (const [g, c] of Object.entries(r.captures ?? {})) {
|
|
const span = m.indices[Number(g)];
|
|
if (!span) continue;
|
|
push(at, span[0], [r.name].filter(Boolean));
|
|
push(span[0], span[1], [r.name, c.name].filter(Boolean));
|
|
at = span[1];
|
|
}
|
|
push(at, end, [r.name].filter(Boolean));
|
|
pos = end > m.index ? end : m.index + 1;
|
|
}
|
|
}
|
|
return tokens;
|
|
}
|
|
const scope = (src, word) => (tokenize(src).find((t) => t.text === word) ?? { scopes: ['(no token)'] }).scopes.join(' ');
|
|
const want = [
|
|
['OPENQASM 3.0;', 'OPENQASM', 'keyword.control.directive.version'],
|
|
['OPENQASM 3.0;', '3.0', 'constant.numeric.version'],
|
|
['include "stdgates.inc";', 'include', 'keyword.control.import'],
|
|
['include "stdgates.inc";', 'stdgates.inc"', 'string.quoted.double'],
|
|
['gate bell(t) a, b {', 'gate', 'storage.type.gate'],
|
|
['gate bell(t) a, b {', 'bell', 'entity.name.function.gate'],
|
|
[' h q[0]; // c', 'h', 'support.function.gate'],
|
|
[' h q[0]; // c', '// c', 'comment.line.double-slash'],
|
|
['rz(pi / 2) q[1];', 'rz', 'support.function.gate'],
|
|
['rz(pi / 2) q[1];', 'pi', 'constant.language'],
|
|
['rz(pi / 2) q[1];', '/', 'keyword.operator'],
|
|
['bell q[0], q[1];', 'bell', 'entity.name.function.call'],
|
|
[' qubit[2] q;', 'qubit', 'storage.type'],
|
|
[' qubit[2] q;', '2', 'constant.numeric'],
|
|
['delay[100ns] q;', '100ns', 'constant.numeric.duration'],
|
|
['ctrl @ x a, b;', 'ctrl', 'storage.modifier'],
|
|
['ctrl @ x a, b;', '@', 'keyword.operator'],
|
|
['cx $0, $1;', '$0', 'variable.language.hardware'],
|
|
['z = 1.0 + 2.0im;', '2.0im', 'constant.numeric.imaginary'],
|
|
['uint[8] m = 0x0F;', '0x0F', 'constant.numeric.integer'],
|
|
['if (c[0] == 1) {', 'if', 'keyword.control'],
|
|
['if (c[0] == 1) {', '==', 'keyword.operator'],
|
|
['c = measure q;', 'measure', 'keyword.other'],
|
|
['#pragma noise off', '#pragma', 'keyword.control.directive.pragma'],
|
|
['@bind x', '@bind', 'storage.type.annotation'],
|
|
['def parity(bit[2] b) -> bit {', 'parity', 'entity.name.function'],
|
|
['bool b = true;', 'true', 'constant.language.boolean'],
|
|
['f = sin(theta);', 'sin', 'support.function.builtin'],
|
|
['/* a\nb */ x q;', 'b */', 'comment.block'],
|
|
];
|
|
for (const [src, word, name] of want) ok(scope(src, word).includes(name), `\`${word}\` in \`${src}\`: ${scope(src, word)}, not ${name}`);
|
|
const corpus = join(repo, 'wai/wai-quantum/src/engine/testdata/editor');
|
|
for (const f of readdirSync(corpus).filter((f) => f.endsWith('.qasm'))) {
|
|
const toks = tokenize(readFileSync(join(corpus, f), 'utf8'));
|
|
ok(toks.some((t) => t.scopes.length > 0), `${f} is highlighted`);
|
|
}
|
|
|
|
// The other editors' configurations.
|
|
const ed = (p) => read(`editors/${p}`);
|
|
const hx = toml(ed('languages.toml'));
|
|
eq(hx['language-server']['wai-quantum'], { command: 'wai-quantum', args: ['lsp'] }, 'languages.toml: the server');
|
|
eq([hx.language[0].name, hx.language[0]['file-types'], hx.language[0]['language-servers'], hx.language[0].formatter], ['openqasm', ['qasm'], ['wai-quantum'], { command: 'wai-quantum', args: ['fmt'] }], 'languages.toml: the language');
|
|
ok(existsSync(join(repo, hx.grammar[0].source.subpath, 'src/parser.c')), 'languages.toml: the grammar is where it says');
|
|
eq([hx.language[0].debugger.name, hx.language[0].debugger.transport, hx.language[0].debugger.command, hx.language[0].debugger.args], ['wai-quantum', 'stdio', 'wai-quantum', ['dap']], 'languages.toml: the debugger');
|
|
eq(hx.language[0].debugger.templates[0].args, { program: '{0}', stopOnEntry: true }, 'languages.toml: the debugger\'s launch');
|
|
const ext2 = toml(ed('extension/extension.toml'));
|
|
ok(existsSync(join(repo, ext2.grammars.openqasm.path, 'src/parser.c')), 'extension.toml: the grammar is where it says');
|
|
eq(ext2.language_servers['wai-quantum'].languages, ['OpenQASM'], 'extension.toml: the server is for OpenQASM');
|
|
const zcfg = toml(ed('extension/languages/openqasm/config.toml'));
|
|
eq([zcfg.name, zcfg.grammar, zcfg.path_suffixes], ['OpenQASM', 'openqasm', ['qasm']], 'config.toml: the language');
|
|
const cargo = toml(ed('extension/Cargo.toml'));
|
|
eq(cargo.lib['crate-type'], ['cdylib'], 'Cargo.toml: a component');
|
|
ok(Object.values(cargo.dependencies).every((v) => /^=\d+\.\d+\.\d+$/.test(v)), 'Cargo.toml: its dependency is pinned');
|
|
const lua = ed('wai-quantum.lua');
|
|
luaparse.parse(lua, { luaVersion: '5.1' });
|
|
checks += 1;
|
|
ok(lua.includes("cmd = { 'wai-quantum', 'lsp' }") && lua.includes("location = 'wai/tools/tree-sitter-openqasm'"), 'the Lua configuration: the server and the grammar');
|
|
ok(lua.includes("dap.adapters['wai-quantum'] = { type = 'executable', command = 'wai-quantum', args = { 'dap' } }") && lua.includes('dap.configurations.openqasm'), 'the Lua configuration: the debugger');
|
|
ok(['run', 'plan', 'critique', 'shortcuts', 'receipt'].every((c) => serverCommands.includes(`wai-quantum.${c}`)) && lua.includes("'wai-quantum.' .. name"), 'the Lua configuration: the commands are the server\'s');
|
|
|
|
// The query copies: the grammar's, in each editor's capture names.
|
|
const grammarQueries = (q) => readRepo(`wai/tools/tree-sitter-openqasm/queries/${q}`);
|
|
const MAPS = {
|
|
'runtime/queries/openqasm/highlights.scm': ['highlights.scm', { '@number.float': '@constant.numeric.float', '@number': '@constant.numeric', '@boolean': '@constant.builtin.boolean', '@function.call': '@function' }],
|
|
'runtime/queries/openqasm/locals.scm': ['locals.scm', {}],
|
|
'extension/languages/openqasm/highlights.scm': ['highlights.scm', { '@keyword.directive': '@preproc', '@number.float': '@number', '@function.call': '@function', '@variable.builtin': '@variable.special' }],
|
|
};
|
|
for (const [copy, [source, map]] of Object.entries(MAPS)) {
|
|
const head = `; The grammar's queries/${source}, in this editor's capture names. Do not edit:\n; test/check.mjs --write-queries writes it.\n`;
|
|
const text = head + grammarQueries(source).replace(/@[a-z_.]+/g, (c) => map[c] ?? c);
|
|
if (process.argv.includes('--write-queries')) {
|
|
mkdirSync(dirname(join(here, 'editors', copy)), { recursive: true });
|
|
writeFileSync(join(here, 'editors', copy), text);
|
|
}
|
|
eq(ed(copy), text, `editors/${copy} is the grammar's ${source}, mapped`);
|
|
}
|
|
|
|
// The packaged extension.
|
|
const at = process.argv.indexOf('--vsix');
|
|
if (at > 0) {
|
|
const zip = readFileSync(resolve(process.argv[at + 1]));
|
|
let eocd = zip.length - 22;
|
|
while (eocd >= 0 && zip.readUInt32LE(eocd) !== 0x06054b50) eocd -= 1;
|
|
ok(eocd >= 0, 'the package is a zip archive');
|
|
const count = zip.readUInt16LE(eocd + 10);
|
|
let p = zip.readUInt32LE(eocd + 16);
|
|
const names = [];
|
|
for (let i = 0; i < count; i += 1) {
|
|
assert.equal(zip.readUInt32LE(p), 0x02014b50);
|
|
const n = zip.readUInt16LE(p + 28);
|
|
const x = zip.readUInt16LE(p + 30);
|
|
const c = zip.readUInt16LE(p + 32);
|
|
names.push(zip.subarray(p + 46, p + 46 + n).toString('utf8'));
|
|
p += 46 + n + x + c;
|
|
}
|
|
eq(names.sort(), [
|
|
'[Content_Types].xml',
|
|
'extension.vsixmanifest',
|
|
'extension/LICENSE.txt',
|
|
'extension/readme.md',
|
|
'extension/dist/extension.js',
|
|
'extension/language-configuration.json',
|
|
'extension/package.json',
|
|
'extension/syntaxes/openqasm.tmLanguage.json',
|
|
].sort(), 'the package holds the extension and nothing else');
|
|
}
|
|
|
|
console.log(`editor extension: ${checks} checks passed${at > 0 ? ', the package included' : ''}`);
|