Playground: add support for I/O redirection (< > >>)

Parse < (stdin from file), > (stdout to file), and >> (append) in
the command line. Files are read from and written to the persistent
virtual filesystem, so redirected output persists across commands.
This commit is contained in:
Sylvestre Ledru
2026-04-04 12:38:23 +02:00
parent 99aadcf376
commit c596ff7755
2 changed files with 172 additions and 50 deletions
+103 -37
View File
@@ -331,49 +331,101 @@ async function runCommand(argv, stdinData = "") {
/**
* Parse a command line into a pipeline of commands.
* Supports simple pipes: cmd1 | cmd2 | cmd3
* Pipes inside quotes are treated as literal characters.
* 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) {
const pipeline = [[]];
// 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 (ch === "|" && !inSingle && !inDouble) {
if (current) { pipeline[pipeline.length - 1].push(current); current = ""; }
pipeline.push([]);
continue;
}
if (ch === " " && !inSingle && !inDouble) {
if (current) { pipeline[pipeline.length - 1].push(current); current = ""; }
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) pipeline[pipeline.length - 1].push(current);
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.
*/
@@ -446,9 +498,17 @@ async function executeCommandLine(line) {
const pipeline = parseCommandLine(line);
let stdinData = "";
for (const args of pipeline) {
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];
// Try WASM execution for commands in the WASM build
@@ -472,20 +532,24 @@ async function executeCommandLine(line) {
return result.stderr + result.stdout;
}
stdinData = result.stdout;
continue;
} catch (e) {
return `Error running '${cmd}': ${e.message}\n`;
}
} else {
// JS fallback for commands not in the WASM build or when WASM isn't ready
const result = jsFallback(args, stdinData);
if (result !== null) {
stdinData = result;
} else {
return `uutils: command not found: ${cmd}\nType 'help' for available commands.\n`;
}
}
// JS fallback for commands not in the WASM build or when WASM isn't ready
const result = jsFallback(args, stdinData);
if (result !== null) {
stdinData = result;
continue;
// Handle output redirection: > file or >> file
if (stdoutFile) {
writeVirtualFile(stdoutFile, stdinData, append);
stdinData = "";
}
return `uutils: command not found: ${cmd}\nType 'help' for available commands.\n`;
}
return stdinData;
@@ -880,6 +944,8 @@ window._uutilsTestInternals = {
resolvePath,
lookupDir,
getPersistentDir,
readVirtualFile,
writeVirtualFile,
get cwd() { return cwd; },
set cwd(v) { cwd = v; },
get locale() { return currentLocale; },
+69 -13
View File
@@ -20,7 +20,7 @@
<script>
(async function() {
const T = window._uutilsTestInternals;
const { parseCommandLine, jsFallback, executeCommandLine, resolvePath, lookupDir, getPersistentDir, SAMPLE_FILES, AVAILABLE_COMMANDS } = T;
const { parseCommandLine, jsFallback, executeCommandLine, resolvePath, lookupDir, getPersistentDir, readVirtualFile, writeVirtualFile, SAMPLE_FILES, AVAILABLE_COMMANDS } = T;
let passed = 0;
let failed = 0;
@@ -71,54 +71,77 @@ async function runTests() {
// ===== parseCommandLine =====
section("parseCommandLine");
// Helper: extract just args arrays from parsed pipeline for simpler assertions
const argsOf = (p) => p.map(s => s.args);
assertDeep("simple command",
parseCommandLine("echo hello"),
argsOf(parseCommandLine("echo hello")),
[["echo", "hello"]]);
assertDeep("multiple args",
parseCommandLine("wc -l fruits.txt"),
argsOf(parseCommandLine("wc -l fruits.txt")),
[["wc", "-l", "fruits.txt"]]);
assertDeep("pipe",
parseCommandLine("echo hello | wc -c"),
argsOf(parseCommandLine("echo hello | wc -c")),
[["echo", "hello"], ["wc", "-c"]]);
assertDeep("double pipe",
parseCommandLine("cat file | sort | uniq"),
argsOf(parseCommandLine("cat file | sort | uniq")),
[["cat", "file"], ["sort"], ["uniq"]]);
assertDeep("single quotes",
parseCommandLine("echo 'hello world'"),
argsOf(parseCommandLine("echo 'hello world'")),
[["echo", "hello world"]]);
assertDeep("double quotes",
parseCommandLine('echo "hello world"'),
argsOf(parseCommandLine('echo "hello world"')),
[["echo", "hello world"]]);
assertDeep("escaped space",
parseCommandLine("echo hello\\ world"),
argsOf(parseCommandLine("echo hello\\ world")),
[["echo", "hello world"]]);
assertDeep("mixed quotes",
parseCommandLine("echo 'it\"s' \"a test\""),
argsOf(parseCommandLine("echo 'it\"s' \"a test\"")),
[["echo", 'it"s', "a test"]]);
assertDeep("empty input",
parseCommandLine(""),
argsOf(parseCommandLine("")),
[[]]);
assertDeep("multiple spaces",
parseCommandLine("echo hello world"),
argsOf(parseCommandLine("echo hello world")),
[["echo", "hello", "world"]]);
assertDeep("pipe inside single quotes is literal",
parseCommandLine("echo 'a|b'"),
argsOf(parseCommandLine("echo 'a|b'")),
[["echo", "a|b"]]);
assertDeep("pipe inside double quotes is literal",
parseCommandLine('echo "a|b"'),
argsOf(parseCommandLine('echo "a|b"')),
[["echo", "a|b"]]);
// Redirection parsing
let p = parseCommandLine("sort < input.txt");
assertDeep("stdin redirect args", p[0].args, ["sort"]);
assert("stdin redirect file", p[0].stdin, "input.txt");
p = parseCommandLine("echo hello > output.txt");
assertDeep("stdout redirect args", p[0].args, ["echo", "hello"]);
assert("stdout redirect file", p[0].stdout, "output.txt");
assert("stdout redirect not append", p[0].append, false);
p = parseCommandLine("echo hello >> output.txt");
assert("append redirect file", p[0].stdout, "output.txt");
assert("append redirect flag", p[0].append, true);
p = parseCommandLine("sort < in.txt | head -3 > out.txt");
assert("pipe + redirect: stdin", p[0].stdin, "in.txt");
assertDeep("pipe + redirect: first args", p[0].args, ["sort"]);
assert("pipe + redirect: stdout", p[1].stdout, "out.txt");
assertDeep("pipe + redirect: second args", p[1].args, ["head", "-3"]);
// ===== jsFallback: echo =====
section("jsFallback: echo");
@@ -578,6 +601,39 @@ async function runTests() {
assert("complete 'sh' includes shuf", r.candidates.includes("shuf"), true);
assert("complete 'sh' includes shred", r.candidates.includes("shred"), true);
// ===== I/O redirection =====
section("I/O redirection");
// > creates a file
await executeCommandLine("echo 'hello redirect' > redir_test.txt");
assert("> creates file",
readVirtualFile("redir_test.txt"), "hello redirect\n");
// < reads from file
await assertAsync("< reads file as stdin",
executeCommandLine("wc -c < redir_test.txt"),
"15\n");
// >> appends
await executeCommandLine("echo 'line2' >> redir_test.txt");
assert(">> appends to file",
readVirtualFile("redir_test.txt"), "hello redirect\nline2\n");
// > overwrites
await executeCommandLine("echo 'overwritten' > redir_test.txt");
assert("> overwrites file",
readVirtualFile("redir_test.txt"), "overwritten\n");
// pipe + redirect
await executeCommandLine("echo '3\n1\n2' | sort > sorted.txt");
assert("pipe | sort > file",
readVirtualFile("sorted.txt"), "1\n2\n3\n");
// < with nonexistent file
await assertAsync("< nonexistent file",
executeCommandLine("sort < no_such_file.txt"),
"no_such_file.txt: No such file\n");
// ===== Summary =====
const summary = document.getElementById("summary");
const total = passed + failed;