This commit is contained in:
Florine W. Dekker 2019-11-06 22:27:25 +01:00
parent b605436ab4
commit 9523c31afe
Signed by: FWDekker
GPG Key ID: B1B567AF58D6EE0F
7 changed files with 619 additions and 331 deletions

View File

@ -270,11 +270,11 @@ export class Commands {
.map(arg => Path.interpret(this.environment.get("cwd"), arg))
.map(path => {
if (!this.fileSystem.has(path))
return `cat: ${it}: No such file`;
return `cat: ${path}: No such file`;
const node = this.fileSystem.get(path);
if (!(node instanceof File))
return `cat: ${it}: No such file`;
return `cat: ${path}: No such file`;
return node.contents;
})

View File

@ -1,4 +1,4 @@
import {getFileExtension, IllegalArgumentError, IllegalStateError} from "./Shared";
import {emptyFunction, getFileExtension, IllegalArgumentError, IllegalStateError} from "./Shared";
/**
@ -312,6 +312,19 @@ export abstract class Node {
*/
abstract nameString(name: string, path: Path): string;
/**
* Recursively visits all nodes contained within this node.
*
* @param path the path to the current node
* @param fun the function to apply to each node, including this node
* @param pre the function to apply to the current node before applying the first `fun`
* @param post the function to apply to the current node after applying the last `fun`
*/
abstract visit(path: string,
fun: (node: Node, path: string) => void,
pre: (node: Node, path: string) => void,
post: (node: Node, path: string) => void): void;
/**
* Returns the JSON deserialization of the given string as a node.
@ -451,6 +464,18 @@ export class Directory extends Node {
return `<a href="#" class="dirLink" onclick="execute('cd ${path.toString(true)}');execute('ls')">${name}</a>`;
}
visit(path: string,
fun: (node: Node, path: string) => void,
pre: (node: Node, path: string) => void = emptyFunction,
post: (node: Node, path: string) => void = emptyFunction) {
pre(this, path);
fun(this, path);
Object.keys(this._nodes).forEach(name => this._nodes[name].visit(`${path}/${name}`, fun, pre, post));
post(this, path);
}
/**
* Parses the given object into a directory.
@ -509,6 +534,15 @@ export class File extends Node {
}
}
visit(path: string,
fun: (node: Node, path: string) => void,
pre: (node: Node, path: string) => void = emptyFunction,
post: (node: Node, path: string) => void = emptyFunction) {
pre(this, path);
fun(this, path);
post(this, path);
}
/**
* Parses the given object into a file.

335
src/main/js/InputParser.ts Normal file
View File

@ -0,0 +1,335 @@
import {Environment} from "./Environment";
import {Directory, FileSystem, Path} from "./FileSystem";
import {IllegalArgumentError, IllegalStateError} from "./Shared";
import {InputArgs} from "./Shell";
import {EscapeCharacters} from "./Terminal";
/**
* A parser for input strings.
*/
export class InputParser {
/**
* The tokenizer turn the input into tokens with.
*/
private readonly tokenizer: Tokenizer;
/**
* The globber to glob file paths with.
*/
private readonly globber: Globber;
/**
* Constructs a new input parser.
*
* Usually, you'll want to use the static `InputParser#create` method instead.
*
* @param tokenizer the tokenizer turn the input into tokens with
* @param globber the globber to glob file paths with
*/
constructor(tokenizer: Tokenizer, globber: Globber) {
this.tokenizer = tokenizer;
this.globber = globber;
}
/**
* Constructs a new input parser.
*
* @param environment the environment containing the variables to substitute
* @param fileSystem the file system describing the valid paths to glob
*/
static create(environment: Environment, fileSystem: FileSystem): InputParser {
return new InputParser(new Tokenizer(environment), new Globber(fileSystem, environment.get("cwd")));
}
/**
* Parses the given input string to a set of command-line arguments.
*
* @param input the string to parse
*/
parse(input: string): InputArgs {
const tokens = this.globber.glob(this.tokenizer.tokenize(input));
const command = tokens[0] ?? "";
const [options, args] = 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);
}
/**
* Returns the redirect target described by the last token that describes a redirect target, or the default redirect
* target if no token describes a redirect target.
*
* @param tokens an array of tokens of which some tokens may describe a redirect target
*/
private getRedirectTarget(tokens: string[]): InputArgs.RedirectTarget {
let redirectTarget: InputArgs.RedirectTarget = ["default"];
tokens.forEach(token => {
if (token.startsWith(`${EscapeCharacters.Escape}>${EscapeCharacters.Escape}>`))
redirectTarget = ["append", token.slice(4)];
else if (token.startsWith(`${EscapeCharacters.Escape}>`))
redirectTarget = ["write", token.slice(2)];
});
return redirectTarget;
}
/**
* Parses options and arguments.
*
* @param tokens the tokens that form the options and arguments
*/
private parseOpts(tokens: string[]): [InputArgs.Options, string[]] {
const options: { [key: string]: string | null } = {};
let i;
for (i = 0; i < tokens.length; i++) {
const arg = tokens[i];
if (!arg.startsWith("-") || arg === "--")
break;
const argsParts = arg.split(/=(.*)/, 2);
if (argsParts.length === 0 || argsParts.length > 2)
throw new IllegalArgumentError("Unexpected number of parts.");
if (argsParts[0].indexOf(' ') >= 0)
break;
const value = argsParts.length === 1 ? null : argsParts[1];
if (argsParts[0].startsWith("--")) {
const key = argsParts[0].substr(2);
if (key === "")
break;
options[key] = value;
} else {
const keys = argsParts[0].substr(1);
if (keys === "")
break;
if (keys.length === 1) {
options[keys] = value;
} else {
if (value !== null)
throw new IllegalArgumentError("Cannot assign value to multiple short options.");
for (const key of keys)
options[key] = value;
}
}
}
return [options, tokens.slice(i)];
}
}
/**
* Turns an input string into a series of expanded tokens.
*/
export class Tokenizer {
/**
* The environment containing the variables to substitute.
*/
private readonly environment: Environment;
/**
* Constructs a new tokenizer.
*
* @param environment the environment containing the variables to substitute
*/
constructor(environment: Environment) {
this.environment = environment;
}
/**
* Tokenizes the input string and expands the tokens.
*
* @param input the string to tokenize
*/
tokenize(input: string): string[] {
const tokens: string[] = [];
let token = "";
let isInSingleQuotes = false;
let isInDoubleQuotes = false;
let isInCurlyBraces = 0;
for (let i = 0; i < input.length; i++) {
const char = input[i];
switch (char) {
case "\\":
if (i === input.length - 1)
throw new IllegalArgumentError(
"Unexpected end of input. '\\' was used but there was nothing to escape.");
const nextChar = input[i + 1];
if (isInSingleQuotes || isInDoubleQuotes)
token += "\\" + nextChar;
else if (nextChar === "n")
token += "\n";
else
token += nextChar;
i++;
break;
case "'":
if (isInDoubleQuotes)
token += char;
else
isInSingleQuotes = !isInSingleQuotes;
break;
case "\"":
if (isInSingleQuotes)
token += char;
else
isInDoubleQuotes = !isInDoubleQuotes;
break;
case "{":
if (isInSingleQuotes || isInDoubleQuotes)
token += char;
else
isInCurlyBraces++;
break;
case "}":
if (isInSingleQuotes || isInDoubleQuotes)
token += char;
else
isInCurlyBraces--;
if (isInCurlyBraces < 0)
throw new IllegalArgumentError("Unexpected closing '}' without corresponding '{'.");
break;
case " ":
if (isInSingleQuotes || isInDoubleQuotes) {
token += char;
} else if (token !== "") {
tokens.push(token);
token = "";
}
break;
case "$":
if (isInSingleQuotes || isInDoubleQuotes) {
token += char;
break;
}
let key = "";
for (; i + 1 < input.length; i++) {
const nextChar = input[i + 1];
if (nextChar.match(/^[0-9a-z_]+$/i))
key += nextChar;
else
break;
}
if (key === "")
throw new IllegalArgumentError(`Missing variable name after '$'.`);
token += this.environment.getOrDefault(key, "");
break;
case ">":
if (isInSingleQuotes || isInDoubleQuotes) {
token += char;
break;
}
if (token !== "") {
tokens.push(token);
token = "";
}
token += EscapeCharacters.Escape + ">";
if (input[i + 1] === ">") {
token += EscapeCharacters.Escape + ">";
i++;
}
while (input[i + 1] === " ")
i++;
break;
case "*":
case "?":
if (isInSingleQuotes || isInDoubleQuotes)
token += char;
else
token += EscapeCharacters.Escape + char;
break;
default:
token += char;
break;
}
}
if (token !== "")
tokens.push(token);
if (isInSingleQuotes || isInDoubleQuotes)
throw new IllegalArgumentError("Unexpected end of input. Missing closing quotation mark.");
return tokens;
}
}
/**
* Globs file paths in tokens.
*/
export class Globber {
/**
* The file system describing the valid paths to glob.
*/
private readonly fileSystem: FileSystem;
/**
* The path to the current working directory to which globbing is relative.
*/
private readonly cwd: Path;
/**
* Constructs a new globber.
*
* @param fileSystem the file system describing the valid paths to glob
* @param cwd the path to the current working directory to which globbing is relative
*/
constructor(fileSystem: FileSystem, cwd: string) {
this.fileSystem = fileSystem;
this.cwd = new Path(cwd);
}
/**
* Returns globbed tokens.
*
* @param tokens the tokens to glob
*/
glob(tokens: string[]): string[] {
const cwdNode = this.fileSystem.get(this.cwd);
if (!(cwdNode instanceof Directory))
throw new IllegalStateError("cwd is not a directory.");
const paths: string[] = [];
cwdNode.visit("", (_, path) => paths.push(path.slice(1)));
paths.shift();
let newTokens: string[] = [];
tokens.forEach(token => {
if (token.indexOf(EscapeCharacters.Escape + "?") < 0 && token.indexOf(EscapeCharacters.Escape + "*") < 0) {
newTokens = newTokens.concat([token]);
return;
}
const pattern = token
.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&")
.replaceAll(new RegExp(`${EscapeCharacters.Escape}\\\\\\?`), ".")
.replaceAll(new RegExp(`${EscapeCharacters.Escape}\\\\\\*${EscapeCharacters.Escape}\\\\\\*`), ".*")
.replaceAll(new RegExp(`${EscapeCharacters.Escape}\\\\\\*`), "[^/]*");
const matches = paths.filter(path => path.match(new RegExp(`^${pattern}$`)));
if (matches.length === 0)
throw new IllegalArgumentError(`Glob pattern '${token}' has no matches.`);
newTokens = newTokens.concat(matches);
});
return newTokens;
}
}

View File

@ -2,7 +2,8 @@ import * as Cookies from "js-cookie";
import {Commands} from "./Commands";
import {Environment} from "./Environment";
import {Directory, File, FileSystem, Node, Path} from "./FileSystem";
import {asciiHeaderHtml, IllegalArgumentError, IllegalStateError, stripHtmlTags} from "./Shared";
import {InputParser} from "./InputParser";
import {asciiHeaderHtml, IllegalStateError, stripHtmlTags} from "./Shared";
import {EscapeCharacters, InputHistory} from "./Terminal";
import {UserList} from "./UserList";
@ -124,8 +125,13 @@ export class Shell {
this.inputHistory.addEntry(inputString.trim());
const parser = new InputParser(this.environment);
const input = parser.parse(stripHtmlTags(inputString));
const parser = InputParser.create(this.environment, this.fileSystem);
let input;
try {
input = parser.parse(stripHtmlTags(inputString));
} catch (error) {
return error.message;
}
if (input.redirectTarget[0] === "write") {
try {
const path = Path.interpret(this.environment.get("cwd"), input.redirectTarget[1]);
@ -351,224 +357,3 @@ export class InputArgs {
return this._args[index] !== undefined;
}
}
/**
* 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.
*
* @param input the string to parse
*/
parse(input: string): InputArgs {
const tokens = this.tokenize(input);
const command = tokens[0] ?? "";
const [options, args] = 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);
}
/**
* Tokenizes the input string.
*
* @param input the string to tokenize
*/
private tokenize(input: string): string[] {
const tokens: string[] = [];
let token = "";
let isInSingleQuotes = false;
let isInDoubleQuotes = false;
let isInCurlyBraces = 0;
for (let i = 0; i < input.length; i++) {
const char = input[i];
switch (char) {
case "\\":
if (i === input.length - 1)
throw new IllegalArgumentError(
"Unexpected end of input. '\\' was used but there was nothing to escape.");
const nextChar = input[i + 1];
if (isInSingleQuotes || isInDoubleQuotes)
token += "\\" + nextChar;
else if (nextChar === "n")
token += "\n";
else
token += nextChar;
i++;
break;
case "'":
if (isInDoubleQuotes)
token += char;
else
isInSingleQuotes = !isInSingleQuotes;
break;
case "\"":
if (isInSingleQuotes)
token += char;
else
isInDoubleQuotes = !isInDoubleQuotes;
break;
case "{":
if (isInSingleQuotes || isInDoubleQuotes)
token += char;
else
isInCurlyBraces++;
break;
case "}":
if (isInSingleQuotes || isInDoubleQuotes)
token += char;
else
isInCurlyBraces--;
if (isInCurlyBraces < 0)
throw new IllegalArgumentError("Unexpected closing '}' without corresponding '{'.");
break;
case " ":
if (isInSingleQuotes || isInDoubleQuotes) {
token += char;
} else if (token !== "") {
tokens.push(token);
token = "";
}
break;
case "$":
if (isInSingleQuotes || isInDoubleQuotes) {
token += char;
break;
}
let key = "";
for (; i + 1 < input.length; i++) {
const nextChar = input[i + 1];
if (nextChar.match(/^[0-9a-z_]+$/i))
key += nextChar;
else
break;
}
if (key === "")
throw new IllegalArgumentError(`Missing variable name after '$'.`);
token += this.environment.getOrDefault(key, "");
break;
case ">":
if (isInSingleQuotes || isInDoubleQuotes) {
token += char;
break;
}
if (token !== "") {
tokens.push(token);
token = "";
}
token += EscapeCharacters.Escape + ">";
if (input[i + 1] === ">") {
token += `>`;
i++;
}
while (input[i + 1] === " ")
i++;
break;
default:
token += char;
break;
}
}
if (token !== "")
tokens.push(token);
if (isInSingleQuotes || isInDoubleQuotes)
throw new IllegalArgumentError("Unexpected end of input. Missing closing quotation mark.");
return tokens;
}
/**
* Returns the redirect target described by the last token that describes a redirect target, or the default redirect
* target if no token describes a redirect target.
*
* @param tokens an array of tokens of which some tokens may describe a redirect target
*/
private getRedirectTarget(tokens: string[]): InputArgs.RedirectTarget {
let redirectTarget: InputArgs.RedirectTarget = ["default"];
tokens.forEach(token => {
if (token.startsWith(`${EscapeCharacters.Escape}>>`))
redirectTarget = ["append", token.slice(3)];
else if (token.startsWith(`${EscapeCharacters.Escape}>`))
redirectTarget = ["write", token.slice(2)];
});
return redirectTarget;
}
/**
* Parses options and arguments.
*
* @param tokens the tokens that form the options and arguments
*/
private parseOpts(tokens: string[]): [InputArgs.Options, string[]] {
const options: { [key: string]: string | null } = {};
let i;
for (i = 0; i < tokens.length; i++) {
const arg = tokens[i];
if (!arg.startsWith("-") || arg === "--")
break;
const argsParts = arg.split(/=(.*)/, 2);
if (argsParts.length === 0 || argsParts.length > 2)
throw new IllegalArgumentError("Unexpected number of parts.");
if (argsParts[0].indexOf(' ') >= 0)
break;
const value = argsParts.length === 1 ? null : argsParts[1];
if (argsParts[0].startsWith("--")) {
const key = argsParts[0].substr(2);
if (key === "")
break;
options[key] = value;
} else {
const keys = argsParts[0].substr(1);
if (keys === "")
break;
if (keys.length === 1) {
options[keys] = value;
} else {
if (value !== null)
throw new IllegalArgumentError("Cannot assign value to multiple short options.");
for (const key of keys)
options[key] = value;
}
}
}
return [options, tokens.slice(i)];
}
}

View File

@ -219,7 +219,7 @@ export class Terminal {
this.isInputHidden = false;
break;
default:
buffer += output.charAt(i) + output.charAt(i + 1);
buffer += output.charAt(i + 1);
break;
}
i++;

View File

@ -10,8 +10,8 @@ describe("environment", () => {
beforeEach(() => {
environment = new Environment();
roEnvironment = new Environment(["readonly"]);
environment = new Environment([], {cwd: "/"});
roEnvironment = new Environment(["readonly"], {cwd: "/"});
});

View File

@ -3,108 +3,35 @@ import {expect} from "chai";
import "../main/js/Extensions"
import {Environment} from "../main/js/Environment";
import {InputParser} from "../main/js/Shell";
import {Globber, InputParser, Tokenizer} from "../main/js/InputParser";
import {Directory, File, FileSystem} from "../main/js/FileSystem";
import {EscapeCharacters} from "../main/js/Terminal";
describe("input args", () => {
describe("input parser", () => {
let parser: InputParser;
beforeEach(() => {
parser = new InputParser(new Environment());
const dummyGlobber = new class extends Globber {
constructor() {
super(new FileSystem(), "");
}
glob(tokens: string[]): string[] {
return tokens;
}
};
parser = new InputParser(new Tokenizer(new Environment()), dummyGlobber);
});
describe("tokenization", () => {
it("concatenates multiple strings into one token", () => {
expect(parser.parse(`'co'm"m nd"`).command).to.equal("comm nd");
});
it("includes escaped spaces into the token", () => {
expect(parser.parse("com\\ mand").command).to.equal("com mand");
});
it("includes escaped quotation marks into the token", () => {
expect(parser.parse(`com\\'man\\"d`).command).to.equal(`com'man"d`);
});
it("does not escape ordinary characters inside strings", () => {
expect(parser.parse(`\\p`).command).to.equal("p");
expect(parser.parse(`'\\p'`).command).to.equal("\\p");
expect(parser.parse(`"\\p"`).command).to.equal("\\p");
});
describe("grouping", () => {
describe("quotes",() => {
it("groups using single quotes", () => {
expect(parser.parse("a'b'a").command).to.equal("aba");
});
it("groups using double quotes", () => {
expect(parser.parse(`a"b"a`).command).to.equal("aba");
});
it("throws an error if single quotes are not closed", () => {
expect(() => parser.parse("a'ba")).to.throw;
});
it("throws an error if double quotes are not closed", () => {
expect(() => parser.parse(`a"ba`)).to.throw;
});
it("does not group double quotes within single quotes", () => {
expect(parser.parse(`a'b"b'a`).command).to.equal(`ab"ba`);
});
it("does not group single quotes within double quotes", () => {
expect(parser.parse(`a"b'b"a`).command).to.equal("ab'ba");
});
});
describe("curly braces",() => {
it("groups using curly braces", () => {
expect(parser.parse("a{b}a").command).to.equal("aba");
});
it("groups using nested curly braces", ()=> {
expect(parser.parse("a{{b}{b}}a").command).to.equal("abba");
});
it("throws an error if curly braces are not closed", () => {
expect(() => parser.parse("a{ba")).to.throw;
});
it("throws an error if curly braces are not opened", () => {
expect(() => parser.parse("a}ba")).to.throw;
});
it("throws an error if nested curly braces are not closed", () => {
expect(() => parser.parse("a{{b}a")).to.throw;
});
it("does not group curly braces within single quotes", () => {
expect(parser.parse(`a'b{b'a`).command).to.equal("ab{ba");
});
it("does not group curly braces within double quotes", () => {
expect(parser.parse(`a"b{b"a`).command).to.equal("ab{ba");
});
});
});
});
describe("command", () => {
it("returns the first token as the command", () => {
expect(parser.parse("command arg1 arg2").command).to.equal("command");
});
it("returns the first token as the command even if there are unnecessary spaces", () => {
expect(parser.parse(" command arg1 arg2").command).to.equal("command");
});
it("returns the first token as the command even if it contains special symbols", () => {
expect(parser.parse("4com-mand3 arg1 arg2").command).to.equal("4com-mand3");
});
});
describe("options", () => {
@ -276,45 +203,252 @@ describe("input args", () => {
expect(inputArgs.args).to.have.members([">", "file"]);
});
});
});
describe("tokenizer", () => {
const escape = EscapeCharacters.Escape;
let tokenizer: Tokenizer;
beforeEach(() => {
tokenizer = new Tokenizer(new Environment());
});
describe("tokens", () => {
describe("whitespace", () => {
it("ignores unnecessary leading whitespace", () => {
expect(tokenizer.tokenize(" token1 token2")).to.have.members(["token1", "token2"]);
});
it("ignores unnecessary trailing whitespace", () => {
expect(tokenizer.tokenize("token1 token2 ")).to.have.members(["token1", "token2"]);
});
it("ignores unnecessary whitespace in between tokens", () => {
expect(tokenizer.tokenize("token1 token2")).to.have.members(["token1", "token2"]);
});
});
describe("escape characters", () => {
it("includes escaped spaces into the token", () => {
expect(tokenizer.tokenize("com\\ mand")).to.have.members(["com mand"]);
});
it("includes escaped quotation marks in the token", () => {
expect(tokenizer.tokenize(`com\\'man\\"d`)).to.have.members([`com'man"d`]);
});
it("does not escape ordinary characters inside strings", () => {
expect(tokenizer.tokenize(`\\p`)).to.have.members(["p"]);
expect(tokenizer.tokenize(`'\\p'`)).to.have.members(["\\p"]);
expect(tokenizer.tokenize(`"\\p"`)).to.have.members(["\\p"]);
});
it("includes escaped spaces at the very end", () => {
expect(tokenizer.tokenize("a b\\ ")).to.have.members(["a", "b "]);
});
it("throws an error if an escape occurs but no character follows", () => {
expect(() => tokenizer.tokenize("\\")).to.throw;
});
});
describe("grouping", () => {
describe("quotes", () => {
it("groups using single quotes", () => {
expect(tokenizer.tokenize("a'b'a")).to.have.members(["aba"]);
});
it("groups using double quotes", () => {
expect(tokenizer.tokenize(`a"b"a`)).to.have.members(["aba"]);
});
it("throws an error if single quotes are not closed", () => {
expect(() => tokenizer.tokenize("a'ba")).to.throw;
});
it("throws an error if double quotes are not closed", () => {
expect(() => tokenizer.tokenize(`a"ba`)).to.throw;
});
it("does not group double quotes within single quotes", () => {
expect(tokenizer.tokenize(`a'b"b'a`)).to.have.members([`ab"ba`]);
});
it("does not group single quotes within double quotes", () => {
expect(tokenizer.tokenize(`a"b'b"a`)).to.have.members(["ab'ba"]);
});
});
describe("curly braces", () => {
it("groups using curly braces", () => {
expect(tokenizer.tokenize("a{b}a")).to.have.members(["aba"]);
});
it("groups using nested curly braces", () => {
expect(tokenizer.tokenize("a{{b}{b}}a")).to.have.members(["abba"]);
});
it("throws an error if curly braces are not closed", () => {
expect(() => tokenizer.tokenize("a{ba")).to.throw;
});
it("throws an error if curly braces are not opened", () => {
expect(() => tokenizer.tokenize("a}ba")).to.throw;
});
it("throws an error if nested curly braces are not closed", () => {
expect(() => tokenizer.tokenize("a{{b}a")).to.throw;
});
it("does not group curly braces within single quotes", () => {
expect(tokenizer.tokenize(`a'b{b'a`)).to.have.members(["ab{ba"]);
});
it("does not group curly braces within double quotes", () => {
expect(tokenizer.tokenize(`a"b{b"a`)).to.have.members(["ab{ba"]);
});
});
});
});
describe("environment", () => {
beforeEach(() => {
parser = new InputParser(new Environment([], {a: "b", aa: "c", r: ">"}));
tokenizer = new Tokenizer(new Environment([], {a: "b", aa: "c", r: ">", cwd: "/"}));
});
it("substitutes a known environment variable with its value", () => {
expect(parser.parse("$a").command).to.equal("b");
expect(tokenizer.tokenize("$a")).to.have.members(["b"]);
});
it("substitutes an unknown environment variable with nothing", () => {
expect(parser.parse("$b").command).to.equal("");
expect(tokenizer.tokenize("a$b")).to.have.members(["a"]);
});
it("substitutes consecutive known environment variables with their value", () => {
expect(parser.parse("$a$aa$a").command).to.equal("bcb");
expect(tokenizer.tokenize("$a$aa$a")).to.have.members(["bcb"]);
});
it("throws an error for nameless environment variables", () => {
expect(() => parser.parse("$").command).to.throw;
expect(() => tokenizer.tokenize("$")).to.throw;
});
it("does not substitute environment variables in the middle of a single-quoted string", () => {
expect(parser.parse("a'$a'c").command).to.equal("a$ac");
expect(tokenizer.tokenize("a'$a'c")).to.have.members(["a$ac"]);
});
it("does not substitute environment variables in the middle of a double-quoted string", () => {
expect(parser.parse(`a"$a"c`).command).to.equal("a$ac");
expect(tokenizer.tokenize(`a"$a"c`)).to.have.members(["a$ac"]);
});
it("substitutes environment variables in the middle of curly braces", () => {
expect(parser.parse("a{$a}c").command).to.equal("abc");
expect(tokenizer.tokenize("a{$a}c")).to.have.members(["abc"]);
});
});
describe("escapes", () => {
it("escapes output target characters", () => {
expect(tokenizer.tokenize("a >b")).to.have.members(["a", `${escape}>b`]);
expect(tokenizer.tokenize("a >>b")).to.have.members(["a", `${escape}>${escape}>b`]);
});
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"]);
it("does not escape escaped target characters", () => {
expect(tokenizer.tokenize("a \\>b")).to.have.members(["a", ">b"]);
expect(tokenizer.tokenize("a \\>>b")).to.have.members(["a", ">", `${escape}>b`]);
});
it("escapes glob characters", () => {
expect(tokenizer.tokenize("a b?")).to.have.members(["a", `b${escape}?`]);
expect(tokenizer.tokenize("a b*")).to.have.members(["a", `b${escape}*`]);
expect(tokenizer.tokenize("a b**")).to.have.members(["a", `b${escape}*${escape}*`]);
});
it("does not escape escaped glob characters", () => {
expect(tokenizer.tokenize("a b\\?")).to.have.members(["a", `b?`]);
expect(tokenizer.tokenize("a b\\*")).to.have.members(["a", `b*`]);
});
});
});
describe("globber", () => {
const escape = EscapeCharacters.Escape;
let globber: Globber;
beforeEach(() => {
globber = new Globber(
new FileSystem(new Directory({
"aa": new Directory({
"ab1": new File()
}),
"ab1": new File(),
"ab2": new File(),
"aa3": new File(),
"b?": new File(),
".a": new File()
})),
"/"
);
});
describe("?", () => {
it("throws an error if no matches are found", () => {
expect(() => globber.glob([`x${escape}?`])).to.throw;
});
it("globs a single ?", () => {
expect(globber.glob([`ab${escape}?`])).to.have.members(["ab1", "ab2"]);
});
it("globs multiple ?s", () => {
expect(globber.glob([`a${escape}?${escape}?`])).to.have.members(["ab1", "ab2", "aa3"]);
});
it("does not process unescaped ?s", () => {
expect(globber.glob(["a?"])).to.have.members(["a?"]);
});
});
describe("*", () => {
it("throws an error if no matches are found", () => {
expect(() => globber.glob([`x${escape}*`])).to.throw;
});
it("globs a single *", () => {
expect(globber.glob([`a${escape}*`])).to.have.members(["aa", "ab1", "ab2", "aa3"]);
});
it("globs multiple *s", () => {
expect(globber.glob([`a${escape}*/${escape}*`])).to.have.members(["aa/ab1"]);
});
it("does not process unescaped *s", () => {
expect(globber.glob(["a*"])).to.have.members(["a*"]);
});
});
describe("**", () => {
it("throws an error if no matches are found", () => {
expect(() => globber.glob([`x${escape}**`])).to.throw;
});
it("globs **", () => {
expect(globber.glob([`${escape}*${escape}*b1`])).to.have.members(["ab1", "aa/ab1"]);
});
it("does not match the directory itself", () => {
expect(globber.glob([`${escape}*${escape}*`]).map(it => it.trim())).to.not.contain("");
});
it("does not process unescaped **s", () => {
expect(globber.glob(["a**"])).to.have.members(["a**"]);
});
});
it("does not use an embedded `.*` in regex matching", () => {
expect(globber.glob([`.${escape}*`])).to.have.members([".a"]);
})
});