diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index 0f600de0b..b9e735d1d 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -227,6 +227,58 @@ jobs: echo "grep WASM build: ${grep_short} (${grep_date})" fi + - name: Build find WASM binary + run: | + # uutils findutils ships `find` as a standalone binary (not part of the + # coreutils multicall) and, like grep, depends on the Oniguruma C + # library (onig_sys), so the WASM build needs a WASI sysroot to compile + # the bundled C sources. Reuse the WASI SDK fetched by the grep step if + # it's still around, otherwise download it. + WASI_SDK_VERSION=25 + WASI_SDK_DIR="wasi-sdk-${WASI_SDK_VERSION}.0-x86_64-linux" + if [ ! -d "$WASI_SDK_DIR" ]; then + curl -sL "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${WASI_SDK_VERSION}/${WASI_SDK_DIR}.tar.gz" | tar xz + fi + export WASI_SDK_PATH="$PWD/${WASI_SDK_DIR}" + export CC_wasm32_wasip1="$WASI_SDK_PATH/bin/clang" + export CFLAGS_wasm32_wasip1="--sysroot=$WASI_SDK_PATH/share/wasi-sysroot" + cd findutils + # Only `find` is shipped: xargs/locate/updatedb need process spawning or + # an on-disk database, neither of which works in the browser WASI sandbox. + cargo build --release --target wasm32-wasip1 --bin find + if [ -f target/wasm32-wasip1/release/find.wasm ]; then + mkdir -p ../uutils.github.io/static/wasm + cp target/wasm32-wasip1/release/find.wasm ../uutils.github.io/static/wasm/find.wasm + # Optimize WASM size if wasm-opt is available + if command -v wasm-opt &> /dev/null; then + wasm-opt -Oz ../uutils.github.io/static/wasm/find.wasm -o ../uutils.github.io/static/wasm/find.wasm + fi + echo "find WASM binary size: $(du -h ../uutils.github.io/static/wasm/find.wasm | cut -f1)" + # Advertise find in the playground's command list. find ships as its + # own WASM module, so it isn't picked up by the coreutils feat_wasm + # scan above; append it to the generated list here. + commands_js=../uutils.github.io/static/wasm/commands.js + if [ -f "$commands_js" ]; then + existing=$(sed -n 's/^const WASM_COMMANDS = \[\(.*\)\];$/\1/p' "$commands_js") + echo "const WASM_COMMANDS = [${existing}, \"find\"];" > "$commands_js" + else + echo 'const WASM_COMMANDS = ["find"];' > "$commands_js" + fi + # Record the findutils commit used to build its WASM module so the + # playground can show it alongside the coreutils build. + find_hash=$(git rev-parse HEAD) + find_short=$(git rev-parse --short HEAD) + find_date=$(git show -s --format=%cI HEAD) + { + echo "const UUTILS_FINDUTILS_VERSION = {" + echo " commit: \"${find_hash}\"," + echo " short: \"${find_short}\"," + echo " date: \"${find_date}\"" + echo "};" + } >> ../uutils.github.io/static/wasm/version.js + echo "find WASM build: ${find_short} (${find_date})" + fi + - name: Run Zola uses: shalzz/zola-deploy-action@v0.22.1 env: diff --git a/content/playground.md b/content/playground.md index 14541a528..8adc158ce 100644 --- a/content/playground.md +++ b/content/playground.md @@ -61,6 +61,12 @@ template = "page.html" parts.push('grep ' + UUTILS_GREP_VERSION.short + ' (' + grepDate + ')'); } + if (typeof UUTILS_FINDUTILS_VERSION !== "undefined") { + var findDate = UUTILS_FINDUTILS_VERSION.date.split("T")[0]; + var findUrl = "https://github.com/uutils/findutils/commit/" + UUTILS_FINDUTILS_VERSION.commit; + parts.push('findutils ' + + UUTILS_FINDUTILS_VERSION.short + ' (' + findDate + ')'); + } if (typeof SITE_VERSION !== "undefined") { var siteDate = SITE_VERSION.date.split("T")[0]; var siteUrl = "https://github.com/uutils/uutils.github.io/commit/" + SITE_VERSION.commit; @@ -95,6 +101,7 @@ Click an example to run it in the terminal: + diff --git a/static/js/wasm-terminal.js b/static/js/wasm-terminal.js index 5a841db74..6b002cca8 100644 --- a/static/js/wasm-terminal.js +++ b/static/js/wasm-terminal.js @@ -14,9 +14,14 @@ if (typeof SharedArrayBuffer === "undefined") { } const WASM_URL = "/wasm/uutils.wasm"; -// grep ships as its own standalone WASM module (it is not part of the -// coreutils multicall binary), loaded lazily alongside it. -const GREP_WASM_URL = "/wasm/grep.wasm"; +// Some utilities ship as their own standalone WASM modules rather than as part +// of the coreutils multicall binary (grep lives in uutils/grep, find in +// uutils/findutils). They are loaded lazily alongside the multicall module and +// each is optional — see loadStandaloneWasm. +const STANDALONE_WASM_URLS = { + grep: "/wasm/grep.wasm", + find: "/wasm/find.wasm", +}; const XTERM_CSS = "https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css"; const XTERM_CSS_INTEGRITY = "sha384-tStR1zLfWgsiXCF3IgfB3lBa8KmBe/lG287CL9WCeKgQYcp1bjb4/+mwN6oti4Co"; const XTERM_JS = "https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"; @@ -34,6 +39,11 @@ const SAMPLE_FILES = { "fruits.txt": "banana\napple\ncherry\ndate\napple\nbanana\ncherry\napple\n", "csv.txt": "name,age,city\nAlice,30,Paris\nBob,25,London\nCharlie,35,Berlin\nDiana,28,Tokyo\n", "words.txt": "hello world\nfoo bar baz\nthe quick brown fox\njumps over the lazy dog\n", + // Emoji-named files so `find` has something fun (and Unicode!) to match. + "šŸŽ.md": "# Apple\n", + "šŸŒ.md": "# Banana\n", + "šŸ’.md": "# Cherry\n", + "šŸ„.md": "# Kiwi\n", }; // Commands available in the feat_wasm build. @@ -51,7 +61,7 @@ const FALLBACK_COMMANDS = [ "sha1sum", "sha224sum", "sha256sum", "sha384sum", "sha512sum", "shred", "shuf", "sleep", "sum", "tee", "true", "truncate", "uname", "unexpand", "uniq", "unlink", "vdir", "wc", - "grep", + "grep", "find", ]; const AVAILABLE_COMMANDS = (typeof WASM_COMMANDS !== "undefined" && Array.isArray(WASM_COMMANDS) && WASM_COMMANDS.length > 0) @@ -67,7 +77,9 @@ const LOCALE_SHORTCUTS = { }; let wasmModule = null; -let grepModule = null; // standalone grep WASM module (null if unavailable) +// Compiled standalone modules, keyed by command name (e.g. "grep", "find"). +// A key is present only once its module has loaded successfully. +const standaloneModules = {}; let wasiShim = null; let terminal = null; let inputBuffer = ""; @@ -155,29 +167,32 @@ async function loadWasm() { } /** - * Load the standalone grep WASM module. grep is optional: if the binary isn't - * present (e.g. local dev without a CI build), this resolves to null and grep - * commands report that they're unavailable rather than breaking the terminal. + * Load the optional standalone WASM modules (grep, find, …). Each is optional: + * if a binary isn't present (e.g. local dev without a CI build), its error is + * swallowed and the corresponding command reports that it's unavailable rather + * than breaking the terminal or blocking the coreutils module. */ -async function loadGrepWasm() { - if (grepModule) return grepModule; - try { - const { module, size } = await compileWasmModule(GREP_WASM_URL); - grepModule = module; - wasmSize += size; - } catch (e) { - console.warn("grep WASM unavailable:", e.message); - grepModule = null; - } - return grepModule; +async function loadStandaloneWasm() { + await Promise.all( + Object.entries(STANDALONE_WASM_URLS).map(async ([cmd, url]) => { + if (standaloneModules[cmd]) return; + try { + const { module, size } = await compileWasmModule(url); + standaloneModules[cmd] = module; + wasmSize += size; + } catch (e) { + console.warn(`${cmd} WASM unavailable:`, e.message); + } + }) + ); } async function initWasm() { if (wasmReady) return; try { - // grep is optional and loadGrepWasm swallows its own errors, so it never - // blocks the coreutils module from becoming ready. - await Promise.all([loadWasiShim(), loadWasm(), loadGrepWasm()]); + // The standalone modules are optional and loadStandaloneWasm swallows its + // own errors, so they never block the coreutils module from becoming ready. + await Promise.all([loadWasiShim(), loadWasm(), loadStandaloneWasm()]); wasmReady = true; } catch (e) { // Will fall back to JS implementations @@ -493,6 +508,7 @@ async function executeCommandLine(line) { " wc -l fruits.txt\n" + " seq 1 10 | factor\n" + " grep -i alice names.txt\n" + + " find . -name '*.md'\n" + " basename /usr/local/bin/rustc\n" + " date\n" + " uname -a\n" @@ -566,11 +582,11 @@ async function executeCommandLine(line) { return `uutils: command not found: ${cmd}\nType 'help' for available commands.\n`; } - // grep is a separate WASM module rather than part of the coreutils - // multicall binary. - const isGrep = cmd === "grep"; - if (isGrep && !grepModule) { - return "grep is not available in this build.\n"; + // Some utilities (grep, find) are separate WASM modules rather than part + // of the coreutils multicall binary. + const isStandalone = cmd in STANDALONE_WASM_URLS; + if (isStandalone && !standaloneModules[cmd]) { + return `${cmd} is not available in this build.\n`; } try { @@ -586,10 +602,18 @@ async function executeCommandLine(line) { if (!hasPathArg && cwd && ["ls", "dir"].includes(cmd)) { resolvedArgs.push(cwd); } - // grep is invoked directly (argv[0] = "grep"); coreutils utilities go - // through the multicall dispatcher (argv = ["coreutils", , ...]). + // find takes its starting paths *before* the expression. When the user + // gives none (e.g. `find -type f`), GNU find defaults to "."; mirror that + // but use the virtual cwd so `cd subdir; find` searches the right place. + if (cmd === "find") { + const hasStartPath = resolvedArgs.length > 1 && !resolvedArgs[1].startsWith("-"); + if (!hasStartPath) resolvedArgs.splice(1, 0, cwd || "."); + } + // Standalone utilities are invoked directly (argv[0] = the command name); + // coreutils utilities go through the multicall dispatcher + // (argv = ["coreutils", , ...]). let dispatchArgs = resolvedArgs; - if (isGrep) { + if (cmd === "grep") { // browser_wasi_shim reports stdout as a TTY, so grep would emit GNU // match-highlight escape codes by default. That looks fine in the // terminal but corrupts piped/redirected output (e.g. `grep x | wc`), @@ -599,8 +623,8 @@ async function executeCommandLine(line) { ? resolvedArgs : [resolvedArgs[0], "--color=never", ...resolvedArgs.slice(1)]; } - const wasmArgs = isGrep ? dispatchArgs : ["coreutils", ...resolvedArgs]; - const result = await runCommand(wasmArgs, stdinData, isGrep ? grepModule : wasmModule); + const wasmArgs = isStandalone ? dispatchArgs : ["coreutils", ...resolvedArgs]; + const result = await runCommand(wasmArgs, stdinData, isStandalone ? standaloneModules[cmd] : wasmModule); if (result.stderr) { return result.stderr + result.stdout; } @@ -924,7 +948,8 @@ window._uutilsTestInternals = { get locale() { return currentLocale; }, set locale(v) { currentLocale = v; }, get wasmReady() { return wasmReady; }, - get grepReady() { return grepModule !== null; }, + get grepReady() { return !!standaloneModules.grep; }, + get findReady() { return !!standaloneModules.find; }, initWasm, LOCALE_SHORTCUTS, SAMPLE_FILES, diff --git a/static/js/wasm-terminal.test.html b/static/js/wasm-terminal.test.html index 004ec7b98..130bef07d 100644 --- a/static/js/wasm-terminal.test.html +++ b/static/js/wasm-terminal.test.html @@ -472,6 +472,34 @@ async function runTests() { section("grep WASM module (SKIPPED - grep.wasm not loaded)"); } + // ===== find (standalone WASM module) ===== + if (T.findReady) { + section("find WASM module"); + + assert("find in AVAILABLE_COMMANDS", + AVAILABLE_COMMANDS.includes("find"), true); + + await assertAsync("find by exact name", + executeCommandLine("find . -name names.txt"), + "./names.txt\n"); + + await assertAsync("find with no matches", + executeCommandLine("find . -name '*.csv'"), + ""); + + // Exercises directory traversal plus a pipe into a coreutils utility. + await assertAsync("find piped into sort", + executeCommandLine("find . -type f -name '*.txt' | sort"), + "./csv.txt\n./fruits.txt\n./names.txt\n./numbers.txt\n./words.txt\n"); + + // find matches (and returns) emoji / multibyte filenames. + await assertAsync("find emoji-named files", + executeCommandLine("find . -name '*.md' | sort"), + "./šŸŒ.md\n./šŸŽ.md\n./šŸ’.md\n./šŸ„.md\n"); + } else { + section("find WASM module (SKIPPED - find.wasm not loaded)"); + } + } else { section("l10n WASM integration (SKIPPED - WASM not loaded)"); }