forked from tools/josh
Fix #40
And * Fix absolute paths with rmdir * Fix inverted behaviour of -n with echo * Store environment variables in a session-based cookie * Improve escaped behaviour of > characters * Simplify input parser's tokeniser
This commit is contained in:
parent
c51defad04
commit
493c32d12e
|
@ -2,7 +2,7 @@ import * as Cookies from "js-cookie";
|
||||||
import "./Extensions"
|
import "./Extensions"
|
||||||
import {File, FileSystem} from "./FileSystem"
|
import {File, FileSystem} from "./FileSystem"
|
||||||
import {IllegalStateError, stripHtmlTags} from "./Shared";
|
import {IllegalStateError, stripHtmlTags} from "./Shared";
|
||||||
import {InputArgs} from "./Shell";
|
import {Environment, InputArgs} from "./Shell";
|
||||||
import {EscapeCharacters} from "./Terminal";
|
import {EscapeCharacters} from "./Terminal";
|
||||||
import {UserSession} from "./UserSession";
|
import {UserSession} from "./UserSession";
|
||||||
|
|
||||||
|
@ -11,6 +11,10 @@ import {UserSession} from "./UserSession";
|
||||||
* A collection of commands executed within a particular user session.
|
* A collection of commands executed within a particular user session.
|
||||||
*/
|
*/
|
||||||
export class Commands {
|
export class Commands {
|
||||||
|
/**
|
||||||
|
* The environment in which commands are executed.
|
||||||
|
*/
|
||||||
|
private readonly environment: Environment;
|
||||||
/**
|
/**
|
||||||
* The user session describing the user that executes commands.
|
* The user session describing the user that executes commands.
|
||||||
*/
|
*/
|
||||||
|
@ -28,10 +32,12 @@ export class Commands {
|
||||||
/**
|
/**
|
||||||
* Constructs a new collection of commands executed within the given user session.
|
* Constructs a new collection of commands executed within the given user session.
|
||||||
*
|
*
|
||||||
|
* @param environment the environment in which commands are executed
|
||||||
* @param userSession the user session describing the user that executes commands
|
* @param userSession the user session describing the user that executes commands
|
||||||
* @param fileSystem the file system to interact with
|
* @param fileSystem the file system to interact with
|
||||||
*/
|
*/
|
||||||
constructor(userSession: UserSession, fileSystem: FileSystem) {
|
constructor(environment: Environment, userSession: UserSession, fileSystem: FileSystem) {
|
||||||
|
this.environment = environment;
|
||||||
this.userSession = userSession;
|
this.userSession = userSession;
|
||||||
this.fileSystem = fileSystem;
|
this.fileSystem = fileSystem;
|
||||||
this.commands = {
|
this.commands = {
|
||||||
|
@ -171,6 +177,14 @@ export class Commands {
|
||||||
If more than one directory is given, the directories are removed in the order they are given in.`.trimLines(),
|
If more than one directory is given, the directories are removed in the order they are given in.`.trimLines(),
|
||||||
new InputValidator({minArgs: 1})
|
new InputValidator({minArgs: 1})
|
||||||
),
|
),
|
||||||
|
"set": new Command(
|
||||||
|
this.set,
|
||||||
|
`set environment variable`,
|
||||||
|
`set key [value]`,
|
||||||
|
`Sets the environment variable with the given key to the given value.
|
||||||
|
If no value is given, the environment variable is cleared.`.trimLines(),
|
||||||
|
new InputValidator({minArgs: 1, maxArgs: 2})
|
||||||
|
),
|
||||||
"touch": new Command(
|
"touch": new Command(
|
||||||
this.touch,
|
this.touch,
|
||||||
`change file timestamps`,
|
`change file timestamps`,
|
||||||
|
@ -202,6 +216,7 @@ export class Commands {
|
||||||
Cookies.remove("files");
|
Cookies.remove("files");
|
||||||
Cookies.remove("cwd");
|
Cookies.remove("cwd");
|
||||||
Cookies.remove("user");
|
Cookies.remove("user");
|
||||||
|
Cookies.remove("env");
|
||||||
location.reload();
|
location.reload();
|
||||||
throw new Error("Goodbye");
|
throw new Error("Goodbye");
|
||||||
}
|
}
|
||||||
|
@ -265,7 +280,7 @@ export class Commands {
|
||||||
|
|
||||||
private echo(input: InputArgs): string {
|
private echo(input: InputArgs): string {
|
||||||
return input.args.join(" ").replace("hunter2", "*******")
|
return input.args.join(" ").replace("hunter2", "*******")
|
||||||
+ (input.hasOption("n") ? "\n" : "");
|
+ (input.hasOption("n") ? "" : "\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
private exit(): string {
|
private exit(): string {
|
||||||
|
@ -399,6 +414,18 @@ export class Commands {
|
||||||
return this.fileSystem.rmdirs(input.args);
|
return this.fileSystem.rmdirs(input.args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private set(input: InputArgs): string {
|
||||||
|
if (!input.args[0].match(/^[0-9a-z_]+$/i))
|
||||||
|
return "Environment variable keys can only contain alphanumerical characters and underscores.";
|
||||||
|
|
||||||
|
if (input.args.length === 1)
|
||||||
|
delete this.environment[input.args[0]];
|
||||||
|
else
|
||||||
|
this.environment[input.args[0]] = input.args[1];
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
private touch(input: InputArgs): string {
|
private touch(input: InputArgs): string {
|
||||||
return this.fileSystem.createFiles(input.args);
|
return this.fileSystem.createFiles(input.args);
|
||||||
}
|
}
|
||||||
|
|
|
@ -412,7 +412,7 @@ export class FileSystem {
|
||||||
* @return an empty string if the removal was successful, or a message explaining what went wrong
|
* @return an empty string if the removal was successful, or a message explaining what went wrong
|
||||||
*/
|
*/
|
||||||
private rmdir(pathString: string): string {
|
private rmdir(pathString: string): string {
|
||||||
const path = this._cwd.getChild(pathString);
|
const path = this.getPathTo(pathString);
|
||||||
|
|
||||||
if (path.toString() === "/") {
|
if (path.toString() === "/") {
|
||||||
if (this.root.nodeCount > 0)
|
if (this.root.nodeCount > 0)
|
||||||
|
|
|
@ -10,12 +10,16 @@ import {UserSession} from "./UserSession";
|
||||||
* A shell that interacts with the user session and file system to execute commands.
|
* A shell that interacts with the user session and file system to execute commands.
|
||||||
*/
|
*/
|
||||||
export class Shell {
|
export class Shell {
|
||||||
|
/**
|
||||||
|
* The environment in which commands are executed.
|
||||||
|
*/
|
||||||
|
private readonly environment: Environment;
|
||||||
/**
|
/**
|
||||||
* The history of the user's inputs.
|
* The history of the user's inputs.
|
||||||
*/
|
*/
|
||||||
private readonly inputHistory: InputHistory;
|
private readonly inputHistory: InputHistory;
|
||||||
/**
|
/**
|
||||||
* The user session describing the user that interacts with the terminal.
|
* The user session describing the user that interacts with the shell.
|
||||||
*/
|
*/
|
||||||
private readonly userSession: UserSession;
|
private readonly userSession: UserSession;
|
||||||
/**
|
/**
|
||||||
|
@ -76,7 +80,16 @@ export class Shell {
|
||||||
console.warn("Failed to set cwd from cookie.");
|
console.warn("Failed to set cwd from cookie.");
|
||||||
}
|
}
|
||||||
|
|
||||||
this.commands = new Commands(this.userSession, this.fileSystem);
|
// Read environment from cookie
|
||||||
|
const env = Cookies.get("env") || "{}";
|
||||||
|
try {
|
||||||
|
this.environment = JSON.parse(env);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to set environment from cookie.");
|
||||||
|
this.environment = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.commands = new Commands(this.environment, this.userSession, this.fileSystem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -134,7 +147,7 @@ export class Shell {
|
||||||
*
|
*
|
||||||
* @param inputString the input to process
|
* @param inputString the input to process
|
||||||
*/
|
*/
|
||||||
execute(inputString: string): string {
|
execute(inputString: string): string {
|
||||||
if (!this.userSession.isLoggedIn) {
|
if (!this.userSession.isLoggedIn) {
|
||||||
if (this.attemptUser === undefined) {
|
if (this.attemptUser === undefined) {
|
||||||
this.attemptUser = inputString.trim();
|
this.attemptUser = inputString.trim();
|
||||||
|
@ -153,7 +166,7 @@ export class Shell {
|
||||||
|
|
||||||
this.inputHistory.addEntry(inputString.trim());
|
this.inputHistory.addEntry(inputString.trim());
|
||||||
|
|
||||||
const input = new InputParser().parse(stripHtmlTags(inputString));
|
const input = new InputParser(this.environment).parse(stripHtmlTags(inputString));
|
||||||
if (input.redirectTarget[0] === "write") {
|
if (input.redirectTarget[0] === "write") {
|
||||||
const rms = this.fileSystem.rms([input.redirectTarget[1]], true);
|
const rms = this.fileSystem.rms([input.redirectTarget[1]], true);
|
||||||
if (rms !== "")
|
if (rms !== "")
|
||||||
|
@ -208,6 +221,7 @@ export class Shell {
|
||||||
"path": "/"
|
"path": "/"
|
||||||
});
|
});
|
||||||
Cookies.set("cwd", this.fileSystem.cwd, {"path": "/"});
|
Cookies.set("cwd", this.fileSystem.cwd, {"path": "/"});
|
||||||
|
Cookies.set("env", this.environment, {"path": "/"});
|
||||||
|
|
||||||
const user = this.userSession.currentUser;
|
const user = this.userSession.currentUser;
|
||||||
Cookies.set("user", user === undefined ? "" : user.name, {"path": "/"});
|
Cookies.set("user", user === undefined ? "" : user.name, {"path": "/"});
|
||||||
|
@ -215,6 +229,11 @@ export class Shell {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A set of environment variables.
|
||||||
|
*/
|
||||||
|
export type Environment = { [key: string]: string }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The options given to a command.
|
* The options given to a command.
|
||||||
*/
|
*/
|
||||||
|
@ -327,6 +346,22 @@ export class InputArgs {
|
||||||
* A parser for input strings.
|
* A parser for input strings.
|
||||||
*/
|
*/
|
||||||
export class InputParser {
|
export class InputParser {
|
||||||
|
/**
|
||||||
|
* The environment containing the variables to substitute.
|
||||||
|
*/
|
||||||
|
private readonly environment: Environment;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new input parser.
|
||||||
|
*
|
||||||
|
* @param environment the environment containing the variables to substitute
|
||||||
|
*/
|
||||||
|
constructor(environment: Environment) {
|
||||||
|
this.environment = environment;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses the given input string to a set of command-line arguments.
|
* Parses the given input string to a set of command-line arguments.
|
||||||
*
|
*
|
||||||
|
@ -337,11 +372,7 @@ export class InputParser {
|
||||||
const tokens = this.tokenize(input);
|
const tokens = this.tokenize(input);
|
||||||
const command = tokens[0] || "";
|
const command = tokens[0] || "";
|
||||||
const [options, args] =
|
const [options, args] =
|
||||||
this.parseOpts(
|
this.parseOpts(tokens.slice(1).filter(it => !it.startsWith(`${EscapeCharacters.Escape}`)));
|
||||||
tokens.slice(1)
|
|
||||||
.filter(it => !it.startsWith(">"))
|
|
||||||
.map(it => it.replace(/\\>/, ">"))
|
|
||||||
);
|
|
||||||
const redirectTarget = this.getRedirectTarget(tokens.slice(1));
|
const redirectTarget = this.getRedirectTarget(tokens.slice(1));
|
||||||
|
|
||||||
return new InputArgs(command, options, args, redirectTarget);
|
return new InputArgs(command, options, args, redirectTarget);
|
||||||
|
@ -349,10 +380,28 @@ export class InputParser {
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the first token present in the given string.
|
* Tokenizes the input string.
|
||||||
|
*
|
||||||
|
* @param input the string to tokenize
|
||||||
|
* @return the array of tokens found in the input string
|
||||||
|
*/
|
||||||
|
private tokenize(input: string): string[] {
|
||||||
|
const tokens = [];
|
||||||
|
|
||||||
|
while (input !== "") {
|
||||||
|
let token;
|
||||||
|
[token, input] = this.getNextToken(input);
|
||||||
|
tokens.push(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the first token in the given string and the remaining string.
|
||||||
*
|
*
|
||||||
* @param input the string of which to return the first token
|
* @param input the string of which to return the first token
|
||||||
* @return the first token present in the given string
|
* @return the first token in the given string and the remaining string
|
||||||
*/
|
*/
|
||||||
private getNextToken(input: string): [string, string] {
|
private getNextToken(input: string): [string, string] {
|
||||||
let token = "";
|
let token = "";
|
||||||
|
@ -366,32 +415,10 @@ export class InputParser {
|
||||||
throw new Error("Unexpected end of input. `\\` was used but there was nothing to escape.");
|
throw new Error("Unexpected end of input. `\\` was used but there was nothing to escape.");
|
||||||
|
|
||||||
const nextChar = input[i + 1];
|
const nextChar = input[i + 1];
|
||||||
switch (nextChar) {
|
if (isInSingleQuotes || isInDoubleQuotes)
|
||||||
case "\\":
|
token += "\\" + nextChar;
|
||||||
token += "\\";
|
else
|
||||||
break;
|
token += nextChar;
|
||||||
case "/":
|
|
||||||
if (isInSingleQuotes || isInDoubleQuotes)
|
|
||||||
token += "\\/";
|
|
||||||
else
|
|
||||||
token += "/";
|
|
||||||
break;
|
|
||||||
case "'":
|
|
||||||
token += "'";
|
|
||||||
break;
|
|
||||||
case "\"":
|
|
||||||
token += "\"";
|
|
||||||
break;
|
|
||||||
case " ":
|
|
||||||
token += " ";
|
|
||||||
break;
|
|
||||||
case ">":
|
|
||||||
token += "\\>";
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
token += "\\" + nextChar;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
i++;
|
i++;
|
||||||
break;
|
break;
|
||||||
case "'":
|
case "'":
|
||||||
|
@ -413,22 +440,28 @@ export class InputParser {
|
||||||
return [token, input.slice(i + 1)];
|
return [token, input.slice(i + 1)];
|
||||||
break;
|
break;
|
||||||
case ">":
|
case ">":
|
||||||
if (!isInSingleQuotes && !isInDoubleQuotes) {
|
if (isInSingleQuotes || isInDoubleQuotes) {
|
||||||
if (token !== "")
|
token += char;
|
||||||
return [token, input.slice(i)];
|
break;
|
||||||
|
|
||||||
if (i !== input.length - 1 && input[i + 1] === ">") {
|
|
||||||
const token = this.getNextToken(input.slice(i + 2));
|
|
||||||
token[0] = ">>" + token[0];
|
|
||||||
return token;
|
|
||||||
} else {
|
|
||||||
const token = this.getNextToken(input.slice(i + 1));
|
|
||||||
token[0] = ">" + token[0];
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
token += "\\" + char;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flush current token if not empty
|
||||||
|
if (token !== "")
|
||||||
|
return [token, input.slice(i)];
|
||||||
|
|
||||||
|
if (i !== input.length - 1 && input[i + 1] === ">") {
|
||||||
|
const token = this.getNextToken(input.slice(i + 2));
|
||||||
|
token[0] = `${EscapeCharacters.Escape}>>${token[0]}`;
|
||||||
|
return token;
|
||||||
|
} else {
|
||||||
|
const token = this.getNextToken(input.slice(i + 1));
|
||||||
|
token[0] = `${EscapeCharacters.Escape}>${token[0]}`;
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
case "$":
|
||||||
|
const nextVariable = this.getNextVariable(input.slice(i + 1));
|
||||||
|
token += nextVariable[0];
|
||||||
|
i += nextVariable[1];
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
token += char;
|
token += char;
|
||||||
|
@ -443,21 +476,22 @@ export class InputParser {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tokenizes the input string.
|
* Returns the value of the first environment variable in the given string and the length of the variable name.
|
||||||
*
|
*
|
||||||
* @param input the string to tokenize
|
* @param input the string to find the first environment variable in
|
||||||
* @return the array of tokens found in the input string
|
* @return the value of the first environment variable in the given string and the length of the variable name
|
||||||
*/
|
*/
|
||||||
private tokenize(input: string): string[] {
|
private getNextVariable(input: string): [string, number] {
|
||||||
const tokens = [];
|
let variable = "";
|
||||||
|
let i: number;
|
||||||
|
for (i = 0; i < input.length; i++) {
|
||||||
|
const char = input[i];
|
||||||
|
if (!char.match(/[0-9a-z_]/i))
|
||||||
|
break;
|
||||||
|
|
||||||
while (input !== "") {
|
variable += char;
|
||||||
let token;
|
|
||||||
[token, input] = this.getNextToken(input);
|
|
||||||
tokens.push(token);
|
|
||||||
}
|
}
|
||||||
|
return [this.environment[variable] || "", i];
|
||||||
return tokens;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -472,10 +506,10 @@ export class InputParser {
|
||||||
let redirectTarget: ["default"] | ["write" | "append", string] = ["default"];
|
let redirectTarget: ["default"] | ["write" | "append", string] = ["default"];
|
||||||
|
|
||||||
tokens.forEach(token => {
|
tokens.forEach(token => {
|
||||||
if (token.startsWith(">>"))
|
if (token.startsWith(`${EscapeCharacters.Escape}>>`))
|
||||||
redirectTarget = ["append", token.slice(2)];
|
redirectTarget = ["append", token.slice(3)];
|
||||||
else if (token.startsWith(">"))
|
else if (token.startsWith(`${EscapeCharacters.Escape}>`))
|
||||||
redirectTarget = ["write", token.slice(1)];
|
redirectTarget = ["write", token.slice(2)];
|
||||||
});
|
});
|
||||||
|
|
||||||
return redirectTarget;
|
return redirectTarget;
|
||||||
|
|
|
@ -8,10 +8,12 @@ import {InputParser} from "../main/js/Shell";
|
||||||
describe("input args", () => {
|
describe("input args", () => {
|
||||||
let parser: InputParser;
|
let parser: InputParser;
|
||||||
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
parser = new InputParser();
|
parser = new InputParser({});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
describe("tokenization", () => {
|
describe("tokenization", () => {
|
||||||
it("concatenates multiple strings into one token", () => {
|
it("concatenates multiple strings into one token", () => {
|
||||||
expect(parser.parse(`'co'm"m nd"`).command).to.equal("comm nd");
|
expect(parser.parse(`'co'm"m nd"`).command).to.equal("comm nd");
|
||||||
|
@ -24,6 +26,11 @@ describe("input args", () => {
|
||||||
it("includes escaped quotation marks into the token", () => {
|
it("includes escaped quotation marks into the token", () => {
|
||||||
expect(parser.parse(`com\\'man\\"d`).command).to.equal(`com'man"d`);
|
expect(parser.parse(`com\\'man\\"d`).command).to.equal(`com'man"d`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not escape inside strings", () => {
|
||||||
|
expect(parser.parse(`\\n`).command).to.equal("n");
|
||||||
|
expect(parser.parse(`"\\n"`).command).to.equal("\\n");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("command", () => {
|
describe("command", () => {
|
||||||
|
@ -206,4 +213,37 @@ describe("input args", () => {
|
||||||
expect(inputArgs.args).to.have.members([">", "file"]);
|
expect(inputArgs.args).to.have.members([">", "file"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("environment", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
parser = new InputParser({a: "b", aa: "c", r: ">"});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it("substitutes a known environment variable with its value", () => {
|
||||||
|
expect(parser.parse("$a").command).to.equal("b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("substitutes an unknown environment variable with nothing", () => {
|
||||||
|
expect(parser.parse("$b").command).to.equal("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("substitutes consecutive known environment variables with their value", () => {
|
||||||
|
expect(parser.parse("$a$aa$a").command).to.equal("bcb");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("substitutes nameless environment variables with nothing", () => {
|
||||||
|
expect(parser.parse("$$$").command).to.equal("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("substitutes known environment variables that are in the middle of a string", () => {
|
||||||
|
expect(parser.parse("a'$a'c").command).to.equal("abc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("substitutes special characters without interpreting them", () => {
|
||||||
|
const inputArgs = parser.parse("command $r file");
|
||||||
|
expect(inputArgs.args).to.have.members([">", "file"]);
|
||||||
|
expect(inputArgs.redirectTarget).to.have.members(["default"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in New Issue