parent
fea0235d4a
commit
a02130585e
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "fwdekker.com",
|
"name": "fwdekker.com",
|
||||||
"version": "1.9.2",
|
"version": "1.9.3",
|
||||||
"description": "The source code of [my personal website](https://fwdekker.com/).",
|
"description": "The source code of [my personal website](https://fwdekker.com/).",
|
||||||
"author": "Felix W. Dekker",
|
"author": "Felix W. Dekker",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|
|
@ -2,7 +2,6 @@ import {Environment} from "./Environment";
|
||||||
import {Directory, File, FileSystem, Path} from "./FileSystem";
|
import {Directory, File, FileSystem, Path} from "./FileSystem";
|
||||||
import {IllegalArgumentError} from "./Shared";
|
import {IllegalArgumentError} from "./Shared";
|
||||||
import {InputArgs} from "./Shell";
|
import {InputArgs} from "./Shell";
|
||||||
import {EscapeCharacters} from "./Terminal";
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -49,9 +48,13 @@ export class InputParser {
|
||||||
* @param input the string to parse
|
* @param input the string to parse
|
||||||
*/
|
*/
|
||||||
parse(input: string): InputArgs {
|
parse(input: string): InputArgs {
|
||||||
const tokens = this.tokenizer.tokenize(input);
|
const tokens = this.tokenizer.tokenize(escape(input));
|
||||||
const textTokens = this.globber.glob(tokens.filter(it => it instanceof InputParser.TextToken));
|
|
||||||
const redirectTokens = tokens.filter(it => it instanceof InputParser.RedirectToken);
|
const textTokens = this.globber.glob(tokens.filter(it => it instanceof InputParser.TextToken))
|
||||||
|
.map(it => new InputParser.TextToken(unescape(it.contents)));
|
||||||
|
const redirectTokens = tokens
|
||||||
|
.filter(it => it instanceof InputParser.RedirectToken)
|
||||||
|
.map(it => new InputParser.RedirectToken(unescape(it.contents)));
|
||||||
|
|
||||||
const command = tokens[0]?.contents ?? "";
|
const command = tokens[0]?.contents ?? "";
|
||||||
const [options, args] = this.parseOpts(textTokens.slice(1));
|
const [options, args] = this.parseOpts(textTokens.slice(1));
|
||||||
|
@ -256,10 +259,13 @@ export class Tokenizer {
|
||||||
break;
|
break;
|
||||||
case "*":
|
case "*":
|
||||||
case "?":
|
case "?":
|
||||||
|
if (token instanceof InputParser.RedirectToken)
|
||||||
|
throw new IllegalArgumentError(`Invalid token '${char}' in redirect target.`);
|
||||||
|
|
||||||
if (isInSingleQuotes || isInDoubleQuotes)
|
if (isInSingleQuotes || isInDoubleQuotes)
|
||||||
token.contents += char;
|
token.contents += char;
|
||||||
else
|
else
|
||||||
token.contents += EscapeCharacters.Escape + char;
|
token.contents += InputParser.EscapeChar + char;
|
||||||
break;
|
break;
|
||||||
case "~":
|
case "~":
|
||||||
if (isInSingleQuotes || isInDoubleQuotes || isInCurlyBraces || token.contents !== "")
|
if (isInSingleQuotes || isInDoubleQuotes || isInCurlyBraces || token.contents !== "")
|
||||||
|
@ -319,12 +325,20 @@ export class Globber {
|
||||||
return tokens
|
return tokens
|
||||||
.map(it => it.contents)
|
.map(it => it.contents)
|
||||||
.map(token => {
|
.map(token => {
|
||||||
if (token.startsWith("/"))
|
if (!this.isGlob(token))
|
||||||
return this.glob2("/", token.slice(1), new Path("/"));
|
return [token];
|
||||||
else
|
|
||||||
return this.glob2("", token, this.cwd);
|
let tokens: string[];
|
||||||
}
|
if (token.startsWith("/"))
|
||||||
)
|
tokens = this.glob2("/", token.slice(1), new Path("/"));
|
||||||
|
else
|
||||||
|
tokens = this.glob2("", token, this.cwd);
|
||||||
|
|
||||||
|
if (tokens.length === 0)
|
||||||
|
throw new IllegalArgumentError(`Token '${unescape(token)}' does not match any files.`);
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
})
|
||||||
.reduce((acc, tokens) => acc.concat(tokens), [])
|
.reduce((acc, tokens) => acc.concat(tokens), [])
|
||||||
.map(it => new InputParser.TextToken(it));
|
.map(it => new InputParser.TextToken(it));
|
||||||
}
|
}
|
||||||
|
@ -338,9 +352,6 @@ export class Globber {
|
||||||
* @param path the current location in the file system
|
* @param path the current location in the file system
|
||||||
*/
|
*/
|
||||||
private glob2(history: string, glob: string, path: Path): string[] {
|
private glob2(history: string, glob: string, path: Path): string[] {
|
||||||
if (!glob.includes(EscapeCharacters.Escape + "?") && !glob.includes(EscapeCharacters.Escape + "*"))
|
|
||||||
return [history + glob];
|
|
||||||
|
|
||||||
const dir = this.fileSystem.get(path);
|
const dir = this.fileSystem.get(path);
|
||||||
if (!(dir instanceof Directory))
|
if (!(dir instanceof Directory))
|
||||||
return [history + glob];
|
return [history + glob];
|
||||||
|
@ -353,13 +364,9 @@ export class Globber {
|
||||||
if (nextPart === "..")
|
if (nextPart === "..")
|
||||||
return this.glob2(history + nextPart + "/", remainder, path.parent);
|
return this.glob2(history + nextPart + "/", remainder, path.parent);
|
||||||
|
|
||||||
const pattern = nextPart
|
|
||||||
.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&") // Escape regex from user input
|
|
||||||
.replaceAll(new RegExp(`${EscapeCharacters.Escape}\\\\\\?`), ".")
|
|
||||||
.replaceAll(new RegExp(`${EscapeCharacters.Escape}\\\\\\*`), "[^/]*");
|
|
||||||
|
|
||||||
return Object.keys(dir.nodes)
|
return Object.keys(dir.nodes)
|
||||||
.filter(fileName => fileName.match(new RegExp(`^${pattern}$`)))
|
.filter(it => it.match(this.glob2regex(nextPart)))
|
||||||
|
.map(it => escape(it))
|
||||||
.map(fileName => {
|
.map(fileName => {
|
||||||
if (dir.nodes[fileName] instanceof File) {
|
if (dir.nodes[fileName] instanceof File) {
|
||||||
// Only match files if there are no more /s to match
|
// Only match files if there are no more /s to match
|
||||||
|
@ -380,6 +387,62 @@ export class Globber {
|
||||||
})
|
})
|
||||||
.reduce((acc, it) => acc.concat(it), []);
|
.reduce((acc, it) => acc.concat(it), []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns `true` if and only if the given glob string uses any special glob characters.
|
||||||
|
*
|
||||||
|
* @param glob the string to check for globness
|
||||||
|
*/
|
||||||
|
private isGlob(glob: string): boolean {
|
||||||
|
for (let i = 0; i < glob.length; i++) {
|
||||||
|
const char = glob[i];
|
||||||
|
|
||||||
|
if (char !== InputParser.EscapeChar)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
i++;
|
||||||
|
const nextChar = glob[i];
|
||||||
|
if (nextChar === "?" || nextChar === "*")
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a glob string to a regular expression.
|
||||||
|
*
|
||||||
|
* @param glob the glob string to convert
|
||||||
|
*/
|
||||||
|
private glob2regex(glob: string): RegExp {
|
||||||
|
let regex = "";
|
||||||
|
|
||||||
|
for (let i = 0; i < glob.length; i++) {
|
||||||
|
const char = glob[i];
|
||||||
|
if (char !== InputParser.EscapeChar) {
|
||||||
|
if ("-\/\\^$*+?.()|[\]{}".includes(char))
|
||||||
|
regex += "\\" + char;
|
||||||
|
else
|
||||||
|
regex += char;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
i++;
|
||||||
|
const nextChar = glob[i];
|
||||||
|
if (nextChar === undefined)
|
||||||
|
throw new IllegalArgumentError("Unescaped escape character inside input parser.");
|
||||||
|
|
||||||
|
if (nextChar === "?")
|
||||||
|
regex += ".";
|
||||||
|
else if (nextChar === "*")
|
||||||
|
regex += "[^/]*";
|
||||||
|
else
|
||||||
|
regex += nextChar;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RegExp(`^${regex}$`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -420,3 +483,23 @@ export module InputParser {
|
||||||
readonly type: string = "redirect";
|
readonly type: string = "redirect";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escapes all occurrences of the input parser's escape character.
|
||||||
|
*
|
||||||
|
* @param string the string to escape in
|
||||||
|
*/
|
||||||
|
function escape(string: string): string {
|
||||||
|
return string.replace(new RegExp(InputParser.EscapeChar, "g"), InputParser.EscapeChar + InputParser.EscapeChar);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unescapes all occurrences of the input parser's escape character.
|
||||||
|
*
|
||||||
|
* @param string the string to unescape in
|
||||||
|
*/
|
||||||
|
function unescape(string: string): string {
|
||||||
|
return string.replace(new RegExp(InputParser.EscapeChar + InputParser.EscapeChar, "g"), InputParser.EscapeChar);
|
||||||
|
}
|
||||||
|
|
|
@ -4,11 +4,20 @@ import {expect} from "chai";
|
||||||
import {Environment} from "../main/js/Environment";
|
import {Environment} from "../main/js/Environment";
|
||||||
import {Globber, InputParser, Tokenizer} from "../main/js/InputParser";
|
import {Globber, InputParser, Tokenizer} from "../main/js/InputParser";
|
||||||
import {Directory, File, FileSystem, Node, Path} from "../main/js/FileSystem";
|
import {Directory, File, FileSystem, Node, Path} from "../main/js/FileSystem";
|
||||||
import {EscapeCharacters} from "../main/js/Terminal";
|
|
||||||
import TextToken = InputParser.TextToken;
|
import TextToken = InputParser.TextToken;
|
||||||
import RedirectToken = InputParser.RedirectToken;
|
import RedirectToken = InputParser.RedirectToken;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shorthand for the escape character used internally in the input parser.
|
||||||
|
*/
|
||||||
|
const escape = InputParser.EscapeChar;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts the given strings to text tokens.
|
||||||
|
*
|
||||||
|
* @param strings the strings to convert to text token
|
||||||
|
*/
|
||||||
function tokens(...strings: string[]): InputParser.TextToken[] {
|
function tokens(...strings: string[]): InputParser.TextToken[] {
|
||||||
return strings.map(it => new InputParser.TextToken(it));
|
return strings.map(it => new InputParser.TextToken(it));
|
||||||
}
|
}
|
||||||
|
@ -71,7 +80,7 @@ describe("input parser", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not assign a value to grouped short options", () => {
|
it("does not assign a value to grouped short options", () => {
|
||||||
expect(() => parser.parse("command -opq=arg -r")).to.throw;
|
expect(() => parser.parse("command -opq=arg -r")).to.throw();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stops parsing options if a short option name contains a space", () => {
|
it("stops parsing options if a short option name contains a space", () => {
|
||||||
|
@ -252,7 +261,6 @@ describe("input parser", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("tokenizer", () => {
|
describe("tokenizer", () => {
|
||||||
const escape = EscapeCharacters.Escape;
|
|
||||||
let tokenizer: Tokenizer;
|
let tokenizer: Tokenizer;
|
||||||
|
|
||||||
|
|
||||||
|
@ -276,7 +284,7 @@ describe("tokenizer", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("escape characters", () => {
|
describe("input escape characters", () => {
|
||||||
it("includes escaped spaces into the token", () => {
|
it("includes escaped spaces into the token", () => {
|
||||||
expect(tokenizer.tokenize("com\\ mand")).to.have.deep.members(tokens("com mand"));
|
expect(tokenizer.tokenize("com\\ mand")).to.have.deep.members(tokens("com mand"));
|
||||||
});
|
});
|
||||||
|
@ -296,7 +304,7 @@ describe("tokenizer", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws an error if an escape occurs but no character follows", () => {
|
it("throws an error if an escape occurs but no character follows", () => {
|
||||||
expect(() => tokenizer.tokenize("\\")).to.throw;
|
expect(() => tokenizer.tokenize("\\")).to.throw();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -311,11 +319,11 @@ describe("tokenizer", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws an error if single quotes are not closed", () => {
|
it("throws an error if single quotes are not closed", () => {
|
||||||
expect(() => tokenizer.tokenize("a'ba")).to.throw;
|
expect(() => tokenizer.tokenize("a'ba")).to.throw();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws an error if double quotes are not closed", () => {
|
it("throws an error if double quotes are not closed", () => {
|
||||||
expect(() => tokenizer.tokenize(`a"ba`)).to.throw;
|
expect(() => tokenizer.tokenize(`a"ba`)).to.throw();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not group double quotes within single quotes", () => {
|
it("does not group double quotes within single quotes", () => {
|
||||||
|
@ -337,15 +345,15 @@ describe("tokenizer", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws an error if curly braces are not closed", () => {
|
it("throws an error if curly braces are not closed", () => {
|
||||||
expect(() => tokenizer.tokenize("a{ba")).to.throw;
|
expect(() => tokenizer.tokenize("a{ba")).to.throw();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws an error if curly braces are not opened", () => {
|
it("throws an error if curly braces are not opened", () => {
|
||||||
expect(() => tokenizer.tokenize("a}ba")).to.throw;
|
expect(() => tokenizer.tokenize("a}ba")).to.throw();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws an error if nested curly braces are not closed", () => {
|
it("throws an error if nested curly braces are not closed", () => {
|
||||||
expect(() => tokenizer.tokenize("a{{b}a")).to.throw;
|
expect(() => tokenizer.tokenize("a{{b}a")).to.throw();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not group curly braces within single quotes", () => {
|
it("does not group curly braces within single quotes", () => {
|
||||||
|
@ -378,7 +386,7 @@ describe("tokenizer", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws an error for nameless environment variables", () => {
|
it("throws an error for nameless environment variables", () => {
|
||||||
expect(() => tokenizer.tokenize("$")).to.throw;
|
expect(() => tokenizer.tokenize("$")).to.throw();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not substitute environment variables in the middle of a single-quoted string", () => {
|
it("does not substitute environment variables in the middle of a single-quoted string", () => {
|
||||||
|
@ -425,35 +433,45 @@ describe("tokenizer", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("escapes", () => {
|
describe("internal escape characters", () => {
|
||||||
it("escapes output target characters", () => {
|
it("puts redirect targets in redirect tokens", () => {
|
||||||
expect(tokenizer.tokenize("a >b")).to.have.deep.members([new TextToken("a"), new RedirectToken(">b")]);
|
expect(tokenizer.tokenize("a >b")).to.have.deep.members([new TextToken("a"), new RedirectToken(">b")]);
|
||||||
expect(tokenizer.tokenize("a >>b")).to.have.deep.members([new TextToken("a"), new RedirectToken(">>b")]);
|
expect(tokenizer.tokenize("a >>b")).to.have.deep.members([new TextToken("a"), new RedirectToken(">>b")]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not escape escaped target characters", () => {
|
it("does not put escaped redirect targets in redirect tokens", () => {
|
||||||
expect(tokenizer.tokenize("a \\>b"))
|
expect(tokenizer.tokenize("a \\>b"))
|
||||||
.to.have.deep.members([new TextToken("a"), new TextToken(">b")]);
|
.to.have.deep.members([new TextToken("a"), new TextToken(">b")]);
|
||||||
expect(tokenizer.tokenize("a \\>>b"))
|
expect(tokenizer.tokenize("a \\>>b"))
|
||||||
.to.have.deep.members([new TextToken("a"), new TextToken(">"), new RedirectToken(">b")]);
|
.to.have.deep.members([new TextToken("a"), new TextToken(">"), new RedirectToken(">b")]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("throws an error if a glob character is used in the redirect target", () => {
|
||||||
|
expect(() => tokenizer.tokenize("a >a?")).to.throw();
|
||||||
|
expect(() => tokenizer.tokenize("a >a*")).to.throw();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains the escape character in a redirect target", () => {
|
||||||
|
expect(tokenizer.tokenize(`>${escape}`)[0]).to.deep.equal(new RedirectToken(`>${escape}`));
|
||||||
|
});
|
||||||
|
|
||||||
it("escapes glob characters", () => {
|
it("escapes glob characters", () => {
|
||||||
expect(tokenizer.tokenize("a b?")).to.have.deep.members(tokens("a", `b${escape}?`));
|
expect(tokenizer.tokenize("a b?")).to.have.deep.members(tokens("a", `b${escape}?`));
|
||||||
expect(tokenizer.tokenize("a b*")).to.have.deep.members(tokens("a", `b${escape}*`));
|
expect(tokenizer.tokenize("a b*")).to.have.deep.members(tokens("a", `b${escape}*`));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not escape escaped glob characters", () => {
|
it("does not escape user-escaped glob characters", () => {
|
||||||
expect(tokenizer.tokenize("a b\\?")).to.have.deep.members(tokens("a", "b?"));
|
expect(tokenizer.tokenize("a b\\?")).to.have.deep.members(tokens("a", "b?"));
|
||||||
expect(tokenizer.tokenize("a b\\*")).to.have.deep.members(tokens("a", "b*"));
|
expect(tokenizer.tokenize("a b\\*")).to.have.deep.members(tokens("a", "b*"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not escape internally-escaped glob characters", () => {
|
||||||
|
expect(tokenizer.tokenize(`a ${escape}\\?`)).to.have.deep.members(tokens("a", `${escape}?`));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("globber", () => {
|
describe("globber", () => {
|
||||||
const escape = EscapeCharacters.Escape;
|
|
||||||
|
|
||||||
|
|
||||||
const createGlobber = function(nodes: { [path: string]: Node } = {}, cwd: string = "/"): Globber {
|
const createGlobber = function(nodes: { [path: string]: Node } = {}, cwd: string = "/"): Globber {
|
||||||
const fs = new FileSystem(new Directory());
|
const fs = new FileSystem(new Directory());
|
||||||
for (const path of Object.getOwnPropertyNames(nodes))
|
for (const path of Object.getOwnPropertyNames(nodes))
|
||||||
|
@ -464,6 +482,13 @@ describe("globber", () => {
|
||||||
|
|
||||||
|
|
||||||
describe("?", () => {
|
describe("?", () => {
|
||||||
|
it("does not remove internal escape characters from the output", () => {
|
||||||
|
const globber = createGlobber({[`/${escape}1`]: new File()});
|
||||||
|
|
||||||
|
expect(globber.glob(tokens(`${escape}${escape}${escape}?`)))
|
||||||
|
.to.have.deep.members(tokens(`${escape}${escape}1`));
|
||||||
|
});
|
||||||
|
|
||||||
it("does not expand unescaped ?s", () => {
|
it("does not expand unescaped ?s", () => {
|
||||||
const globber = createGlobber({"/ab": new File()});
|
const globber = createGlobber({"/ab": new File()});
|
||||||
|
|
||||||
|
@ -544,6 +569,13 @@ describe("globber", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("*", () => {
|
describe("*", () => {
|
||||||
|
it("does not remove internal escape characters from the output", () => {
|
||||||
|
const globber = createGlobber({[`/${escape}1`]: new File()});
|
||||||
|
|
||||||
|
expect(globber.glob(tokens(`${escape}${escape}${escape}*`)))
|
||||||
|
.to.have.deep.members(tokens(`${escape}${escape}1`));
|
||||||
|
});
|
||||||
|
|
||||||
it("does not process unescaped *s", () => {
|
it("does not process unescaped *s", () => {
|
||||||
const globber = createGlobber({"/ab": new File()});
|
const globber = createGlobber({"/ab": new File()});
|
||||||
|
|
||||||
|
@ -637,13 +669,18 @@ describe("globber", () => {
|
||||||
|
|
||||||
describe("shared edge cases", () => {
|
describe("shared edge cases", () => {
|
||||||
it("throws an error if no matches are found", () => {
|
it("throws an error if no matches are found", () => {
|
||||||
expect(() => createGlobber().glob(tokens(`x${escape}?`))).to.throw;
|
expect(() => createGlobber().glob(tokens(`x${escape}?`))).to.throw();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns an empty token without change", () => {
|
it("returns an empty token without change", () => {
|
||||||
expect(createGlobber().glob(tokens(""))).to.have.deep.members(tokens(""));
|
expect(createGlobber().glob(tokens(""))).to.have.deep.members(tokens(""));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not remove escape characters from glob-less inputs", () => {
|
||||||
|
expect(createGlobber().glob(tokens(`${escape}${escape}`)))
|
||||||
|
.to.have.deep.members(tokens(`${escape}${escape}`));
|
||||||
|
});
|
||||||
|
|
||||||
it("returns a glob-less token without change", () => {
|
it("returns a glob-less token without change", () => {
|
||||||
expect(createGlobber().glob(tokens("abc"))).to.have.deep.members(tokens("abc"));
|
expect(createGlobber().glob(tokens("abc"))).to.have.deep.members(tokens("abc"));
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in New Issue