You've already forked uutils.github.io
mirror of
https://github.com/uutils/uutils.github.io.git
synced 2026-06-10 16:12:28 -07:00
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:
+103
-37
@@ -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; },
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user