From 8f283be63347a296450c89efd719abcfe1d4fa21 Mon Sep 17 00:00:00 2001 From: "Felix W. Dekker" Date: Mon, 7 Dec 2020 18:08:20 +0100 Subject: [PATCH] Allow output redirection inside scripts Fixes #146. --- package.json | 2 +- src/main/js/Commands.ts | 45 ++++++++++++++++++++++++++++++--------- src/main/js/Shell.ts | 34 ++++------------------------- src/main/js/Stream.ts | 2 +- src/test/Commands.spec.ts | 29 ++++++++++++++++++++----- 5 files changed, 65 insertions(+), 47 deletions(-) diff --git a/package.json b/package.json index eddfc28..d6485da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fwdekker.com", - "version": "0.39.8", + "version": "0.39.9", "description": "The source code of [my personal website](https://fwdekker.com/).", "author": "Felix W. Dekker", "browser": "dist/bundle.js", diff --git a/src/main/js/Commands.ts b/src/main/js/Commands.ts index 4437f07..e2c75c9 100644 --- a/src/main/js/Commands.ts +++ b/src/main/js/Commands.ts @@ -5,7 +5,7 @@ import {InputArgs} from "./InputArgs"; import {InputParser} from "./InputParser"; import {Persistence} from "./Persistence"; import {escapeHtml, IllegalArgumentError, IllegalStateError, isStandalone} from "./Shared"; -import {StreamSet} from "./Stream"; +import {OutputStream, StreamSet} from "./Stream"; import {EscapeCharacters} from "./Terminal"; import {HashProvider, User, UserList} from "./UserList"; @@ -52,39 +52,48 @@ export class Commands { if (input.command === "") return ExitCode.OK; - let target = this.resolve(input.command); + const localStreams = streams.copy(); + try { + localStreams.out = this.toStream(input.redirectTargets[1]) ?? localStreams.out; + localStreams.err = this.toStream(input.redirectTargets[2]) ?? localStreams.err; + } catch (error) { + streams.err.writeLine(`Error while redirecting output:\n${error.message}`); + return ExitCode.MISC; + } + + const target = this.resolve(input.command); if (target === undefined) { - streams.err.writeLine(`Unknown command '${input.command}'.`); + localStreams.err.writeLine(`Unknown command '${input.command}'.`); return ExitCode.COMMAND_NOT_FOUND; } if (target instanceof Error) { - streams.err.writeLine(`Could not parse command '${input.command}': ${target}.`); + localStreams.err.writeLine(`Could not parse command '${input.command}': ${target}.`); return ExitCode.COMMAND_NOT_FOUND; } if (target instanceof DocOnlyCommand) { - streams.err.writeLine(`Could not execute doc-only command. Try 'help ${input.command}' instead.`); + localStreams.err.writeLine(`Could not execute doc-only command. Try 'help ${input.command}' instead.`); return ExitCode.COMMAND_NOT_FOUND; } if (target instanceof Directory) { return this.execute( new InputArgs("/bin/cd", input.options, [input.command].concat(input.args), input.redirectTargets), - streams + localStreams ); } else if (target instanceof Script) { const parser = InputParser.create(this.environment, this.fileSystem); return target.lines .map(line => parser.parseCommands(line)) - .reduce((acc, input) => acc.concat(input)) - .reduce((acc, code) => acc !== 0 ? acc : this.execute(code, streams), 0); + .reduce((acc, input) => acc.concat(input), []) // .flat() + .reduce((acc, code) => acc !== 0 ? acc : this.execute(code, localStreams), 0); } else { const validation = target.validator.validate(input); if (!validation[0]) { - streams.err.writeLine(this.createUsageErrorOutput(input.command, target, validation[1])); + localStreams.err.writeLine(this.createUsageErrorOutput(input.command, target, validation[1])); return ExitCode.USAGE; } - return target.fun.bind(this)(input, streams); + return target.fun.bind(this)(input, localStreams); } } @@ -187,6 +196,22 @@ export class Commands { Usage ${command.usage}`.trimLines(); } + + /** + * Converts a redirect target to an output stream, or `undefined` if the default stream is used. + * + * @param target the target to convert + * @throws if the stream could not be opened + */ + private toStream(target: InputArgs.RedirectTarget | undefined): OutputStream | undefined { + if (target === undefined) + return undefined; + + if (target.target === undefined) + throw new IllegalStateError("Redirect target's target is undefined."); + + return this.fileSystem.open(Path.interpret(this.environment.get("cwd"), target.target), target.type); + } } diff --git a/src/main/js/Shell.ts b/src/main/js/Shell.ts index 689d123..c44a2c4 100644 --- a/src/main/js/Shell.ts +++ b/src/main/js/Shell.ts @@ -1,4 +1,4 @@ -import {Commands} from "./Commands"; +import {Commands, ExitCode} from "./Commands"; import {Environment} from "./Environment"; import {Directory, FileSystem, Path} from "./FileSystem"; import {InputArgs} from "./InputArgs"; @@ -164,23 +164,13 @@ export class Shell { inputs = InputParser.create(this.environment, this.fileSystem).parseCommands(inputString); } catch (error) { streams.err.writeLine(`Could not parse input: ${error.message}`); - this.environment.set("status", "-1"); + this.environment.set("status", "" + ExitCode.USAGE); return; } inputs.forEach(input => { - const localStreams = streams.copy(); - try { - localStreams.out = this.toStream(input.redirectTargets[1]) ?? localStreams.out; - localStreams.err = this.toStream(input.redirectTargets[2]) ?? localStreams.err; - } catch (error) { - streams.err.writeLine(`Error while redirecting output:\n${error.message}`); - this.environment.set("status", "-1"); - return; - } - - const output = this.commands.execute(input, localStreams); - this.environment.set("status", "" + output); + const status = this.commands.execute(input, streams); + this.environment.set("status", "" + status); if (this.environment.get("user") === "") { this.inputHistory.clear(); @@ -215,20 +205,4 @@ export class Shell { Persistence.setEnvironment(this.environment); Persistence.setFileSystem(this.fileSystem); } - - /** - * Converts a redirect target to an output stream, or `undefined` if the default stream is used. - * - * @param target the target to convert - * @throws if the stream could not be opened - */ - private toStream(target: InputArgs.RedirectTarget | undefined): OutputStream | undefined { - if (target === undefined) - return undefined; - - if (target.target === undefined) - throw new IllegalStateError("Redirect target's target is undefined."); - - return this.fileSystem.open(Path.interpret(this.environment.get("cwd"), target.target), target.type); - } } diff --git a/src/main/js/Stream.ts b/src/main/js/Stream.ts index 1006068..dc538ca 100644 --- a/src/main/js/Stream.ts +++ b/src/main/js/Stream.ts @@ -92,7 +92,7 @@ export class StreamSet { /** - * Returns a copy of this stream set. + * Returns a shallow copy of this stream set. */ copy(): StreamSet { return new StreamSet(this.ins, this.out, this.err); diff --git a/src/test/Commands.spec.ts b/src/test/Commands.spec.ts index e0819c8..0712a8a 100644 --- a/src/test/Commands.spec.ts +++ b/src/test/Commands.spec.ts @@ -97,6 +97,12 @@ describe("commands", () => { }); describe("scripts", () => { + beforeEach(() => loadCommand("echo")); + + + const readFile = (pathString: string) => (fileSystem.get(new Path(pathString)) as File).contents + + it("executes an empty script", () => { fileSystem.add(new Path("/script"), new File("#!/bin/josh\n"), false); @@ -105,22 +111,35 @@ describe("commands", () => { }); it("executes the target as a script if there is a shebang", () => { - loadCommand("echo"); - fileSystem.add(new Path("/script"), new File("#!/bin/josh\necho though\necho only"), false); expect(execute("/script")).to.equal(ExitCode.OK); expect(readOut()).to.equal("though\nonly\n"); }); - it("ignores whitespace around individual lines except the shebang", () => { - loadCommand("echo"); - + it("ignores whitespace around individual lines (other than the shebang)", () => { fileSystem.add(new Path("/script"), new File("#!/bin/josh\n echo rescue \n echo flour "), false); expect(execute("/script")).to.equal(ExitCode.OK); expect(readOut()).to.equal("rescue\nflour\n"); }); + + it("supports output redirection", () => { + fileSystem.add(new Path("/script"), new File("#!/bin/josh\necho flower > /file.txt"), false); + + expect(execute("/script")).to.equal(ExitCode.OK); + expect(readOut()).to.equal(""); + expect(readFile("/file.txt")).to.equal("flower\n"); + }); + + it("support different output redirection than the one the script is invoked under", () => { + fileSystem.add(new Path("/script"), new File("#!/bin/josh\necho sand > /file2.txt\necho hat"), false); + + expect(execute("/script > /file1.txt")).to.equal(ExitCode.OK); + expect(readOut()).to.equal(""); + expect(readFile("/file1.txt")).to.equal("hat\n"); + expect(readFile("/file2.txt")).to.equal("sand\n"); + }); }); describe("commands", () => {