diff --git a/package.json b/package.json index 4198e3b..fc62fb1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fwdekker.com", - "version": "0.38.5", + "version": "0.39.0", "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 5072fe4..4d27c69 100644 --- a/src/main/js/Commands.ts +++ b/src/main/js/Commands.ts @@ -66,6 +66,14 @@ export class Commands { return ExitCode.COMMAND_NOT_FOUND; } + if (command instanceof Script) { + const parser = InputParser.create(this.environment, this.fileSystem); + return command.lines + .map(line => parser.parseCommands(line)) + .reduce((acc, input) => acc.concat(input)) + .reduce((acc, code) => acc !== 0 ? acc : this.execute(code, streams), 0); + } + const validation = command.validator.validate(input); if (!validation[0]) { streams.err.writeLine(this.createUsageErrorOutput(input.command, command, validation[1])); @@ -82,7 +90,7 @@ export class Commands { * @return the command addressed by the given name or path, an 'Error' if the command could be found but could not * be parsed, or `undefined` if the command could not be found */ - resolve(commandName: string): Command | Error | undefined { + resolve(commandName: string): Command | Script | Error | undefined { const cwd = this.environment.get("cwd"); let script: Node | undefined; @@ -97,7 +105,11 @@ export class Commands { const code = script.open("read").read(); try { - return this.interpretScript(code, this.environment, this.userList, this.fileSystem); + if (code.startsWith("#!/bin/josh\n")) { + return new Script(code.split("\n").slice(1)); + } else { + return this.interpretBinary(code, this.environment, this.userList, this.fileSystem); + } } catch (e) { console.error(`Failed to interpret script '${commandName}'.`, code, e); return e; @@ -105,15 +117,15 @@ export class Commands { } /** - * Interprets the given code and returns the `Command` it describes. + * Interprets the given binary and returns the `Command` it describes. * - * @param code a string describing a `Command` + * @param code a string describing a `Command`, i.e. a "binary" * @param environment the environment in which the code is to be executed * @param userList the list of users relevant to the code * @param fileSystem the file system to refer to when executing code * @return the `Command` described by the given code */ - private interpretScript(code: string, environment: Environment, userList: UserList, + private interpretBinary(code: string, environment: Environment, userList: UserList, fileSystem: FileSystem): Command { const josh = { "environment": environment, @@ -161,6 +173,28 @@ export class Commands { } +/** + * A script referring to other commands that can be executed. + */ +export class Script { + /** + * The lines that make up the script. + */ + readonly lines: string[]; + + + /** + * Constructs a new script from the given lines. + * + * Each line should be a valid line in the command line, i.e. can be parsed into an `InputArgs`. + * + * @param lines the lines that make up the script + */ + constructor(lines: string[]) { + this.lines = lines; + } +} + /** * A command that can be executed. */ diff --git a/src/main/js/FileSystem.ts b/src/main/js/FileSystem.ts index ff0a59a..bab08ec 100644 --- a/src/main/js/FileSystem.ts +++ b/src/main/js/FileSystem.ts @@ -44,7 +44,7 @@ export class FileSystem { return [`${name}.lnk`, new File(entry.link)]; const dir = new Directory(); - entry.entries.forEach((child: any) => dir.add(...(this.unpack(child)))) + entry.entries.forEach((child: any) => dir.add(...(this.unpack(child)))); return [name, dir]; } @@ -708,14 +708,18 @@ export class File extends Node { nameString(name: string, path: Path): string { switch (this.mime ?? getFileExtension(name)) { - case "txt": { - const script = `execute('cat ${path.toString(true)}')`; + case "jsh": { + const script = `execute('${path.toString(true)}'); return false`; return `${name}`; } case "lnk": { const script = `execute('open ${path.toString(true)}'); return false`; return `${name}`; } + case "txt": { + const script = `execute('cat ${path.toString(true)}')`; + return `${name}`; + } default: return name; } diff --git a/src/test/Commands.spec.ts b/src/test/Commands.spec.ts index 740954a..c5d0f56 100644 --- a/src/test/Commands.spec.ts +++ b/src/test/Commands.spec.ts @@ -2,7 +2,7 @@ import {expect} from "chai"; import "jsdom-global"; import "mocha"; -import {Command, commandBinaries, Commands, ExitCode} from "../main/js/Commands"; +import {Command, commandBinaries, Commands, ExitCode, Script} from "../main/js/Commands"; import {Environment} from "../main/js/Environment"; import {Directory, File, FileSystem, Path} from "../main/js/FileSystem"; import {InputParser} from "../main/js/InputParser"; @@ -54,43 +54,74 @@ describe("commands", () => { describe("execute", () => { - it("writes an error if it cannot resolve the command", () => { - expect(execute("does-not-exist")).to.equal(ExitCode.COMMAND_NOT_FOUND); - expect(readErr()).to.equal("Unknown command 'does-not-exist'.\n"); + describe("error handling", () => { + it("writes an error if it cannot resolve the target", () => { + expect(execute("does-not-exist")).to.equal(ExitCode.COMMAND_NOT_FOUND); + expect(readErr()).to.equal("Unknown command 'does-not-exist'.\n"); + }); + + it("writes an error if the target has no shebang and it is not a valid command", () => { + fileSystem.add(new Path("/command"), new File("echo hesitate"), false); + + expect(execute("/command")).to.equal(ExitCode.COMMAND_NOT_FOUND); + expect(readErr()).to.equal("Could not parse command '/command': SyntaxError: Unexpected identifier.\n"); + }); + + it("writes an error if the target is a doc-only command", () => { + fileSystem.add(new Path("/command"), new File(`return new DocOnlyCommand("", "")`), false); + + expect(execute("/command")).to.equal(ExitCode.COMMAND_NOT_FOUND); + expect(readErr()).to.equal("Could not execute doc-only command. Try 'help /command' instead.\n"); + }); + + it("writes an error if the arguments to the command are invalid", () => { + const command = `return new Command("", "", "", "", new InputValidator({minArgs: 2}))`; + fileSystem.add(new Path("/command"), new File(command), false); + + expect(execute("/command arg1")).to.equal(ExitCode.USAGE); + expect(readErr()).to.contain("Invalid usage of '/command'. Expected at least 2 arguments but got 1."); + }); }); - it("writes an error if the command is invalid", () => { - fileSystem.add(new Path("/command"), new File("invalid"), false); + describe("scripts", () => { + it("executes an empty script", () => { + fileSystem.add(new Path("/script"), new File("#!/bin/josh\n"), false); - expect(execute("/command")).to.equal(ExitCode.COMMAND_NOT_FOUND); - expect(readErr()).to.equal("Could not parse command '/command': ReferenceError: invalid is not defined.\n"); + expect(execute("/script")).to.equal(0); + expect(readOut()).to.equal(""); + }); + + 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(0); + expect(readOut()).to.equal("though\nonly\n"); + }); + + it("ignores whitespace around individual lines except the shebang", () => { + loadCommand("echo"); + + fileSystem.add(new Path("/script"), new File("#!/bin/josh\n echo rescue \n echo flour "), false); + + expect(execute("/script")).to.equal(0); + expect(readOut()).to.equal("rescue\nflour\n"); + }); }); - it("writes an error if the command is a doc-only command", () => { - fileSystem.add(new Path("/command"), new File(`return new DocOnlyCommand("", "")`), false); - - expect(execute("/command")).to.equal(ExitCode.COMMAND_NOT_FOUND); - expect(readErr()).to.equal("Could not execute doc-only command. Try 'help /command' instead.\n"); - }); - - it("writes an error if the arguments to the command are invalid", () => { - const command = `return new Command("", "", "", "", new InputValidator({minArgs: 2}))`; - fileSystem.add(new Path("/command"), new File(command), false); - - expect(execute("/command arg1")).to.equal(ExitCode.USAGE); - expect(readErr()).to.contain("Invalid usage of '/command'. Expected at least 2 arguments but got 1."); - }); - - it("executes the command otherwise", () => { - const command = `return new Command( + describe("commands", () => { + it("executes the target as a command if there is no shebang", () => { + const command = `return new Command( (input, streams) => { streams.out.writeLine(input.args[0]); return Number(input.args[1]); }, "", "", "", new InputValidator() )`.trimMultiLines(); - fileSystem.add(new Path("/command"), new File(command), false); + fileSystem.add(new Path("/command"), new File(command), false); - expect(execute("/command output 42")).to.equal(42); - expect(readOut()).to.equal("output\n"); + expect(execute("/command output 42")).to.equal(42); + expect(readOut()).to.equal("output\n"); + }); }); }); @@ -127,6 +158,18 @@ describe("commands", () => { }); }); + describe("scripts", () => { + it("resolves a script if it exists", () => { + fileSystem.add(new Path("/script"), new File("#!/bin/josh\necho square"), true); + + expect((commands.resolve("/script") as Script).lines).to.deep.equal(["echo square"]); + }); + + it("cannot resolve a script if it does not exist", () => { + expect(commands.resolve("/script")).to.equal(undefined); + }); + }); + it("cannot resolve a command if the file cannot be parsed", () => { fileSystem.add(new Path("/command"), new File("invalid"), true);