From 5984e9c8e42158568dee166e047fd1c21cca0a2a Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Fri, 26 Apr 2024 12:42:15 -0700 Subject: [PATCH] Type generation with WIT folder support (#426) --- .../js-component-bindgen-component/src/lib.rs | 25 +++++-- src/api.js | 2 +- src/cmd/transpile.js | 70 +++++++++++++++---- src/jco.js | 18 ++++- test/api.js | 10 +++ test/cli.js | 15 ++++ 6 files changed, 115 insertions(+), 25 deletions(-) diff --git a/crates/js-component-bindgen-component/src/lib.rs b/crates/js-component-bindgen-component/src/lib.rs index b7ce53f99..729167482 100644 --- a/crates/js-component-bindgen-component/src/lib.rs +++ b/crates/js-component-bindgen-component/src/lib.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use anyhow::Result; +use anyhow::{Context, Result}; use js_component_bindgen::{ generate_types, source::wit_parser::{Resolve, UnresolvedPackage}, @@ -117,17 +117,30 @@ impl Guest for JsComponentBindgenComponent { opts: TypeGenerationOptions, ) -> Result)>, String> { let mut resolve = Resolve::default(); - let pkg = match opts.wit { + let id = match opts.wit { Wit::Source(source) => { - UnresolvedPackage::parse(&PathBuf::from(format!("{name}.wit")), &source) - .map_err(|e| e.to_string())? + let pkg = UnresolvedPackage::parse(&PathBuf::from(format!("{name}.wit")), &source) + .map_err(|e| e.to_string())?; + resolve.push(pkg).map_err(|e| e.to_string())? } Wit::Path(path) => { - UnresolvedPackage::parse_file(&PathBuf::from(path)).map_err(|e| e.to_string())? + let path = PathBuf::from(path); + if path.is_dir() { + resolve.push_dir(&path).map_err(|e| e.to_string())?.0 + } else { + let contents = std::fs::read(&path) + .with_context(|| format!("failed to read file {path:?}")) + .map_err(|e| e.to_string())?; + let text = match std::str::from_utf8(&contents) { + Ok(s) => s, + Err(_) => return Err("input file is not valid utf-8".into()), + }; + let pkg = UnresolvedPackage::parse(&path, text).map_err(|e| e.to_string())?; + resolve.push(pkg).map_err(|e| e.to_string())? + } } Wit::Binary(_) => todo!(), }; - let id = resolve.push(pkg).map_err(|e| e.to_string())?; let world_string = opts.world.map(|world| world.to_string()); let world = resolve diff --git a/src/api.js b/src/api.js index b5aff82f5..04d8e8c7a 100644 --- a/src/api.js +++ b/src/api.js @@ -1,5 +1,5 @@ export { optimizeComponent as opt } from './cmd/opt.js'; -export { transpileComponent as transpile } from './cmd/transpile.js'; +export { transpileComponent as transpile, typesComponent as types } from './cmd/transpile.js'; import { $init, tools } from "../obj/wasm-tools.js"; const { print: printFn, parse: parseFn, componentWit: componentWitFn, componentNew: componentNewFn, componentEmbed: componentEmbedFn, metadataAdd: metadataAddFn, metadataShow: metadataShowFn } = tools; diff --git a/src/cmd/transpile.js b/src/cmd/transpile.js index 6464c580e..088028b77 100644 --- a/src/cmd/transpile.js +++ b/src/cmd/transpile.js @@ -1,4 +1,4 @@ -import { $init, generate } from '../../obj/js-component-bindgen-component.js'; +import { $init, generate, generateTypes } from '../../obj/js-component-bindgen-component.js'; import { writeFile } from 'node:fs/promises'; import { mkdir } from 'node:fs/promises'; import { dirname, extname, basename, resolve } from 'node:path'; @@ -14,6 +14,58 @@ import { platform } from 'node:process'; const isWindows = platform === 'win32'; +export async function types (witPath, opts) { + const files = await typesComponent(witPath, opts); + await writeFiles(files, opts.quiet ? false : 'Generated Type Files'); +} + +/** + * @param {string} witPath + * @param {{ + * name?: string, + * worldName?: string, + * instantiation?: 'async' | 'sync', + * tlaCompat?: bool, + * outDir?: string, + * }} opts + * @returns {Promise<{ [filename: string]: Uint8Array }>} + */ +export async function typesComponent (witPath, opts) { + await $init; + const name = opts.name || (opts.worldName + ? opts.worldName.split(':').pop().split('/').pop() + : basename(witPath.slice(0, -extname(witPath).length || Infinity))); + let instantiation; + if (opts.instantiation) { + instantiation = { tag: opts.instantiation }; + } + let outDir = (opts.outDir ?? '').replace(/\\/g, '/'); + if (!outDir.endsWith('/') && outDir !== '') + outDir += '/'; + return Object.fromEntries(generateTypes(name, { + wit: { tag: 'path', val: (isWindows ? '//?/' : '') + resolve(witPath) }, + instantiation, + tlaCompat: opts.tlaCompat ?? false, + world: opts.worldName + }).map(([name, file]) => [`${outDir}${name}`, file])); +} + +async function writeFiles(files, summaryTitle) { + await Promise.all(Object.entries(files).map(async ([name, file]) => { + await mkdir(dirname(name), { recursive: true }); + await writeFile(name, file); + })); + if (!summaryTitle) + return; + console.log(c` + {bold ${summaryTitle}:} + +${table(Object.entries(files).map(([name, source]) => [ + c` - {italic ${name}} `, + c`{black.italic ${sizeStr(source.length)}}` + ]))}`); +} + export async function transpile (componentPath, opts, program) { const varIdx = program?.parent.rawArgs.indexOf('--'); if (varIdx !== undefined && varIdx !== -1) @@ -37,20 +89,7 @@ export async function transpile (componentPath, opts, program) { if (opts.map) opts.map = Object.fromEntries(opts.map.map(mapping => mapping.split('='))); const { files } = await transpileComponent(component, opts); - - await Promise.all(Object.entries(files).map(async ([name, file]) => { - await mkdir(dirname(name), { recursive: true }); - await writeFile(name, file); - })); - - if (!opts.quiet) - console.log(c` -{bold Transpiled JS Component Files:} - -${table(Object.entries(files).map(([name, source]) => [ - c` - {italic ${name}} `, - c`{black.italic ${sizeStr(source.length)}}` -]))}`); + await writeFiles(files, opts.quiet ? false : 'Transpiled JS Component Files'); } let WASM_2_JS; @@ -91,6 +130,7 @@ async function wasm2Js (source) { * minify?: bool, * optimize?: bool, * namespacedExports?: bool, + * outDir?: string, * multiMemory?: bool, * optArgs?: string[], * }} opts diff --git a/src/jco.js b/src/jco.js index 167c902d4..964d3ce6e 100755 --- a/src/jco.js +++ b/src/jco.js @@ -1,7 +1,7 @@ #!/usr/bin/env node import { program, Option } from 'commander'; import { opt } from './cmd/opt.js'; -import { transpile } from './cmd/transpile.js'; +import { transpile, types } from './cmd/transpile.js'; import { run as runCmd, serve as serveCmd } from './cmd/run.js'; import { parse, print, componentNew, componentEmbed, metadataAdd, metadataShow, componentWit } from './cmd/wasm-tools.js'; import { componentize } from './cmd/componentize.js'; @@ -48,12 +48,24 @@ program.command('transpile') .option('--stub', 'generate a stub implementation from a WIT file directly') .option('--js', 'output JS instead of core WebAssembly') .addOption(new Option('-I, --instantiation [mode]', 'output for custom module instantiation').choices(['async', 'sync']).preset('async')) - .option('-q, --quiet', 'disable logging') + .option('-q, --quiet', 'disable output summary') .option('--no-namespaced-exports', 'disable namespaced exports for typescript compatibility') .option('--multi-memory', 'optimized output for Wasm multi-memory') .option('--', 'for --optimize, custom wasm-opt arguments (defaults to best size optimization)') .action(asyncAction(transpile)); +program.command('types') + .description('Generate types for the given WIT') + .usage(' -o ') + .argument('', 'path to a WIT file or directory') + .option('--name ', 'custom output name') + .option('-n, --world-name ', 'WIT world to generate types for') + .requiredOption('-o, --out-dir ', 'output directory') + .option('--tla-compat', 'generates types for the TLA compat output with an async $init promise export') + .addOption(new Option('-I, --instantiation [mode]', 'type output for custom module instantiation').choices(['async', 'sync']).preset('async')) + .option('-q, --quiet', 'disable output summary') + .action(asyncAction(types)); + program.command('run') .description('Run a WASI Command component') .usage(' ') @@ -158,7 +170,7 @@ program.command('embed') .requiredOption('--wit ', 'WIT world path') .option('--dummy', 'generate a dummy component') .option('--string-encoding ', 'set the component string encoding') - .option('--world ', 'positional world path to embed') + .option('-n, --world-name ', 'world name to embed') .option('-m, --metadata ', 'field=name[@version] producer metadata to add with the embedding') .action(asyncAction(componentEmbed)); diff --git a/test/api.js b/test/api.js index 1810c09fe..a728dae69 100644 --- a/test/api.js +++ b/test/api.js @@ -2,6 +2,7 @@ import { deepStrictEqual, ok, strictEqual } from "node:assert"; import { readFile } from "node:fs/promises"; import { transpile, + types, opt, print, parse, @@ -92,6 +93,15 @@ export async function apiTest(fixtures) { ok(source.includes("'#testimport'")); }); + test('Type generation', async () => { + const files = await types('test/fixtures/wit', { + worldName: 'test:flavorful/flavorful', + }); + strictEqual(Object.keys(files).length, 2); + strictEqual(Object.keys(files)[0], 'flavorful.d.ts'); + ok(Buffer.from(files[Object.keys(files)[0]]).includes('export const test')); + }); + test("Optimize", async () => { const component = await readFile( `test/fixtures/components/flavorful.component.wasm` diff --git a/test/cli.js b/test/cli.js index 6c91c375a..5b156e689 100644 --- a/test/cli.js +++ b/test/cli.js @@ -180,6 +180,21 @@ export async function cliTest(fixtures) { ); }); + test("Type generation", async () => { + const { stderr } = await exec( + jcoPath, + "types", + "test/fixtures/wit", + "--world-name", + "test:flavorful/flavorful", + "-o", + outDir + ); + strictEqual(stderr, ""); + const source = await readFile(`${outDir}/flavorful.d.ts`, "utf8"); + ok(source.includes("export const test")); + }); + test("TypeScript naming checks", async () => { const { stderr } = await exec( jcoPath,