From 58c389eb61fb7e6b46c53578c0faef7dacc4d493 Mon Sep 17 00:00:00 2001 From: Gordon Smith Date: Fri, 15 Aug 2025 13:01:31 +0100 Subject: [PATCH] feat: add extract core wasm command Signed-off-by: Gordon Smith Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- package.json | 35 +++++++++--- src/extension.ts | 107 +++++++++++++++++++++++++++++++++++- src/wasmUtils.ts | 35 ++++++++++++ tests/commands.test.ts | 42 ++++++++++++++ wit-bindgen-wasm/Cargo.lock | 23 ++++++-- wit-bindgen-wasm/Cargo.toml | 1 + wit-bindgen-wasm/src/lib.rs | 41 ++++++++++++++ 7 files changed, 271 insertions(+), 13 deletions(-) create mode 100644 tests/commands.test.ts diff --git a/package.json b/package.json index 5e161ef..a811543 100644 --- a/package.json +++ b/package.json @@ -172,6 +172,11 @@ "command": "wit-idl.extractWit", "title": "Extract WIT", "category": "WIT" + }, + { + "command": "wit-idl.extractCoreWasm", + "title": "Extract Core Wasm", + "category": "WIT" } ], "submenus": [ @@ -190,29 +195,39 @@ { "submenu": "wit-idl.generateBindings.submenu", "when": "resourceExtname == .wit || witIdl.isWasmComponent", - "group": "navigation" + "group": "navigation@12" }, { "command": "wit-idl.extractWit", "when": "resourceExtname == .wasm && witIdl.isWasmComponent", - "group": "navigation" + "group": "navigation@10" + }, + { + "command": "wit-idl.extractCoreWasm", + "when": "resourceExtname == .wasm && witIdl.isWasmComponent", + "group": "navigation@11" } ], "explorer/context": [ { "command": "wit-idl.syntaxCheck", "when": "resourceExtname == .wit", - "group": "navigation" + "group": "4_witIdl@10" }, { - "submenu": "wit-idl.generateBindings.submenu", - "when": "resourceExtname == .wit || witIdl.isWasmComponent", - "group": "navigation" + "command": "wit-idl.extractWit", + "when": "resourceExtname == .wasm && witIdl.isWasmComponent", + "group": "4_witIdl@11" }, { - "command": "wit-idl.extractWit", + "command": "wit-idl.extractCoreWasm", "when": "resourceExtname == .wasm && witIdl.isWasmComponent", - "group": "navigation" + "group": "4_witIdl@12" + }, + { + "submenu": "wit-idl.generateBindings.submenu", + "when": "resourceExtname == .wit || witIdl.isWasmComponent", + "group": "4_witIdl@13" } ], "wit-idl.generateBindings.submenu": [ @@ -266,6 +281,10 @@ { "command": "wit-idl.extractWit", "when": "witIdl.isWasmComponent" + }, + { + "command": "wit-idl.extractCoreWasm", + "when": "witIdl.isWasmComponent" } ] }, diff --git a/src/extension.ts b/src/extension.ts index b844dc5..00df00a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,7 +3,12 @@ import * as path from "path"; import * as fs from "fs"; import { WitSyntaxValidator } from "./validator.js"; import { isWasmComponentFile } from "./wasmDetection.js"; -import { getWitBindgenVersionFromWasm, extractWitFromComponent, generateBindingsFromWasm } from "./wasmUtils.js"; +import { + getWitBindgenVersionFromWasm, + extractWitFromComponent, + generateBindingsFromWasm, + extractCoreWasmFromComponent, +} from "./wasmUtils.js"; // Removed: openExtractedWitDocument - we now render a readonly view in a custom editor @@ -369,6 +374,105 @@ export function activate(context: vscode.ExtensionContext) { } }); + // Implement extractCoreWasm command + const extractCoreWasmCommand = vscode.commands.registerCommand( + "wit-idl.extractCoreWasm", + async (resource?: vscode.Uri) => { + try { + const targetUri: vscode.Uri | undefined = resource ?? vscode.window.activeTextEditor?.document.uri; + if (!targetUri) { + vscode.window.showErrorMessage("No file selected."); + return; + } + + const filePath: string = targetUri.fsPath; + if (!filePath.toLowerCase().endsWith(".wasm")) { + vscode.window.showErrorMessage("Selected file is not a .wasm file."); + return; + } + + const isComp: boolean = await isWasmComponent(filePath); + if (!isComp) { + vscode.window.showWarningMessage("The selected .wasm is not a WebAssembly component."); + return; + } + + // Extract using embedded wasm-utils (no external CLI dependency) + const bytes = await vscode.workspace.fs.readFile(targetUri); + const fileMap = await extractCoreWasmFromComponent(bytes); + const entries = Object.entries(fileMap).sort(([a], [b]) => + a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }) + ); + if (entries.length === 0) { + vscode.window.showWarningMessage("No core wasm modules found in this component."); + return; + } + + let [chosenName, chosenContent] = entries[0]; + if (entries.length > 1) { + const pick = await vscode.window.showQuickPick( + entries.map(([name]) => name), + { + placeHolder: "Multiple core wasm modules found. Select one to save.", + } + ); + if (!pick) { + return; + } + const found = entries.find(([name]) => name === pick); + if (found) { + [chosenName, chosenContent] = found; + } + } + + // Now we know which core module is selected, propose a default filename that includes it + const baseName: string = path.basename(filePath, ".wasm"); + const defaultFileName: string = `${baseName}.${chosenName}`; // e.g., mycomp.core0.wasm + const defaultDir: string = path.dirname(filePath); + const saveUri = await vscode.window.showSaveDialog({ + title: "Save extracted Core Wasm", + defaultUri: vscode.Uri.file(path.join(defaultDir, defaultFileName)), + filters: { WebAssembly: ["wasm"], "All Files": ["*"] }, + }); + if (!saveUri) { + return; + } + + // Write selected core wasm to the chosen path + const data = chosenContent; // already Uint8Array + try { + await vscode.workspace.fs.stat(saveUri); + const choice = await vscode.window.showWarningMessage( + `File already exists: ${saveUri.fsPath}. Overwrite?`, + { modal: true }, + "Overwrite", + "Cancel" + ); + if (choice !== "Overwrite") { + return; + } + } catch { + // stat throws if file doesn't exist; continue + } + await vscode.workspace.fs.writeFile(saveUri, data); + + vscode.window.showInformationMessage(`Core wasm extracted to ${saveUri.fsPath}`); + try { + await vscode.commands.executeCommand("vscode.open", saveUri); + } catch (openError) { + vscode.window.showWarningMessage( + `Core wasm was extracted and saved to ${saveUri.fsPath}, but could not be opened automatically. You can open it manually (e.g., with "Open With... Hex Editor").` + ); + } + } catch (error) { + console.error("extractCoreWasm failed:", error); + vscode.window.showErrorMessage( + `Failed to extract core wasm: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + ); + /** * Create a binding generation command for a specific language * @param language - The target language for bindings @@ -625,6 +729,7 @@ export function activate(context: vscode.ExtensionContext) { syntaxCheckWorkspaceCommand, showVersionCommand, extractWitCommand, + extractCoreWasmCommand, generateRustBindingsCommand, generateCBindingsCommand, generateCSharpBindingsCommand, diff --git a/src/wasmUtils.ts b/src/wasmUtils.ts index 40990a5..1e78650 100644 --- a/src/wasmUtils.ts +++ b/src/wasmUtils.ts @@ -149,6 +149,41 @@ export async function extractWitFromComponent(bytes: Uint8Array): Promise binary bytes map. Empty map on failure or none found. + */ +export async function extractCoreWasmFromComponent(bytes: Uint8Array): Promise> { + if (!wasmModule) { + await initializeWasm(); + } + if (!wasmModule) { + throw new Error("WASM module not initialized"); + } + + function hasExtractCore(obj: unknown): obj is { extractCoreWasmFromComponent: (data: Uint8Array) => string } { + return typeof (obj as { extractCoreWasmFromComponent?: unknown }).extractCoreWasmFromComponent === "function"; + } + + const instance = new wasmModule.WitBindgen(); + try { + if (!hasExtractCore(instance)) { + throw new Error("extractCoreWasmFromComponent not available in wit-bindgen-wasm module"); + } + const json = instance.extractCoreWasmFromComponent(bytes); + const textMap = JSON.parse(json || "{}") as Record; + // Decode into binary bytes (latin1-to-bytes convention from WASM side) + const result: Record = {}; + for (const [name, content] of Object.entries(textMap)) { + // Buffer.from with 'latin1' yields the original byte values 0..255 + result[name] = Buffer.from(content, "latin1"); + } + return result; + } finally { + instance.free(); + } +} + /** * Extract interfaces from WIT content using the WASM module * @param content - The WIT content to parse diff --git a/tests/commands.test.ts b/tests/commands.test.ts new file mode 100644 index 0000000..edd4047 --- /dev/null +++ b/tests/commands.test.ts @@ -0,0 +1,42 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, it, expect } from "vitest"; + +type Command = { command: string }; +type MenuItem = { command?: string; when?: string }; +type Contributes = { + commands: Command[]; + menus: Record & { commandPalette: MenuItem[] }; +}; + +describe("command contributions", () => { + const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf8")) as unknown as { + contributes: Contributes; + }; + + it("includes Extract Core Wasm command", () => { + const commands: Command[] = pkg.contributes.commands; + expect(commands.some((c) => c.command === "wit-idl.extractCoreWasm")).toBe(true); + }); + + it("shows Extract Core Wasm in explorer and editor context menus for wasm components", () => { + const menus = pkg.contributes.menus; + const hasInEditor = (menus["editor/context"] || []).some( + (m: MenuItem) => + m.command === "wit-idl.extractCoreWasm" && !!m.when && /witIdl\.isWasmComponent/.test(m.when) + ); + const hasInExplorer = (menus["explorer/context"] || []).some( + (m: MenuItem) => + m.command === "wit-idl.extractCoreWasm" && !!m.when && /witIdl\.isWasmComponent/.test(m.when) + ); + expect(hasInEditor).toBe(true); + expect(hasInExplorer).toBe(true); + }); + + it("shows Extract Core Wasm in command palette when component is active", () => { + const palette: MenuItem[] = pkg.contributes.menus.commandPalette; + const entry = palette.find((m: MenuItem) => m.command === "wit-idl.extractCoreWasm"); + expect(entry).toBeTruthy(); + expect(entry && entry.when ? /witIdl\.isWasmComponent/.test(entry.when) : false).toBe(true); + }); +}); diff --git a/wit-bindgen-wasm/Cargo.lock b/wit-bindgen-wasm/Cargo.lock index cbc67d8..fb30b5f 100644 --- a/wit-bindgen-wasm/Cargo.lock +++ b/wit-bindgen-wasm/Cargo.lock @@ -61,6 +61,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "foldhash", + "serde", ] [[package]] @@ -352,7 +353,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "724fccfd4f3c24b7e589d333fc0429c68042897a7e8a5f8694f31792471841e7" dependencies = [ "leb128fmt", - "wasmparser", + "wasmparser 0.236.1", ] [[package]] @@ -364,7 +365,20 @@ dependencies = [ "anyhow", "indexmap", "wasm-encoder", - "wasmparser", + "wasmparser 0.236.1", +] + +[[package]] +name = "wasmparser" +version = "0.224.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f17a5917c2ddd3819e84c661fae0d6ba29d7b9c1f0e96c708c65a9c4188e11" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", + "serde", ] [[package]] @@ -506,6 +520,7 @@ dependencies = [ "serde_json", "toml", "wasm-bindgen", + "wasmparser 0.224.1", "web-sys", "wee_alloc", "wit-bindgen-c", @@ -532,7 +547,7 @@ dependencies = [ "serde_json", "wasm-encoder", "wasm-metadata", - "wasmparser", + "wasmparser 0.236.1", "wit-parser", ] @@ -551,5 +566,5 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser", + "wasmparser 0.236.1", ] diff --git a/wit-bindgen-wasm/Cargo.toml b/wit-bindgen-wasm/Cargo.toml index 72aec39..95e0114 100644 --- a/wit-bindgen-wasm/Cargo.toml +++ b/wit-bindgen-wasm/Cargo.toml @@ -18,6 +18,7 @@ wasm-bindgen = "0.2.100" wee_alloc = { version = "0.4.5", optional = true } console_error_panic_hook = { version = "0.1.6", optional = true } serde_json = "1.0" +wasmparser = { version = "0.224", features = ["component-model"] } wit-parser = "0.236" wit-bindgen-core = "0.44" wit-bindgen-c = "0.44" diff --git a/wit-bindgen-wasm/src/lib.rs b/wit-bindgen-wasm/src/lib.rs index 966d03d..b8521e8 100644 --- a/wit-bindgen-wasm/src/lib.rs +++ b/wit-bindgen-wasm/src/lib.rs @@ -6,6 +6,7 @@ use wit_parser::{Resolve, PackageId}; use anyhow::Context; // For component decoding (no text printing here; CLI fallback will be used) use wit_component as wcomp; +use wasmparser::{Parser, Payload}; use wit_bindgen_core::Files; use wit_bindgen_rust as rust; use wit_bindgen_c as c; @@ -116,6 +117,46 @@ impl WitBindgen { Ok(out) } + /// Extract core WebAssembly modules from a component. + /// Returns a JSON object mapping filename -> latin1 content. + /// If no core modules are found, returns an empty JSON object. + #[wasm_bindgen(js_name = extractCoreWasmFromComponent)] + pub fn extract_core_wasm_from_component(&self, bytes: &[u8]) -> String { + match Self::extract_core_wasm_impl(bytes) { + Ok(map) => serde_json::to_string(&map).unwrap_or_else(|_| "{}".to_string()), + Err(e) => { + console_error(&format!("Core wasm extraction failed: {}", e)); + "{}".to_string() + } + } + } + + fn extract_core_wasm_impl(bytes: &[u8]) -> anyhow::Result> { + let mut map = std::collections::HashMap::new(); + + // Use wasmparser's incremental Parser with component-model feature enabled. + // This will iterate payloads and yield ModuleSection for embedded core modules. + let parser = Parser::new(0); + let mut index: usize = 0; + for payload in parser.parse_all(bytes) { + let payload = payload?; + match payload { + // Embedded core module inside a component + Payload::ModuleSection { unchecked_range, .. } => { + let module_bytes = &bytes[unchecked_range.start..unchecked_range.end]; + let filename = format!("core{index}.wasm"); + map.insert(filename, bytes_to_latin1_string(module_bytes)); + index += 1; + } + // Nested component: continue; parse_all already iterates it + Payload::ComponentSection { .. } => {} + _ => {} + } + } + + Ok(map) + } + /// Validate WIT syntax using wit-parser #[wasm_bindgen(js_name = validateWitSyntax)] pub fn validate_wit_syntax(&self, content: &str) -> bool {