Files
Sylvestre Ledru 77efde6da3 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.
2026-06-02 13:47:45 +02:00

934 lines
31 KiB
JavaScript

/**
* WASM Terminal Playground for uutils coreutils.
*
* Lazy-loads xterm.js and @bjorn3/browser_wasi_shim, then provides an
* interactive terminal where visitors can run uutils commands in the browser.
*/
// The WASI shim checks `instanceof SharedArrayBuffer` in random_get.
// Without cross-origin isolation headers, SharedArrayBuffer is undefined
// in most browsers. Define a stub so the instanceof check returns false
// instead of throwing a ReferenceError.
if (typeof SharedArrayBuffer === "undefined") {
globalThis.SharedArrayBuffer = /** @type {any} */ (function () {});
}
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";
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";
const XTERM_JS_INTEGRITY = "sha384-J4qzUjBl1FxyLsl/kQPQIOeINsmp17OHYXDOMpMxlKX53ZfYsL+aWHpgArvOuof9";
const XTERM_FIT_JS = "https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.11.0/lib/addon-fit.min.js";
const XTERM_FIT_JS_INTEGRITY = "sha384-UwMkGaBqfOcrTjPjXdAPWrGQkhpxTJ21vKtTwLb6wBpBM8HQXKAiUuwVJfgY0Yw6";
// NOTE: dynamic import() does not support SRI integrity checks.
// Pin the exact version to reduce supply-chain risk.
const WASI_SHIM_URL = "https://cdn.jsdelivr.net/npm/@bjorn3/browser_wasi_shim@0.4.0/+esm";
// Sample files for the virtual filesystem
const SAMPLE_FILES = {
"names.txt": "Alice\nBob\nCharlie\nDiana\nEve\nFrank\nGrace\nHeidi\n",
"numbers.txt": "42\n7\n13\n99\n1\n56\n23\n8\n100\n3\n",
"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",
};
// Commands available in the feat_wasm build.
// The workflow generates /wasm/commands.js from coreutils' Cargo.toml feat_wasm
// list and exposes it as `WASM_COMMANDS`. The hardcoded list below is a fallback
// for local dev or when the generated file isn't available.
const FALLBACK_COMMANDS = [
"arch", "b2sum", "base32", "base64", "basenc", "basename", "cat", "cksum",
"comm", "cp", "csplit", "cut", "date", "dd", "dir", "dircolors", "dirname",
"echo", "expand", "expr", "factor", "false", "fmt", "fold", "head",
"join", "link", "ln", "ls", "md5sum", "mkdir", "mv", "nl", "numfmt",
"nproc", "od", "paste", "pathchk", "printenv", "printf", "pr", "ptx", "pwd",
"readlink", "realpath", "rm", "rmdir",
"seq", "sort", "split", "tail", "touch", "tr", "tsort",
"sha1sum", "sha224sum", "sha256sum", "sha384sum", "sha512sum",
"shred", "shuf", "sleep", "sum", "tee", "true", "truncate",
"uname", "unexpand", "uniq", "unlink", "vdir", "wc",
"grep",
];
const AVAILABLE_COMMANDS =
(typeof WASM_COMMANDS !== "undefined" && Array.isArray(WASM_COMMANDS) && WASM_COMMANDS.length > 0)
? WASM_COMMANDS
: FALLBACK_COMMANDS;
// Shortcut mappings for locale command (e.g. "fr" -> "fr-FR")
const LOCALE_SHORTCUTS = {
en: "en-US", fr: "fr-FR", de: "de-DE", es: "es-ES", it: "it-IT",
pt: "pt-BR", ja: "ja-JP", zh: "zh-CN", ko: "ko-KR", ru: "ru-RU",
pl: "pl-PL", nl: "nl-NL", sv: "sv-SE", da: "da-DK", fi: "fi-FI",
nb: "nb-NO", uk: "uk-UA", cs: "cs-CZ", ro: "ro-RO", hu: "hu-HU",
};
let wasmModule = null;
let grepModule = null; // standalone grep WASM module (null if unavailable)
let wasiShim = null;
let terminal = null;
let inputBuffer = "";
let cursorPos = 0;
let history = [];
let historyIndex = -1;
let wasmReady = false;
let wasmSize = 0; // downloaded binary size in bytes
let persistentDir = null;
let cwd = ""; // virtual current working directory (relative to preopened root)
let currentLocale = "en-US"; // current locale for l10n
function loadScript(src, integrity) {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) {
resolve();
return;
}
const s = document.createElement("script");
s.src = src;
if (integrity) {
s.integrity = integrity;
s.crossOrigin = "anonymous";
}
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
}
function loadCSS(href, integrity) {
if (document.querySelector(`link[href="${href}"]`)) return;
const link = document.createElement("link");
link.rel = "stylesheet";
if (integrity) {
link.integrity = integrity;
link.crossOrigin = "anonymous";
}
link.href = href;
document.head.appendChild(link);
}
async function loadWasiShim() {
if (wasiShim) return wasiShim;
wasiShim = await import(WASI_SHIM_URL);
return wasiShim;
}
/**
* Fetch and compile a WASM module from the given URL.
* Returns { module, size } where size is the downloaded byte length (0 if the
* server didn't send a content-length header).
*/
async function compileWasmModule(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch WASM binary: ${response.status}`);
}
const contentLength = response.headers.get("content-length");
const size = contentLength ? parseInt(contentLength, 10) : 0;
// compileStreaming requires application/wasm content-type; fall back if not set.
// Clone the response so the fallback path can read the body without re-fetching.
const cloned = response.clone();
let module;
try {
if (WebAssembly.compileStreaming) {
module = await WebAssembly.compileStreaming(response);
} else {
module = await WebAssembly.compile(await response.arrayBuffer());
}
} catch (e) {
// Some servers don't set proper MIME type, compile from the cloned response
console.warn("WASM compileStreaming failed, falling back to arrayBuffer:", e.message);
module = await WebAssembly.compile(await cloned.arrayBuffer());
}
return { module, size };
}
async function loadWasm() {
if (wasmModule) return wasmModule;
const { module, size } = await compileWasmModule(WASM_URL);
wasmModule = module;
wasmSize = size;
return wasmModule;
}
/**
* 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.
*/
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 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()]);
wasmReady = true;
} catch (e) {
// Will fall back to JS implementations
console.warn("WASM init failed:", e);
}
}
/**
* Get or create the persistent virtual filesystem directory.
* This directory is shared across all command invocations so that
* files created by one command (e.g. mkdir, cp) are visible to the next.
*/
function getPersistentDir() {
if (persistentDir) return persistentDir;
const encoder = new TextEncoder();
// WASI nanosecond timestamp for "now"
const nowNs = BigInt(Date.now()) * 1_000_000n;
const fileEntries = Object.entries(SAMPLE_FILES).map(
([name, content]) => {
const file = new wasiShim.File(encoder.encode(content));
// browser_wasi_shim leaves timestamps at 0 (epoch 1970);
// patch them so ls shows a realistic date.
const origStat = file.stat.bind(file);
file.stat = () => { const s = origStat(); s.atim = nowNs; s.mtim = nowNs; s.ctim = nowNs; return s; };
return [name, file];
}
);
persistentDir = new wasiShim.PreopenDirectory(".", fileEntries);
// Also patch the root directory stat
const origDirStat = persistentDir.dir.stat.bind(persistentDir.dir);
persistentDir.dir.stat = () => { const s = origDirStat(); s.atim = nowNs; s.mtim = nowNs; s.ctim = nowNs; return s; };
return persistentDir;
}
/**
* Resolve a path relative to cwd. Returns a normalized path relative to the
* preopened root directory.
*/
function resolvePath(p) {
if (!p || p.startsWith("-")) return p;
// Absolute paths (starting with /) stay as-is — WASI preopened dir is "."
// so absolute paths won't resolve anyway, but don't mangle them.
if (p.startsWith("/")) return p;
const base = cwd ? cwd.split("/") : [];
const parts = [...base, ...p.split("/")];
const resolved = [];
for (const part of parts) {
if (part === "." || part === "") continue;
if (part === "..") { resolved.pop(); continue; }
resolved.push(part);
}
return resolved.join("/") || ".";
}
/**
* Look up a directory in the virtual filesystem.
* Returns the Directory object if found, or null.
*/
function lookupDir(path) {
if (!wasiShim) return null;
const dir = getPersistentDir();
if (!path || path === ".") return dir.dir;
const parts = path.split("/").filter(Boolean);
let current = dir.dir; // the Directory inside PreopenDirectory
for (const part of parts) {
if (part === ".") continue;
if (part === "..") return null; // can't go above root
const entry = current.contents.get(part);
if (!entry || !entry.contents) return null; // not a directory
current = entry;
}
return current;
}
/**
* Run a single uutils command via the WASM module using browser_wasi_shim.
* Returns { stdout: string, stderr: string, exitCode: number }
*/
async function runCommand(argv, stdinData = "", module = wasmModule) {
if (!wasmReady) throw new Error("WASM not loaded");
if (!module) throw new Error("WASM module not loaded");
const encoder = new TextEncoder();
// Collect raw bytes for stdout/stderr to avoid mid-character UTF-8 splits.
// ConsoleStdout.lineBuffered uses TextDecoder in streaming mode which can
// split multibyte UTF-8 characters across fd_write calls. Instead, we
// use ConsoleStdout with a raw byte callback and decode once at the end.
let stdoutBytes = [];
let stderrBytes = [];
const stdoutFd = new wasiShim.ConsoleStdout((bytes) => {
stdoutBytes.push(new Uint8Array(bytes));
});
const stderrFd = new wasiShim.ConsoleStdout((bytes) => {
stderrBytes.push(new Uint8Array(bytes));
});
// stdin as an OpenFile wrapping a File
const stdinFile = new wasiShim.OpenFile(
new wasiShim.File(encoder.encode(stdinData))
);
const preopenDir = getPersistentDir();
const fds = [
stdinFile, // fd 0: stdin
stdoutFd, // fd 1: stdout
stderrFd, // fd 2: stderr
preopenDir, // fd 3: preopened directory
];
const env = [`LANG=${currentLocale}.UTF-8`, "TERM=xterm-256color"];
const wasi = new wasiShim.WASI(argv, env, fds);
// Workaround for https://github.com/bjorn3/browser_wasi_shim/issues/109
// @bjorn3/browser_wasi_shim args_sizes_get and environ_sizes_get use
// JavaScript's String.length (UTF-16 code units) to report the buffer
// size needed for argv/environ strings. However, WASI expects UTF-8
// byte lengths. For ASCII this is identical, but for multibyte characters
// (CJK, emoji, Arabic, etc.) the UTF-8 encoding is longer than the
// UTF-16 code unit count, causing the WASM runtime to allocate a buffer
// that is too small. The result is truncated arguments and garbled output
// (e.g. "こんにちは世界" → "こんにち3").
// Fix: override both functions to use TextEncoder for correct byte counts.
wasi.wasiImport.args_sizes_get = function(argcPtr, argvBufSizePtr) {
const mem = () => new DataView(wasi.inst.exports.memory.buffer);
mem().setUint32(argcPtr, argv.length, true);
let bufSize = 0;
for (const arg of argv) bufSize += encoder.encode(arg).length + 1;
mem().setUint32(argvBufSizePtr, bufSize, true);
return 0;
};
wasi.wasiImport.environ_sizes_get = function(countPtr, sizePtr) {
const mem = () => new DataView(wasi.inst.exports.memory.buffer);
mem().setUint32(countPtr, env.length, true);
let bufSize = 0;
for (const e of env) bufSize += encoder.encode(e).length + 1;
mem().setUint32(sizePtr, bufSize, true);
return 0;
};
// Workaround: browser_wasi_shim Filestat.write_bytes() only writes the
// defined fields and skips padding bytes (e.g. bytes 17-23 between the
// 1-byte filetype and the 8-byte nlink). Rust allocates the stat buffer
// with MaybeUninit (uninitialized stack memory), so those padding bytes
// contain garbage that corrupts the struct when Rust reads it back.
// Fix: patch write_bytes to zero padding before writing fields.
// Monkey-patch write_bytes to also write the padding/nlink fields that
// the original skips. The original writes filetype as a single byte at
// ptr+16 but leaves bytes 17-23 untouched. On WASM those bytes come from
// uninitialized stack memory (MaybeUninit) and corrupt the struct.
wasiShim.wasi.Filestat.prototype.write_bytes = function(view, ptr) {
view.setBigUint64(ptr, this.dev, true);
view.setBigUint64(ptr + 8, this.ino, true);
view.setBigUint64(ptr + 16, BigInt(this.filetype), true); // zero-extends, clearing padding
view.setBigUint64(ptr + 24, this.nlink, true);
view.setBigUint64(ptr + 32, this.size, true);
view.setBigUint64(ptr + 40, this.atim, true);
view.setBigUint64(ptr + 48, this.mtim, true);
view.setBigUint64(ptr + 56, this.ctim, true);
};
let exitCode = 0;
try {
const result = await WebAssembly.instantiate(module, {
wasi_snapshot_preview1: wasi.wasiImport,
});
// instantiate(Module) returns Instance; instantiate(Buffer) returns {instance}
const instance = result.instance || result;
wasi.start(instance);
} catch (e) {
if (e instanceof wasiShim.WASIProcExit) {
exitCode = e.code;
} else {
return { stdout: "", stderr: e.message + "\n", exitCode: 1 };
}
}
const decoder = new TextDecoder();
const concatBytes = (chunks) => {
const total = chunks.reduce((s, c) => s + c.length, 0);
const buf = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) { buf.set(chunk, offset); offset += chunk.length; }
return decoder.decode(buf);
};
return {
stdout: concatBytes(stdoutBytes),
stderr: concatBytes(stderrBytes),
exitCode,
};
}
/**
* Parse a command line into a pipeline of commands.
* Supports pipes (|), input redirection (<), and output redirection (>, >>).
* Returns an array of stages: { args: string[], stdin: string|null, stdout: string|null, append: boolean }
*/
function parseCommandLine(line) {
// First, tokenize respecting quotes
const tokens = [];
let current = "";
let inSingle = false;
let inDouble = false;
let escape = false;
for (const ch of line) {
if (escape) { current += ch; escape = false; continue; }
if (ch === "\\" && !inSingle) { escape = true; continue; }
if (ch === "'" && !inDouble) { inSingle = !inSingle; continue; }
if (ch === '"' && !inSingle) { inDouble = !inDouble; continue; }
if (!inSingle && !inDouble && (ch === "|" || ch === "<" || ch === ">" || ch === " ")) {
if (current) { tokens.push(current); current = ""; }
if (ch !== " ") tokens.push(ch);
continue;
}
current += ch;
}
if (current) tokens.push(current);
// Merge >> into a single token
for (let i = 0; i < tokens.length - 1; i++) {
if (tokens[i] === ">" && tokens[i + 1] === ">") {
tokens.splice(i, 2, ">>");
}
}
// Split into pipeline stages and extract redirections
const pipeline = [];
let stage = { args: [], stdin: null, stdout: null, append: false };
for (let i = 0; i < tokens.length; i++) {
const tok = tokens[i];
if (tok === "|") {
pipeline.push(stage);
stage = { args: [], stdin: null, stdout: null, append: false };
} else if (tok === "<" && i + 1 < tokens.length) {
stage.stdin = tokens[++i];
} else if ((tok === ">" || tok === ">>") && i + 1 < tokens.length) {
stage.append = tok === ">>";
stage.stdout = tokens[++i];
} else {
stage.args.push(tok);
}
}
pipeline.push(stage);
return pipeline;
}
/**
* Read a file from the virtual filesystem. Returns its content as a string,
* or null if not found.
*/
function readVirtualFile(name) {
const dir = getPersistentDir();
const resolved = resolvePath(name);
const parts = resolved.split("/").filter(Boolean);
let current = dir.dir;
for (let i = 0; i < parts.length - 1; i++) {
const entry = current.contents.get(parts[i]);
if (!entry || !entry.contents) return null;
current = entry;
}
const file = current.contents.get(parts[parts.length - 1] || name);
if (!file || !file.data) return null;
return new TextDecoder().decode(file.data);
}
/**
* Write a file to the virtual filesystem.
*/
function writeVirtualFile(name, content, append) {
const dir = getPersistentDir();
const resolved = resolvePath(name);
const encoder = new TextEncoder();
const existing = dir.dir.contents.get(resolved);
let data;
if (append && existing && existing.data) {
const prev = existing.data;
const added = encoder.encode(content);
data = new Uint8Array(prev.length + added.length);
data.set(prev);
data.set(added, prev.length);
} else {
data = encoder.encode(content);
}
const file = new wasiShim.File(data);
dir.dir.contents.set(resolved, file);
}
/**
* Execute a full command line, handling pipes and builtins.
*/
async function executeCommandLine(line) {
line = line.trim();
if (!line) return "";
// Builtin: help
if (line === "help") {
return (
"Available commands:\n" +
AVAILABLE_COMMANDS.join(", ") +
"\n\nBuiltins: help, clear, cd, locale\n" +
"Sample files: names.txt, numbers.txt, fruits.txt, csv.txt, words.txt\n" +
"\nExamples:\n" +
" echo 'hello world'\n" +
" cat numbers.txt\n" +
" echo '5 3 1 4 2' | fmt -w1 | sort -n\n" +
" wc -l fruits.txt\n" +
" seq 1 10 | factor\n" +
" grep -i alice names.txt\n" +
" basename /usr/local/bin/rustc\n" +
" date\n" +
" uname -a\n"
);
}
// Builtin: clear
if (line === "clear") {
if (terminal) terminal.clear();
return "";
}
// Builtin: cd
if (line === "cd" || line.startsWith("cd ")) {
const target = line === "cd" ? "" : line.slice(3).trim();
if (!target || target === "/" || target === "~") {
cwd = "";
return "";
}
const resolved = resolvePath(target);
if (resolved === ".") {
cwd = "";
return "";
}
const dir = lookupDir(resolved);
if (!dir) {
return `cd: ${target}: No such directory\n`;
}
cwd = resolved;
return "";
}
// Builtin: locale — show or set the current locale
if (line === "locale" || line.startsWith("locale ")) {
const arg = line === "locale" ? "" : line.slice(7).trim();
if (!arg) {
return `LANG=${currentLocale}.UTF-8\n`;
}
// Normalize: "fr" -> "fr-FR", "en" -> "en-US", or accept full form
const normalized = arg.includes("-") ? arg : LOCALE_SHORTCUTS[arg.toLowerCase()] || arg;
currentLocale = normalized;
return `Locale set to ${currentLocale}\n`;
}
// Builtin: pwd (return virtual cwd)
if (line === "pwd") {
return "/" + cwd + "\n";
}
const pipeline = parseCommandLine(line);
let stdinData = "";
for (const stage of pipeline) {
const { args, stdin: stdinFile, stdout: stdoutFile, append } = stage;
if (args.length === 0) continue;
// Handle input redirection: < file
if (stdinFile) {
const content = readVirtualFile(stdinFile);
if (content === null) return `${stdinFile}: No such file\n`;
stdinData = content;
}
const cmd = args[0];
if (!wasmReady) {
return "WASM binary is still loading, please wait...\n";
}
if (!AVAILABLE_COMMANDS.includes(cmd)) {
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";
}
try {
// Resolve relative paths using the virtual cwd
const resolvedArgs = args.map((arg, i) => {
if (i === 0) return arg; // command name
if (arg.startsWith("-")) return arg; // flag
return resolvePath(arg);
});
// If the command takes a default path (like ls) and no path args
// were given, add the cwd so it lists the right directory
const hasPathArg = resolvedArgs.slice(1).some(a => !a.startsWith("-"));
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", <util>, ...]).
let dispatchArgs = resolvedArgs;
if (isGrep) {
// 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`),
// so default to no color unless the user asks for it explicitly.
const hasColorFlag = resolvedArgs.some(a => a === "--color" || a.startsWith("--color="));
dispatchArgs = hasColorFlag
? resolvedArgs
: [resolvedArgs[0], "--color=never", ...resolvedArgs.slice(1)];
}
const wasmArgs = isGrep ? dispatchArgs : ["coreutils", ...resolvedArgs];
const result = await runCommand(wasmArgs, stdinData, isGrep ? grepModule : wasmModule);
if (result.stderr) {
return result.stderr + result.stdout;
}
stdinData = result.stdout;
} catch (e) {
return `Error running '${cmd}': ${e.message}\n`;
}
// Handle output redirection: > file or >> file
if (stdoutFile) {
writeVirtualFile(stdoutFile, stdinData, append);
stdinData = "";
}
}
return stdinData;
}
function writeToTerminal(text) {
if (!terminal) return;
const lines = text.split("\n");
for (let i = 0; i < lines.length; i++) {
terminal.write(lines[i]);
if (i < lines.length - 1) terminal.write("\r\n");
}
}
function promptStr() {
const dir = cwd ? `\x1b[1;34m${cwd}\x1b[0m ` : "";
return `\x1b[1;38;5;166muutils\x1b[0m ${dir}\x1b[1;38;5;166m$\x1b[0m `;
}
function promptLen() {
// Visible character count (without ANSI escapes) for cursor positioning
const dir = cwd ? `${cwd} ` : "";
return `uutils ${dir}$ `.length;
}
function prompt() {
if (!terminal) return;
terminal.write("\r\n" + promptStr());
}
function redrawInput() {
terminal.write("\r" + promptStr() + inputBuffer + "\x1b[K");
const moveBack = inputBuffer.length - cursorPos;
if (moveBack > 0) terminal.write(`\x1b[${moveBack}D`);
}
/**
* Compute tab-completion result (pure function, no terminal side-effects).
* Returns { buffer, cursor, completed, candidates }.
*/
function tabComplete(buffer, cursor) {
const before = buffer.slice(0, cursor);
const after = buffer.slice(cursor);
const parts = before.split(/\s+/);
const word = parts[parts.length - 1];
const isCommand = parts.length <= 1;
const builtins = ["help", "clear", "cd", "locale"];
const candidates = isCommand
? AVAILABLE_COMMANDS.concat(builtins).filter(c => c.startsWith(word))
: Object.keys(SAMPLE_FILES).filter(f => f.startsWith(word));
if (candidates.length === 1) {
const suffix = candidates[0].slice(word.length) + " ";
return { buffer: before + suffix + after, cursor: cursor + suffix.length, completed: true, candidates };
}
if (candidates.length > 1) {
let common = candidates[0];
for (const c of candidates) {
while (!c.startsWith(common)) common = common.slice(0, -1);
}
if (common.length > word.length) {
const suffix = common.slice(word.length);
return { buffer: before + suffix + after, cursor: cursor + suffix.length, completed: true, candidates };
}
return { buffer, cursor, completed: false, candidates };
}
return { buffer, cursor, completed: false, candidates: [] };
}
async function handleInput(data) {
for (let i = 0; i < data.length; i++) {
const ch = data[i];
const code = ch.charCodeAt(0);
// Handle escape sequences
if (ch === "\x1b" && i + 2 < data.length) {
if (data[i + 1] === "[") {
const seq = data[i + 2];
if (seq === "A") { // Up arrow
if (history.length > 0 && historyIndex < history.length - 1) {
historyIndex++;
inputBuffer = history[history.length - 1 - historyIndex];
cursorPos = inputBuffer.length;
redrawInput();
}
i += 2; continue;
}
if (seq === "B") { // Down arrow
if (historyIndex > 0) {
historyIndex--;
inputBuffer = history[history.length - 1 - historyIndex];
cursorPos = inputBuffer.length;
redrawInput();
} else if (historyIndex === 0) {
historyIndex = -1;
inputBuffer = "";
cursorPos = 0;
redrawInput();
}
i += 2; continue;
}
if (seq === "C") { // Right arrow
if (cursorPos < inputBuffer.length) { cursorPos++; terminal.write("\x1b[C"); }
i += 2; continue;
}
if (seq === "D") { // Left arrow
if (cursorPos > 0) { cursorPos--; terminal.write("\x1b[D"); }
i += 2; continue;
}
}
continue;
}
if (code === 13) { // Enter
terminal.write("\r\n");
const line = inputBuffer.trim();
if (line) {
history.push(line);
historyIndex = -1;
const output = await executeCommandLine(line);
if (output) writeToTerminal(output);
}
inputBuffer = "";
cursorPos = 0;
prompt();
continue;
}
if (code === 127 || code === 8) { // Backspace
if (cursorPos > 0) {
inputBuffer = inputBuffer.slice(0, cursorPos - 1) + inputBuffer.slice(cursorPos);
cursorPos--;
redrawInput();
}
continue;
}
if (code === 3) { // Ctrl+C
inputBuffer = "";
cursorPos = 0;
terminal.write("^C");
prompt();
continue;
}
if (code === 12) { // Ctrl+L
terminal.clear();
prompt();
terminal.write(inputBuffer);
continue;
}
if (code === 9) { // Tab — completion
const result = tabComplete(inputBuffer, cursorPos);
if (result.completed) {
inputBuffer = result.buffer;
cursorPos = result.cursor;
redrawInput();
} else if (result.candidates.length > 1) {
terminal.write("\r\n" + result.candidates.join(" ") + "\r\n");
prompt();
terminal.write(inputBuffer);
const back = inputBuffer.length - cursorPos;
if (back > 0) terminal.write(`\x1b[${back}D`);
}
continue;
}
if (code === 21) { // Ctrl+U
inputBuffer = "";
cursorPos = 0;
redrawInput();
continue;
}
if (code >= 32) { // Regular character
inputBuffer = inputBuffer.slice(0, cursorPos) + ch + inputBuffer.slice(cursorPos);
cursorPos++;
redrawInput();
}
}
}
/**
* Initialize the playground terminal in the given container element.
*/
async function initPlayground(containerId) {
const container = document.getElementById(containerId);
if (!container) return;
container.innerHTML = '<div class="wasm-loading">Loading terminal...</div>';
loadCSS(XTERM_CSS, XTERM_CSS_INTEGRITY);
await loadScript(XTERM_JS, XTERM_JS_INTEGRITY);
await loadScript(XTERM_FIT_JS, XTERM_FIT_JS_INTEGRITY);
container.innerHTML = "";
terminal = new window.Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: '"Fira Code", "Cascadia Code", Menlo, Monaco, monospace',
theme: {
background: "#1e1e2e",
foreground: "#cdd6f4",
cursor: "#f5e0dc",
selectionBackground: "#585b7066",
black: "#45475a",
red: "#f38ba8",
green: "#a6e3a1",
yellow: "#f9e2af",
blue: "#89b4fa",
magenta: "#f5c2e7",
cyan: "#94e2d5",
white: "#bac2de",
},
rows: 24,
cols: 80,
});
const fitAddon = new window.FitAddon.FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(container);
fitAddon.fit();
window.addEventListener("resize", () => fitAddon.fit());
terminal.writeln("\x1b[1;38;5;166m _ _ _ _ _ _ _\x1b[0m");
terminal.writeln("\x1b[1;38;5;166m | | | | | | | |_(_) |___\x1b[0m");
terminal.writeln("\x1b[1;38;5;166m | |_| | |_| | _| | (_-<\x1b[0m");
terminal.writeln("\x1b[1;38;5;166m \\___/ \\___/ \\__|_|_/__/\x1b[0m");
terminal.writeln("");
terminal.writeln("Welcome to the \x1b[1muutils coreutils\x1b[0m playground!");
terminal.writeln("Run Rust coreutils directly in your browser via WebAssembly.");
terminal.writeln("");
terminal.write("Loading WASM binary...");
terminal.onData(handleInput);
// Load WASM in background
try {
await initWasm();
const sizeStr = wasmSize > 0 ? ` (${(wasmSize / 1024 / 1024).toFixed(1)} MB)` : "";
terminal.writeln(` \x1b[1;32mdone!\x1b[0m${sizeStr}`);
terminal.writeln("");
terminal.writeln("Type \x1b[1;32mhelp\x1b[0m for available commands.");
terminal.writeln("Sample data files: names.txt, numbers.txt, fruits.txt, csv.txt, words.txt");
} catch (e) {
terminal.writeln(" \x1b[1;31mfailed\x1b[0m");
terminal.writeln("Failed to load WASM binary. Commands are not available.");
terminal.writeln("Try reloading the page.");
}
// Run command(s) from URL ?cmd= parameter if present
const urlCmd = new URLSearchParams(window.location.search).get("cmd");
if (urlCmd) {
for (const cmd of urlCmd.split("\n")) {
if (cmd.trim()) await runInTerminal(cmd.trim());
}
} else {
prompt();
}
}
/**
* Run a command in the terminal, displaying it as if the user typed it.
*/
async function runInTerminal(cmd) {
if (!terminal) return;
// Show the command on the prompt line
terminal.write(cmd);
terminal.write("\r\n");
const output = await executeCommandLine(cmd);
if (output) writeToTerminal(output);
prompt();
}
/**
* Set the locale and optionally update the terminal.
*/
function setLocale(locale) {
currentLocale = locale;
if (terminal) {
terminal.writeln(`\r\nLocale set to ${currentLocale}`);
prompt();
}
}
// Expose globally
window.initPlayground = initPlayground;
window.uutilsExecute = executeCommandLine;
window.runInTerminal = runInTerminal;
window.setLocale = setLocale;
// Expose internals for testing
window._uutilsTestInternals = {
parseCommandLine,
executeCommandLine,
resolvePath,
lookupDir,
getPersistentDir,
readVirtualFile,
writeVirtualFile,
get cwd() { return cwd; },
set cwd(v) { cwd = v; },
get locale() { return currentLocale; },
set locale(v) { currentLocale = v; },
get wasmReady() { return wasmReady; },
get grepReady() { return grepModule !== null; },
initWasm,
LOCALE_SHORTCUTS,
SAMPLE_FILES,
AVAILABLE_COMMANDS,
tabComplete,
};