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 {File, FileSystem} from "./FileSystem"
|
||||
import {IllegalStateError, stripHtmlTags} from "./Shared";
|
||||
import {InputArgs} from "./Shell";
|
||||
import {Environment, InputArgs} from "./Shell";
|
||||
import {EscapeCharacters} from "./Terminal";
|
||||
import {UserSession} from "./UserSession";
|
||||
|
||||
|
@ -11,6 +11,10 @@ import {UserSession} from "./UserSession";
|
|||
* A collection of commands executed within a particular user session.
|
||||
*/
|
||||
export class Commands {
|
||||
/**
|
||||
* The environment in which commands are executed.
|
||||
*/
|
||||
private readonly environment: Environment;
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param environment the environment in which commands are executed
|
||||
* @param userSession the user session describing the user that executes commands
|
||||
* @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.fileSystem = fileSystem;
|
||||
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(),
|
||||
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(
|
||||
this.touch,
|
||||
`change file timestamps`,
|
||||
|
@ -202,6 +216,7 @@ export class Commands {
|
|||
Cookies.remove("files");
|
||||
Cookies.remove("cwd");
|
||||
Cookies.remove("user");
|
||||
Cookies.remove("env");
|
||||
location.reload();
|
||||
throw new Error("Goodbye");
|
||||
}
|
||||
|
@ -265,7 +280,7 @@ export class Commands {
|
|||
|
||||
private echo(input: InputArgs): string {
|
||||
return input.args.join(" ").replace("hunter2", "*******")
|
||||
+ (input.hasOption("n") ? "\n" : "");
|
||||
+ (input.hasOption("n") ? "" : "\n");
|
||||
}
|
||||
|
||||
private exit(): string {
|
||||
|
@ -399,6 +414,18 @@ export class Commands {
|
|||
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 {
|
||||
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
|
||||
*/
|
||||
private rmdir(pathString: string): string {
|
||||
const path = this._cwd.getChild(pathString);
|
||||
const path = this.getPathTo(pathString);
|
||||
|
||||
if (path.toString() === "/") {
|
||||
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.
|
||||
*/
|
||||
export class Shell {
|
||||
/**
|
||||
* The environment in which commands are executed.
|
||||
*/
|
||||
private readonly environment: Environment;
|
||||
/**
|
||||
* The history of the user's inputs.
|
||||
*/
|
||||
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;
|
||||
/**
|
||||
|
@ -76,7 +80,16 @@ export class Shell {
|
|||
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
|
||||
*/
|
||||
execute(inputString: string): string {
|
||||
execute(inputString: string): string {
|
||||
if (!this.userSession.isLoggedIn) {
|
||||
if (this.attemptUser === undefined) {
|
||||
this.attemptUser = inputString.trim();
|
||||
|
@ -153,7 +166,7 @@ export class Shell {
|
|||
|
||||
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") {
|
||||
const rms = this.fileSystem.rms([input.redirectTarget[1]], true);
|
||||
if (rms !== "")
|
||||
|
@ -208,6 +221,7 @@ export class Shell {
|
|||
"path": "/"
|
||||
});
|
||||
Cookies.set("cwd", this.fileSystem.cwd, {"path": "/"});
|
||||
Cookies.set("env", this.environment, {"path": "/"});
|
||||
|
||||
const user = this.userSession.currentUser;
|
||||
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.
|
||||
*/
|
||||
|
@ -327,6 +346,22 @@ export class InputArgs {
|
|||
* A parser for input strings.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
|
@ -337,11 +372,7 @@ export class InputParser {
|
|||
const tokens = this.tokenize(input);
|
||||
const command = tokens[0] || "";
|
||||
const [options, args] =
|
||||
this.parseOpts(
|
||||
tokens.slice(1)
|
||||
.filter(it => !it.startsWith(">"))
|
||||
.map(it => it.replace(/\\>/, ">"))
|
||||
);
|
||||
this.parseOpts(tokens.slice(1).filter(it => !it.startsWith(`${EscapeCharacters.Escape}`)));
|
||||
const redirectTarget = this.getRedirectTarget(tokens.slice(1));
|
||||
|
||||
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
|
||||
* @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] {
|
||||
let token = "";
|
||||
|
@ -366,32 +415,10 @@ export class InputParser {
|
|||
throw new Error("Unexpected end of input. `\\` was used but there was nothing to escape.");
|
||||
|
||||
const nextChar = input[i + 1];
|
||||
switch (nextChar) {
|
||||
case "\\":
|
||||
token += "\\";
|
||||
break;
|
||||
case "/":
|
||||
if (isInSingleQuotes || isInDoubleQuotes)
|
||||
token += "\\/";
|
||||
else
|
||||
token += "/";
|
||||
break;
|
||||
case "'":
|
||||
token += "'";
|
||||
break;
|
||||
case "\"":
|
||||
token += "\"";
|
||||
break;
|
||||
case " ":
|
||||
token += " ";
|
||||
break;
|
||||
case ">":
|
||||
token += "\\>";
|
||||
break;
|
||||
default:
|
||||
token += "\\" + nextChar;
|
||||
break;
|
||||
}
|
||||
if (isInSingleQuotes || isInDoubleQuotes)
|
||||
token += "\\" + nextChar;
|
||||
else
|
||||
token += nextChar;
|
||||
i++;
|
||||
break;
|
||||
case "'":
|
||||
|
@ -413,22 +440,28 @@ export class InputParser {
|
|||
return [token, input.slice(i + 1)];
|
||||
break;
|
||||
case ">":
|
||||
if (!isInSingleQuotes && !isInDoubleQuotes) {
|
||||
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] = ">>" + token[0];
|
||||
return token;
|
||||
} else {
|
||||
const token = this.getNextToken(input.slice(i + 1));
|
||||
token[0] = ">" + token[0];
|
||||
return token;
|
||||
}
|
||||
} else {
|
||||
token += "\\" + char;
|
||||
if (isInSingleQuotes || isInDoubleQuotes) {
|
||||
token += char;
|
||||
break;
|
||||
}
|
||||
|
||||
// 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;
|
||||
default:
|
||||
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
|
||||
* @return the array of tokens found in the input string
|
||||
* @param input the string to find the first environment variable in
|
||||
* @return the value of the first environment variable in the given string and the length of the variable name
|
||||
*/
|
||||
private tokenize(input: string): string[] {
|
||||
const tokens = [];
|
||||
private getNextVariable(input: string): [string, number] {
|
||||
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 !== "") {
|
||||
let token;
|
||||
[token, input] = this.getNextToken(input);
|
||||
tokens.push(token);
|
||||
variable += char;
|
||||
}
|
||||
|
||||
return tokens;
|
||||
return [this.environment[variable] || "", i];
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -472,10 +506,10 @@ export class InputParser {
|
|||
let redirectTarget: ["default"] | ["write" | "append", string] = ["default"];
|
||||
|
||||
tokens.forEach(token => {
|
||||
if (token.startsWith(">>"))
|
||||
redirectTarget = ["append", token.slice(2)];
|
||||
else if (token.startsWith(">"))
|
||||
redirectTarget = ["write", token.slice(1)];
|
||||
if (token.startsWith(`${EscapeCharacters.Escape}>>`))
|
||||
redirectTarget = ["append", token.slice(3)];
|
||||
else if (token.startsWith(`${EscapeCharacters.Escape}>`))
|
||||
redirectTarget = ["write", token.slice(2)];
|
||||
});
|
||||
|
||||
return redirectTarget;
|
||||
|
|
|
@ -8,10 +8,12 @@ import {InputParser} from "../main/js/Shell";
|
|||
describe("input args", () => {
|
||||
let parser: InputParser;
|
||||
|
||||
|
||||
beforeEach(() => {
|
||||
parser = new InputParser();
|
||||
parser = new InputParser({});
|
||||
});
|
||||
|
||||
|
||||
describe("tokenization", () => {
|
||||
it("concatenates multiple strings into one token", () => {
|
||||
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", () => {
|
||||
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", () => {
|
||||
|
@ -206,4 +213,37 @@ describe("input args", () => {
|
|||
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