diff --git a/config/commands.js b/config/commands.js
index 0ad60b7b..1b96379c 100644
--- a/config/commands.js
+++ b/config/commands.js
@@ -385,7 +385,8 @@ const commands = {
if (_filesHere().includes(filename)) {
let file = getFileContents(filename);
- const matches = file.matchAll(q);
+ const literalQuery = q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ const matches = file.matchAll(literalQuery);
for (const match of matches) {
file = file.replaceAll(match[0], colorText(match[0], "files"));
}
diff --git a/tests/commands.test.js b/tests/commands.test.js
index bd1cf2bf..701a2550 100644
--- a/tests/commands.test.js
+++ b/tests/commands.test.js
@@ -114,7 +114,13 @@ describe("apply", () => {
// Loads the full command set with a fake terminal. `cd` is the one command with
// real branching logic — a switch over ~, .., /home, /bin and team member names
// — and it drives term.cwd, which the prompt renders on every keystroke.
-function loadCommands({ cwd = "~", user = "guest", team = { avidan: {} } } = {}) {
+function loadCommands({
+ cwd = "~",
+ user = "guest",
+ team = { avidan: {} },
+ files = {},
+ colorText = (text) => text,
+} = {}) {
const term = {
cwd,
user,
@@ -132,7 +138,11 @@ function loadCommands({ cwd = "~", user = "guest", team = { avidan: {} } } = {})
team,
help: {},
portfolio: {},
- colorText: (text) => text,
+ colorText,
+ _DIRS: { [cwd]: Object.keys(files) },
+ _FILES: Object.fromEntries(Object.keys(files).map((name) => [name, name])),
+ _filesHere: () => Object.keys(files),
+ getFileContents: (filename) => files[filename],
window: {},
});
vm.runInContext(commandSource, context);
@@ -145,6 +155,79 @@ function loadCommands({ cwd = "~", user = "guest", team = { avidan: {} } } = {})
return { commands, term };
}
+describe("grep", () => {
+ it("matches and highlights a metacharacter literally", () => {
+ const { commands, term } = loadCommands({
+ files: { "sample.txt": "before ( after (" },
+ colorText: (text, style) => `<${style}>${text}${style}>`,
+ });
+
+ expect(() => commands.grep(["(", "sample.txt"])).not.toThrow();
+ expect(term.writeln).toHaveBeenCalledWith(
+ "before ( after ("
+ );
+ });
+
+ it("does not throw for regex metacharacters", () => {
+ const { commands } = loadCommands({
+ files: { "sample.txt": "()[]$^*+?|\\" },
+ });
+
+ for (const pattern of ["(", ")", "[", "]", "$", "^", "*", "+", "?", "|", "\\"]) {
+ expect(() => commands.grep([pattern, "sample.txt"])).not.toThrow();
+ }
+ });
+
+ it("matches metacharacters literally and case-sensitively", () => {
+ const { commands, term } = loadCommands({
+ files: { "sample.txt": "A+B a+b" },
+ colorText: (text, style) => `<${style}>${text}${style}>`,
+ });
+
+ commands.grep(["+", "sample.txt"]);
+
+ expect(term.writeln).toHaveBeenCalledWith(
+ "A+B a+b"
+ );
+ });
+
+ it("highlights only literal dots", () => {
+ const { commands, term } = loadCommands({
+ files: { "sample.txt": "one.two\n..." },
+ colorText: (text, style) => `<${style}>${text}${style}>`,
+ });
+
+ commands.grep([".", "sample.txt"]);
+
+ expect(term.writeln).toHaveBeenCalledWith(
+ "one.two\n..."
+ );
+ });
+
+ it("preserves plain-pattern highlighting and missing-argument usage", () => {
+ const matching = loadCommands({
+ files: { "sample.txt": "foo bar foo" },
+ colorText: (text, style) => `<${style}>${text}${style}>`,
+ });
+ matching.commands.grep(["foo", "sample.txt"]);
+ expect(matching.term.writeln).toHaveBeenCalledWith(
+ "foo bar foo"
+ );
+
+ const missing = loadCommands();
+ missing.commands.grep([]);
+ expect(missing.term.stylePrint).toHaveBeenCalledWith(
+ "usage: %grep% [pattern] [filename]"
+ );
+
+ const notFound = loadCommands();
+ notFound.commands.grep(["foo", "missing.txt"]);
+ expect(notFound.term.stylePrint).toHaveBeenCalledWith(
+ "No such file or directory: missing.txt"
+ );
+ });
+});
+
describe("cd", () => {
// Table ported from #51 (@astonm, 2021), which never landed. The cases still
// describe the intended behaviour; only the harness has changed.