Skip to content

Commit 20a37bc

Browse files
committed
playground: ship uutils diffutils (diff, cmp)
Add uutils/diffutils to the playground alongside grep and find. The diffutils binary is a multicall that provides diff and cmp, dispatched on argv[0], so each command is invoked directly by name. Unlike grep/find it has only pure-Rust dependencies (regex, not onig), so its WASM build needs no WASI SDK. Generalize the standalone-module registry from command->url to module->{url, commands} so one module can back several commands: diff and cmp share a single diffutils.wasm, load once on demand, and get one 'Load' button. Add a couple of near-identical shopping-*.txt sample files so diff and cmp have a small, readable change to show, plus a diff example, help entry, version footer, and tests (diff/cmp exact-match; diff -u checked on its hunk body, since its header carries an environment-dependent mtime).
1 parent 615c5ea commit 20a37bc

4 files changed

Lines changed: 170 additions & 54 deletions

File tree

.github/workflows/website.yml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ jobs:
5050
path: './grep'
5151
fetch-depth: 0
5252

53+
- name: Checkout Diffutils Repository
54+
uses: actions/checkout@v6
55+
with:
56+
repository: uutils/diffutils
57+
path: './diffutils'
58+
fetch-depth: 0
59+
5360
- name: Install `rust` toolchain
5461
uses: dtolnay/rust-toolchain@stable
5562
with:
@@ -279,6 +286,46 @@ jobs:
279286
echo "find WASM build: ${find_short} (${find_date})"
280287
fi
281288
289+
- name: Build diffutils WASM binary
290+
run: |
291+
# uutils diffutils ships a single multicall binary that provides `diff`
292+
# and `cmp` (dispatched on argv[0]). Unlike grep/find it has only
293+
# pure-Rust dependencies (regex, not onig), so no WASI SDK is needed.
294+
cd diffutils
295+
cargo build --release --target wasm32-wasip1 --bin diffutils
296+
if [ -f target/wasm32-wasip1/release/diffutils.wasm ]; then
297+
mkdir -p ../uutils.github.io/static/wasm
298+
cp target/wasm32-wasip1/release/diffutils.wasm ../uutils.github.io/static/wasm/diffutils.wasm
299+
# Optimize WASM size if wasm-opt is available
300+
if command -v wasm-opt &> /dev/null; then
301+
wasm-opt -Oz ../uutils.github.io/static/wasm/diffutils.wasm -o ../uutils.github.io/static/wasm/diffutils.wasm
302+
fi
303+
echo "diffutils WASM binary size: $(du -h ../uutils.github.io/static/wasm/diffutils.wasm | cut -f1)"
304+
# Advertise diff and cmp in the playground's command list. They ship as
305+
# their own WASM module, so they aren't picked up by the coreutils
306+
# feat_wasm scan above; append them to the generated list here.
307+
commands_js=../uutils.github.io/static/wasm/commands.js
308+
if [ -f "$commands_js" ]; then
309+
existing=$(sed -n 's/^const WASM_COMMANDS = \[\(.*\)\];$/\1/p' "$commands_js")
310+
echo "const WASM_COMMANDS = [${existing}, \"diff\", \"cmp\"];" > "$commands_js"
311+
else
312+
echo 'const WASM_COMMANDS = ["diff", "cmp"];' > "$commands_js"
313+
fi
314+
# Record the diffutils commit used to build its WASM module so the
315+
# playground can show it alongside the coreutils build.
316+
diff_hash=$(git rev-parse HEAD)
317+
diff_short=$(git rev-parse --short HEAD)
318+
diff_date=$(git show -s --format=%cI HEAD)
319+
{
320+
echo "const UUTILS_DIFFUTILS_VERSION = {"
321+
echo " commit: \"${diff_hash}\","
322+
echo " short: \"${diff_short}\","
323+
echo " date: \"${diff_date}\""
324+
echo "};"
325+
} >> ../uutils.github.io/static/wasm/version.js
326+
echo "diffutils WASM build: ${diff_short} (${diff_date})"
327+
fi
328+
282329
- name: Run Zola
283330
uses: shalzz/zola-deploy-action@v0.22.1
284331
env:

content/playground.md

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,44 +28,45 @@ template = "page.html"
2828
<script defer>
2929
document.addEventListener("DOMContentLoaded", function() {
3030
initPlayground("wasm-playground");
31-
// Build a "Load" button per optional standalone program (grep, find).
32-
// These ship as their own WASM modules and load on demand to keep the
33-
// initial page download light; running the command auto-loads it too.
31+
// Build a "Load" button per optional standalone module (grep, find,
32+
// diffutils). These ship as their own WASM modules and load on demand to
33+
// keep the initial page download light; running a command auto-loads its
34+
// module too (e.g. diff/cmp both come from the diffutils module).
3435
var loaderBar = document.getElementById("playground-loaders");
3536
if (loaderBar && Array.isArray(window.uutilsPrograms)) {
36-
window.uutilsPrograms.forEach(function(cmd) {
37+
window.uutilsPrograms.forEach(function(prog) {
3738
var btn = document.createElement("button");
3839
btn.className = "playground-loader";
3940
var markLoaded = function() {
4041
btn.disabled = true;
4142
btn.classList.add("loaded");
42-
btn.textContent = "" + cmd + " loaded";
43+
btn.textContent = "" + prog + " loaded";
4344
};
4445
var setIdleLabel = function(size) {
45-
btn.textContent = "Load " + cmd + (size ? " (" + (size / 1024 / 1024).toFixed(1) + " MB)" : "");
46+
btn.textContent = "Load " + prog + (size ? " (" + (size / 1024 / 1024).toFixed(1) + " MB)" : "");
4647
};
4748
setIdleLabel(0);
48-
window.programSize(cmd).then(function(size) {
49+
window.programSize(prog).then(function(size) {
4950
if (!btn.classList.contains("loaded") && !btn.disabled) setIdleLabel(size);
5051
});
5152
btn.addEventListener("click", function() {
5253
if (btn.disabled) return;
5354
btn.disabled = true;
54-
btn.textContent = "Loading " + cmd + "";
55-
window.loadProgram(cmd).then(function(mod) {
55+
btn.textContent = "Loading " + prog + "";
56+
window.loadProgram(prog).then(function(mod) {
5657
if (mod) {
5758
markLoaded();
5859
} else {
5960
btn.disabled = false;
60-
btn.textContent = cmd + " unavailable";
61+
btn.textContent = prog + " unavailable";
6162
}
6263
});
6364
});
64-
// Keep the button in sync if the program is loaded by running it.
65+
// Keep the button in sync if the module is loaded by running a command.
6566
document.addEventListener("uutils:program-loaded", function(e) {
66-
if (e.detail && e.detail.cmd === cmd) markLoaded();
67+
if (e.detail && e.detail.module === prog) markLoaded();
6768
});
68-
if (window.isProgramLoaded(cmd)) markLoaded();
69+
if (window.isProgramLoaded(prog)) markLoaded();
6970
loaderBar.appendChild(btn);
7071
});
7172
}
@@ -112,6 +113,12 @@ template = "page.html"
112113
parts.push('findutils <a href="' + findUrl + '"><code>' +
113114
UUTILS_FINDUTILS_VERSION.short + '</code></a> (' + findDate + ')');
114115
}
116+
if (typeof UUTILS_DIFFUTILS_VERSION !== "undefined") {
117+
var diffDate = UUTILS_DIFFUTILS_VERSION.date.split("T")[0];
118+
var diffUrl = "https://github.com/uutils/diffutils/commit/" + UUTILS_DIFFUTILS_VERSION.commit;
119+
parts.push('diffutils <a href="' + diffUrl + '"><code>' +
120+
UUTILS_DIFFUTILS_VERSION.short + '</code></a> (' + diffDate + ')');
121+
}
115122
if (typeof SITE_VERSION !== "undefined") {
116123
var siteDate = SITE_VERSION.date.split("T")[0];
117124
var siteUrl = "https://github.com/uutils/uutils.github.io/commit/" + SITE_VERSION.commit;
@@ -147,6 +154,7 @@ Click an example to run it in the terminal:
147154
<button class="playground-example">printf '🍒 cherry\n🍎 apple\n🍌 banana\n' | sort -k2</button>
148155
<button class="playground-example">printf '🍎 apple\n🍌 banana\n🍒 cherry\n🥝 kiwi\n' | grep 🍌</button>
149156
<button class="playground-example">find . -name '*.md'</button>
157+
<button class="playground-example">diff -u shopping-old.txt shopping-new.txt</button>
150158
<button class="playground-example">sort -n < numbers.txt | head -3</button>
151159
<button class="playground-example">date</button>
152160
<button class="playground-example">uname -a</button>

static/js/wasm-terminal.js

Lines changed: 60 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,21 @@ if (typeof SharedArrayBuffer === "undefined") {
1616
const WASM_URL = "/wasm/uutils.wasm";
1717
// Some utilities ship as their own standalone WASM modules rather than as part
1818
// of the coreutils multicall binary (grep lives in uutils/grep, find in
19-
// uutils/findutils). They are loaded lazily alongside the multicall module and
20-
// each is optional — see loadStandaloneWasm.
21-
const STANDALONE_WASM_URLS = {
22-
grep: "/wasm/grep.wasm",
23-
find: "/wasm/find.wasm",
19+
// uutils/findutils, diff and cmp in uutils/diffutils). Each module is loaded on
20+
// demand and is optional — see loadStandalone. A single module can provide
21+
// several commands (diffutils → diff, cmp), which the diffutils binary
22+
// dispatches on argv[0], so each command is invoked directly by its own name.
23+
const STANDALONE_MODULES = {
24+
grep: { url: "/wasm/grep.wasm", commands: ["grep"] },
25+
find: { url: "/wasm/find.wasm", commands: ["find"] },
26+
diffutils: { url: "/wasm/diffutils.wasm", commands: ["diff", "cmp"] },
2427
};
28+
// Map each command to the module that provides it (e.g. diff -> "diffutils").
29+
const STANDALONE_COMMAND_MODULE = Object.fromEntries(
30+
Object.entries(STANDALONE_MODULES).flatMap(
31+
([mod, def]) => def.commands.map(cmd => [cmd, mod])
32+
)
33+
);
2534
const XTERM_CSS = "https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css";
2635
const XTERM_CSS_INTEGRITY = "sha384-tStR1zLfWgsiXCF3IgfB3lBa8KmBe/lG287CL9WCeKgQYcp1bjb4/+mwN6oti4Co";
2736
const XTERM_JS = "https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js";
@@ -44,6 +53,9 @@ const SAMPLE_FILES = {
4453
"🍌.md": "# Banana\n",
4554
"🍒.md": "# Cherry\n",
4655
"🥝.md": "# Kiwi\n",
56+
// Two near-identical lists so `diff`/`cmp` have a small, readable change to show.
57+
"shopping-old.txt": "milk\neggs\nbread\nbutter\napples\n",
58+
"shopping-new.txt": "milk\neggs\nyogurt\nbread\nhoney\napples\n",
4759
};
4860

4961
// Commands available in the feat_wasm build.
@@ -61,7 +73,7 @@ const FALLBACK_COMMANDS = [
6173
"sha1sum", "sha224sum", "sha256sum", "sha384sum", "sha512sum",
6274
"shred", "shuf", "sleep", "sum", "tee", "true", "truncate",
6375
"uname", "unexpand", "uniq", "unlink", "vdir", "wc",
64-
"grep", "find",
76+
"grep", "find", "diff", "cmp",
6577
];
6678
const AVAILABLE_COMMANDS =
6779
(typeof WASM_COMMANDS !== "undefined" && Array.isArray(WASM_COMMANDS) && WASM_COMMANDS.length > 0)
@@ -77,10 +89,10 @@ const LOCALE_SHORTCUTS = {
7789
};
7890

7991
let wasmModule = null;
80-
// Compiled standalone modules, keyed by command name (e.g. "grep", "find").
92+
// Compiled standalone modules, keyed by module name (e.g. "grep", "diffutils").
8193
// A key is present only once its module has loaded successfully.
8294
const standaloneModules = {};
83-
// In-flight loads, keyed by command name, so a button click and a command that
95+
// In-flight loads, keyed by module name, so a button click and a command that
8496
// both trigger a load (or two rapid clicks) share one fetch instead of racing.
8597
const standaloneLoading = {};
8698
let wasiShim = null;
@@ -180,43 +192,46 @@ async function loadWasm() {
180192
* without a CI build) — in which case the command reports it's unavailable
181193
* rather than breaking the terminal.
182194
*
183-
* When `announce` is set and a terminal exists, a "loading <cmd>… done" notice
184-
* is printed live; the button UI instead reacts to the `uutils:program-loaded`
185-
* event dispatched on success.
195+
* When `announce` is set and a terminal exists, a "loading <module>… done"
196+
* notice is printed live; the button UI instead reacts to the
197+
* `uutils:program-loaded` event dispatched on success.
198+
*
199+
* Keyed by module name (e.g. "diffutils"), so commands that share a module
200+
* (diff, cmp) trigger a single fetch.
186201
*/
187-
function loadStandalone(cmd, { announce = false } = {}) {
188-
if (standaloneModules[cmd]) return Promise.resolve(standaloneModules[cmd]);
189-
if (standaloneLoading[cmd]) return standaloneLoading[cmd];
190-
const url = STANDALONE_WASM_URLS[cmd];
202+
function loadStandalone(mod, { announce = false } = {}) {
203+
if (standaloneModules[mod]) return Promise.resolve(standaloneModules[mod]);
204+
if (standaloneLoading[mod]) return standaloneLoading[mod];
205+
const url = STANDALONE_MODULES[mod] && STANDALONE_MODULES[mod].url;
191206
if (!url) return Promise.resolve(null);
192207
const notify = announce && terminal;
193-
if (notify) terminal.write(`loading ${cmd}… `);
194-
standaloneLoading[cmd] = (async () => {
208+
if (notify) terminal.write(`loading ${mod}… `);
209+
standaloneLoading[mod] = (async () => {
195210
try {
196211
const { module, size } = await compileWasmModule(url);
197-
standaloneModules[cmd] = module;
212+
standaloneModules[mod] = module;
198213
wasmSize += size;
199214
if (notify) terminal.write(`done (${(size / 1024 / 1024).toFixed(1)} MB)\r\n`);
200215
if (typeof document !== "undefined") {
201-
document.dispatchEvent(new CustomEvent("uutils:program-loaded", { detail: { cmd, size } }));
216+
document.dispatchEvent(new CustomEvent("uutils:program-loaded", { detail: { module: mod, size } }));
202217
}
203218
return module;
204219
} catch (e) {
205-
console.warn(`${cmd} WASM unavailable:`, e.message);
220+
console.warn(`${mod} WASM unavailable:`, e.message);
206221
if (notify) terminal.write("unavailable\r\n");
207222
return null;
208223
} finally {
209-
delete standaloneLoading[cmd];
224+
delete standaloneLoading[mod];
210225
}
211226
})();
212-
return standaloneLoading[cmd];
227+
return standaloneLoading[mod];
213228
}
214229

215230
async function initWasm() {
216231
if (wasmReady) return;
217232
try {
218233
// Only the coreutils multicall binary loads eagerly; the standalone
219-
// modules (grep, find) are fetched on demand — see loadStandalone.
234+
// modules (grep, find, diffutils) are fetched on demand — see loadStandalone.
220235
await Promise.all([loadWasiShim(), loadWasm()]);
221236
wasmReady = true;
222237
} catch (e) {
@@ -534,6 +549,7 @@ async function executeCommandLine(line) {
534549
" seq 1 10 | factor\n" +
535550
" grep -i alice names.txt\n" +
536551
" find . -name '*.md'\n" +
552+
" diff -u shopping-old.txt shopping-new.txt\n" +
537553
" basename /usr/local/bin/rustc\n" +
538554
" date\n" +
539555
" uname -a\n"
@@ -607,12 +623,14 @@ async function executeCommandLine(line) {
607623
return `uutils: command not found: ${cmd}\nType 'help' for available commands.\n`;
608624
}
609625

610-
// Some utilities (grep, find) are separate WASM modules rather than part
611-
// of the coreutils multicall binary, and are loaded on demand. Fetch the
612-
// module the first time the command is used (no-op once cached).
613-
const isStandalone = cmd in STANDALONE_WASM_URLS;
614-
if (isStandalone && !standaloneModules[cmd]) {
615-
const mod = await loadStandalone(cmd, { announce: true });
626+
// Some utilities (grep, find, diff, cmp) are separate WASM modules rather
627+
// than part of the coreutils multicall binary, and are loaded on demand.
628+
// Fetch the module the first time one of its commands is used (no-op once
629+
// cached; diff and cmp share the single diffutils module).
630+
const moduleName = STANDALONE_COMMAND_MODULE[cmd];
631+
const isStandalone = !!moduleName;
632+
if (isStandalone && !standaloneModules[moduleName]) {
633+
const mod = await loadStandalone(moduleName, { announce: true });
616634
if (!mod) return `${cmd} is not available in this build.\n`;
617635
}
618636

@@ -651,7 +669,7 @@ async function executeCommandLine(line) {
651669
: [resolvedArgs[0], "--color=never", ...resolvedArgs.slice(1)];
652670
}
653671
const wasmArgs = isStandalone ? dispatchArgs : ["coreutils", ...resolvedArgs];
654-
const result = await runCommand(wasmArgs, stdinData, isStandalone ? standaloneModules[cmd] : wasmModule);
672+
const result = await runCommand(wasmArgs, stdinData, isStandalone ? standaloneModules[moduleName] : wasmModule);
655673
if (result.stderr) {
656674
return result.stderr + result.stdout;
657675
}
@@ -914,7 +932,7 @@ async function initPlayground(containerId) {
914932
terminal.writeln("");
915933
terminal.writeln("Type \x1b[1;32mhelp\x1b[0m for available commands.");
916934
terminal.writeln("Sample data files: names.txt, numbers.txt, fruits.txt, csv.txt, words.txt");
917-
terminal.writeln("\x1b[2mgrep and find load on demand — just run them, or use the buttons below.\x1b[0m");
935+
terminal.writeln("\x1b[2mgrep, find and diff/cmp load on demand — just run them, or use the buttons below.\x1b[0m");
918936
} catch (e) {
919937
terminal.writeln(" \x1b[1;31mfailed\x1b[0m");
920938
terminal.writeln("Failed to load WASM binary. Commands are not available.");
@@ -962,15 +980,16 @@ window.uutilsExecute = executeCommandLine;
962980
window.runInTerminal = runInTerminal;
963981
window.setLocale = setLocale;
964982

965-
// On-demand loading of the optional standalone programs (grep, find), used by
966-
// the "Load" buttons on the playground page.
967-
window.uutilsPrograms = Object.keys(STANDALONE_WASM_URLS);
968-
window.loadProgram = (cmd) => loadStandalone(cmd);
969-
window.isProgramLoaded = (cmd) => !!standaloneModules[cmd];
970-
// Best-effort byte size of a program's module, for the button label (0 if the
971-
// server doesn't report Content-Length or the binary is missing).
972-
window.programSize = async (cmd) => {
973-
const url = STANDALONE_WASM_URLS[cmd];
983+
// On-demand loading of the optional standalone modules (grep, find, diffutils),
984+
// used by the "Load" buttons on the playground page. Keyed by module name; one
985+
// module may back several commands (diffutils → diff, cmp).
986+
window.uutilsPrograms = Object.keys(STANDALONE_MODULES);
987+
window.loadProgram = (mod) => loadStandalone(mod);
988+
window.isProgramLoaded = (mod) => !!standaloneModules[mod];
989+
// Best-effort byte size of a module, for the button label (0 if the server
990+
// doesn't report Content-Length or the binary is missing).
991+
window.programSize = async (mod) => {
992+
const url = STANDALONE_MODULES[mod] && STANDALONE_MODULES[mod].url;
974993
if (!url) return 0;
975994
try {
976995
const r = await fetch(url, { method: "HEAD" });
@@ -997,6 +1016,7 @@ window._uutilsTestInternals = {
9971016
get wasmReady() { return wasmReady; },
9981017
get grepReady() { return !!standaloneModules.grep; },
9991018
get findReady() { return !!standaloneModules.find; },
1019+
get diffutilsReady() { return !!standaloneModules.diffutils; },
10001020
initWasm,
10011021
loadStandalone,
10021022
LOCALE_SHORTCUTS,

static/js/wasm-terminal.test.html

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -498,7 +498,7 @@ <h1>wasm-terminal unit tests</h1>
498498
// Exercises directory traversal plus a pipe into a coreutils utility.
499499
await assertAsync("find piped into sort",
500500
executeCommandLine("find . -type f -name '*.txt' | sort"),
501-
"./csv.txt\n./fruits.txt\n./names.txt\n./numbers.txt\n./words.txt\n");
501+
"./csv.txt\n./fruits.txt\n./names.txt\n./numbers.txt\n./shopping-new.txt\n./shopping-old.txt\n./words.txt\n");
502502

503503
// find matches (and returns) emoji / multibyte filenames.
504504
await assertAsync("find emoji-named files",
@@ -508,6 +508,47 @@ <h1>wasm-terminal unit tests</h1>
508508
section("find WASM module (SKIPPED - find.wasm not loaded)");
509509
}
510510

511+
// ===== diff / cmp (diffutils standalone WASM module) =====
512+
// diff and cmp share one module (diffutils). Pre-load it via the on-demand
513+
// loader (also covers that diff and cmp resolve to the same module). Skips
514+
// gracefully if diffutils.wasm is absent.
515+
await T.loadStandalone("diffutils");
516+
if (T.diffutilsReady) {
517+
section("diffutils WASM module (diff, cmp)");
518+
519+
assert("diff in AVAILABLE_COMMANDS",
520+
AVAILABLE_COMMANDS.includes("diff"), true);
521+
assert("cmp in AVAILABLE_COMMANDS",
522+
AVAILABLE_COMMANDS.includes("cmp"), true);
523+
524+
// Normal diff of the two near-identical shopping lists. Exact match is
525+
// safe here (unlike `diff -u`, whose header carries a file mtime).
526+
await assertAsync("diff (normal) shows the changed lines",
527+
executeCommandLine("diff shopping-old.txt shopping-new.txt"),
528+
"2a3,3\n> yogurt\n4c5\n< butter\n---\n> honey\n");
529+
530+
// Unified diff: assert on the stable hunk body (its --- / +++ header
531+
// lines include a timestamp that varies by environment).
532+
const uni = await executeCommandLine("diff -u shopping-old.txt shopping-new.txt");
533+
assert("diff -u has the hunk header", uni.includes("@@ -1,5 +1,6 @@"), true);
534+
assert("diff -u adds yogurt", uni.includes("\n+yogurt\n"), true);
535+
assert("diff -u removes butter", uni.includes("\n-butter\n"), true);
536+
assert("diff -u adds honey", uni.includes("\n+honey\n"), true);
537+
538+
// Identical files: no output, success.
539+
await assertAsync("diff of identical files is empty",
540+
executeCommandLine("diff names.txt names.txt"),
541+
"");
542+
543+
// cmp reports the first differing byte/line. (browser_wasi_shim runs in
544+
// the C locale, so cmp says "byte" rather than "char".)
545+
await assertAsync("cmp reports first difference",
546+
executeCommandLine("cmp shopping-old.txt shopping-new.txt"),
547+
"shopping-old.txt shopping-new.txt differ: byte 11, line 3\n");
548+
} else {
549+
section("diffutils WASM module (SKIPPED - diffutils.wasm not loaded)");
550+
}
551+
511552
} else {
512553
section("l10n WASM integration (SKIPPED - WASM not loaded)");
513554
}

0 commit comments

Comments
 (0)