#!/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' : ''}`);