Skip to content

Commit 77efde6

Browse files
committed
Add uutils grep to the playground
grep ships as its own standalone WASM module (not part of the coreutils multicall binary) and depends on the Oniguruma C library, so the website workflow now checks out uutils/grep, installs the WASI SDK to provide a wasm sysroot for the onig_sys C sources, builds grep.wasm, and appends "grep" to the generated command list. The terminal loads grep.wasm as an optional second module and dispatches grep directly (argv[0] = "grep"). grep defaults to --color=never since the WASI shim reports stdout as a TTY, which would otherwise emit match- highlight escapes that corrupt piped output; explicit --color is honored. Adds a grep emoji example and grep WASM integration tests.
1 parent 5e4b56a commit 77efde6

4 files changed

Lines changed: 136 additions & 12 deletions

File tree

.github/workflows/website.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@ jobs:
4343
path: './findutils'
4444
fetch-depth: 0
4545

46+
- name: Checkout Grep Repository
47+
uses: actions/checkout@v6
48+
with:
49+
repository: uutils/grep
50+
path: './grep'
51+
fetch-depth: 0
52+
4653
- name: Install `rust` toolchain
4754
uses: dtolnay/rust-toolchain@stable
4855
with:
@@ -174,6 +181,39 @@ jobs:
174181
echo "site build: ${site_short} (${site_date})"
175182
fi
176183
184+
- name: Build grep WASM binary
185+
run: |
186+
# uutils grep is a standalone binary (not part of the coreutils
187+
# multicall) and depends on the Oniguruma C library (onig_sys), so the
188+
# WASM build needs a WASI sysroot to compile the bundled C sources.
189+
WASI_SDK_VERSION=25
190+
WASI_SDK_DIR="wasi-sdk-${WASI_SDK_VERSION}.0-x86_64-linux"
191+
curl -sL "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${WASI_SDK_VERSION}/${WASI_SDK_DIR}.tar.gz" | tar xz
192+
export WASI_SDK_PATH="$PWD/${WASI_SDK_DIR}"
193+
export CC_wasm32_wasip1="$WASI_SDK_PATH/bin/clang"
194+
export CFLAGS_wasm32_wasip1="--sysroot=$WASI_SDK_PATH/share/wasi-sysroot"
195+
cd grep
196+
cargo build --release --target wasm32-wasip1 --bin grep
197+
if [ -f target/wasm32-wasip1/release/grep.wasm ]; then
198+
mkdir -p ../uutils.github.io/static/wasm
199+
cp target/wasm32-wasip1/release/grep.wasm ../uutils.github.io/static/wasm/grep.wasm
200+
# Optimize WASM size if wasm-opt is available
201+
if command -v wasm-opt &> /dev/null; then
202+
wasm-opt -Oz ../uutils.github.io/static/wasm/grep.wasm -o ../uutils.github.io/static/wasm/grep.wasm
203+
fi
204+
echo "grep WASM binary size: $(du -h ../uutils.github.io/static/wasm/grep.wasm | cut -f1)"
205+
# Advertise grep in the playground's command list. grep ships as its
206+
# own WASM module, so it isn't picked up by the coreutils feat_wasm
207+
# scan above; append it to the generated list here.
208+
commands_js=../uutils.github.io/static/wasm/commands.js
209+
if [ -f "$commands_js" ]; then
210+
existing=$(sed -n 's/^const WASM_COMMANDS = \[\(.*\)\];$/\1/p' "$commands_js")
211+
echo "const WASM_COMMANDS = [${existing}, \"grep\"];" > "$commands_js"
212+
else
213+
echo 'const WASM_COMMANDS = ["grep"];' > "$commands_js"
214+
fi
215+
fi
216+
177217
- name: Run Zola
178218
uses: shalzz/zola-deploy-action@v0.22.1
179219
env:

content/playground.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ Click an example to run it in the terminal:
8888
<button class="playground-example">printf '🐑\n🐑\n🐑\n🐑\n🐑\n' | nl</button>
8989
<button class="playground-example">echo '🍎,🍌,🍒,🥝' | cut -d🍌 -f2</button>
9090
<button class="playground-example">printf '🍒 cherry\n🍎 apple\n🍌 banana\n' | sort -k2</button>
91+
<button class="playground-example">printf '🍎 apple\n🍌 banana\n🍒 cherry\n🥝 kiwi\n' | grep 🍌</button>
9192
<button class="playground-example">sort -n < numbers.txt | head -3</button>
9293
<button class="playground-example">date</button>
9394
<button class="playground-example">uname -a</button>

static/js/wasm-terminal.js

Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ if (typeof SharedArrayBuffer === "undefined") {
1414
}
1515

1616
const WASM_URL = "/wasm/uutils.wasm";
17+
// grep ships as its own standalone WASM module (it is not part of the
18+
// coreutils multicall binary), loaded lazily alongside it.
19+
const GREP_WASM_URL = "/wasm/grep.wasm";
1720
const XTERM_CSS = "https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css";
1821
const XTERM_CSS_INTEGRITY = "sha384-tStR1zLfWgsiXCF3IgfB3lBa8KmBe/lG287CL9WCeKgQYcp1bjb4/+mwN6oti4Co";
1922
const XTERM_JS = "https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js";
@@ -48,6 +51,7 @@ const FALLBACK_COMMANDS = [
4851
"sha1sum", "sha224sum", "sha256sum", "sha384sum", "sha512sum",
4952
"shred", "shuf", "sleep", "sum", "tee", "true", "truncate",
5053
"uname", "unexpand", "uniq", "unlink", "vdir", "wc",
54+
"grep",
5155
];
5256
const AVAILABLE_COMMANDS =
5357
(typeof WASM_COMMANDS !== "undefined" && Array.isArray(WASM_COMMANDS) && WASM_COMMANDS.length > 0)
@@ -63,6 +67,7 @@ const LOCALE_SHORTCUTS = {
6367
};
6468

6569
let wasmModule = null;
70+
let grepModule = null; // standalone grep WASM module (null if unavailable)
6671
let wasiShim = null;
6772
let terminal = null;
6873
let inputBuffer = "";
@@ -111,35 +116,68 @@ async function loadWasiShim() {
111116
return wasiShim;
112117
}
113118

114-
async function loadWasm() {
115-
if (wasmModule) return wasmModule;
116-
const response = await fetch(WASM_URL);
119+
/**
120+
* Fetch and compile a WASM module from the given URL.
121+
* Returns { module, size } where size is the downloaded byte length (0 if the
122+
* server didn't send a content-length header).
123+
*/
124+
async function compileWasmModule(url) {
125+
const response = await fetch(url);
117126
if (!response.ok) {
118127
throw new Error(`Failed to fetch WASM binary: ${response.status}`);
119128
}
120129
const contentLength = response.headers.get("content-length");
121-
if (contentLength) wasmSize = parseInt(contentLength, 10);
130+
const size = contentLength ? parseInt(contentLength, 10) : 0;
122131
// compileStreaming requires application/wasm content-type; fall back if not set.
123132
// Clone the response so the fallback path can read the body without re-fetching.
124133
const cloned = response.clone();
134+
let module;
125135
try {
126136
if (WebAssembly.compileStreaming) {
127-
wasmModule = await WebAssembly.compileStreaming(response);
137+
module = await WebAssembly.compileStreaming(response);
128138
} else {
129-
wasmModule = await WebAssembly.compile(await response.arrayBuffer());
139+
module = await WebAssembly.compile(await response.arrayBuffer());
130140
}
131141
} catch (e) {
132142
// Some servers don't set proper MIME type, compile from the cloned response
133143
console.warn("WASM compileStreaming failed, falling back to arrayBuffer:", e.message);
134-
wasmModule = await WebAssembly.compile(await cloned.arrayBuffer());
144+
module = await WebAssembly.compile(await cloned.arrayBuffer());
135145
}
146+
return { module, size };
147+
}
148+
149+
async function loadWasm() {
150+
if (wasmModule) return wasmModule;
151+
const { module, size } = await compileWasmModule(WASM_URL);
152+
wasmModule = module;
153+
wasmSize = size;
136154
return wasmModule;
137155
}
138156

157+
/**
158+
* Load the standalone grep WASM module. grep is optional: if the binary isn't
159+
* present (e.g. local dev without a CI build), this resolves to null and grep
160+
* commands report that they're unavailable rather than breaking the terminal.
161+
*/
162+
async function loadGrepWasm() {
163+
if (grepModule) return grepModule;
164+
try {
165+
const { module, size } = await compileWasmModule(GREP_WASM_URL);
166+
grepModule = module;
167+
wasmSize += size;
168+
} catch (e) {
169+
console.warn("grep WASM unavailable:", e.message);
170+
grepModule = null;
171+
}
172+
return grepModule;
173+
}
174+
139175
async function initWasm() {
140176
if (wasmReady) return;
141177
try {
142-
await Promise.all([loadWasiShim(), loadWasm()]);
178+
// grep is optional and loadGrepWasm swallows its own errors, so it never
179+
// blocks the coreutils module from becoming ready.
180+
await Promise.all([loadWasiShim(), loadWasm(), loadGrepWasm()]);
143181
wasmReady = true;
144182
} catch (e) {
145183
// Will fall back to JS implementations
@@ -218,8 +256,9 @@ function lookupDir(path) {
218256
* Run a single uutils command via the WASM module using browser_wasi_shim.
219257
* Returns { stdout: string, stderr: string, exitCode: number }
220258
*/
221-
async function runCommand(argv, stdinData = "") {
259+
async function runCommand(argv, stdinData = "", module = wasmModule) {
222260
if (!wasmReady) throw new Error("WASM not loaded");
261+
if (!module) throw new Error("WASM module not loaded");
223262

224263
const encoder = new TextEncoder();
225264

@@ -306,7 +345,7 @@ async function runCommand(argv, stdinData = "") {
306345

307346
let exitCode = 0;
308347
try {
309-
const result = await WebAssembly.instantiate(wasmModule, {
348+
const result = await WebAssembly.instantiate(module, {
310349
wasi_snapshot_preview1: wasi.wasiImport,
311350
});
312351
// instantiate(Module) returns Instance; instantiate(Buffer) returns {instance}
@@ -453,6 +492,7 @@ async function executeCommandLine(line) {
453492
" echo '5 3 1 4 2' | fmt -w1 | sort -n\n" +
454493
" wc -l fruits.txt\n" +
455494
" seq 1 10 | factor\n" +
495+
" grep -i alice names.txt\n" +
456496
" basename /usr/local/bin/rustc\n" +
457497
" date\n" +
458498
" uname -a\n"
@@ -526,6 +566,13 @@ async function executeCommandLine(line) {
526566
return `uutils: command not found: ${cmd}\nType 'help' for available commands.\n`;
527567
}
528568

569+
// grep is a separate WASM module rather than part of the coreutils
570+
// multicall binary.
571+
const isGrep = cmd === "grep";
572+
if (isGrep && !grepModule) {
573+
return "grep is not available in this build.\n";
574+
}
575+
529576
try {
530577
// Resolve relative paths using the virtual cwd
531578
const resolvedArgs = args.map((arg, i) => {
@@ -539,8 +586,21 @@ async function executeCommandLine(line) {
539586
if (!hasPathArg && cwd && ["ls", "dir"].includes(cmd)) {
540587
resolvedArgs.push(cwd);
541588
}
542-
const wasmArgs = ["coreutils", ...resolvedArgs];
543-
const result = await runCommand(wasmArgs, stdinData);
589+
// grep is invoked directly (argv[0] = "grep"); coreutils utilities go
590+
// through the multicall dispatcher (argv = ["coreutils", <util>, ...]).
591+
let dispatchArgs = resolvedArgs;
592+
if (isGrep) {
593+
// browser_wasi_shim reports stdout as a TTY, so grep would emit GNU
594+
// match-highlight escape codes by default. That looks fine in the
595+
// terminal but corrupts piped/redirected output (e.g. `grep x | wc`),
596+
// so default to no color unless the user asks for it explicitly.
597+
const hasColorFlag = resolvedArgs.some(a => a === "--color" || a.startsWith("--color="));
598+
dispatchArgs = hasColorFlag
599+
? resolvedArgs
600+
: [resolvedArgs[0], "--color=never", ...resolvedArgs.slice(1)];
601+
}
602+
const wasmArgs = isGrep ? dispatchArgs : ["coreutils", ...resolvedArgs];
603+
const result = await runCommand(wasmArgs, stdinData, isGrep ? grepModule : wasmModule);
544604
if (result.stderr) {
545605
return result.stderr + result.stdout;
546606
}
@@ -864,6 +924,7 @@ window._uutilsTestInternals = {
864924
get locale() { return currentLocale; },
865925
set locale(v) { currentLocale = v; },
866926
get wasmReady() { return wasmReady; },
927+
get grepReady() { return grepModule !== null; },
867928
initWasm,
868929
LOCALE_SHORTCUTS,
869930
SAMPLE_FILES,

static/js/wasm-terminal.test.html

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,28 @@ <h1>wasm-terminal unit tests</h1>
450450
"w\nx\n");
451451
}
452452

453+
// ===== grep (standalone WASM module) =====
454+
if (T.grepReady) {
455+
section("grep WASM module");
456+
457+
assert("grep in AVAILABLE_COMMANDS",
458+
AVAILABLE_COMMANDS.includes("grep"), true);
459+
460+
await assertAsync("grep from file",
461+
executeCommandLine("grep Alice names.txt"),
462+
"Alice\n");
463+
464+
await assertAsync("grep -i from stdin",
465+
executeCommandLine("echo 'APPLE\npear\nApricot' | grep -i ap"),
466+
"APPLE\nApricot\n");
467+
468+
await assertAsync("grep regex via pipe",
469+
executeCommandLine("echo 'foo1\nbar\nfoo2' | grep 'foo[0-9]'"),
470+
"foo1\nfoo2\n");
471+
} else {
472+
section("grep WASM module (SKIPPED - grep.wasm not loaded)");
473+
}
474+
453475
} else {
454476
section("l10n WASM integration (SKIPPED - WASM not loaded)");
455477
}

0 commit comments

Comments
 (0)