From 4936d14639bc86f6d55fb060e3ad0964b3c43074 Mon Sep 17 00:00:00 2001 From: muesli4brekkies <110121045+muesli4brekkies@users.noreply.github.com> Date: Fri, 14 Jun 2024 08:00:48 +0100 Subject: [PATCH] TERMINAL: Add grep command (#1381) --- package-lock.json | 79 +++--- src/Terminal/HelpText.ts | 43 ++++ src/Terminal/Terminal.ts | 2 + src/Terminal/commands/grep.ts | 449 ++++++++++++++++++++++++++++++++++ 4 files changed, 543 insertions(+), 30 deletions(-) create mode 100644 src/Terminal/commands/grep.ts diff --git a/package-lock.json b/package-lock.json index b1e4997f4..824184310 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5946,13 +5946,14 @@ "dev": true }, "node_modules/body-parser": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", - "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", "dev": true, + "license": "MIT", "dependencies": { "bytes": "3.1.2", - "content-type": "~1.0.4", + "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", @@ -5960,7 +5961,7 @@ "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.11.0", - "raw-body": "2.5.1", + "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" }, @@ -5974,6 +5975,7 @@ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -5983,6 +5985,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "dev": true, + "license": "MIT", "dependencies": { "ms": "2.0.0" } @@ -5991,7 +5994,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/bonjour-service": { "version": "1.1.1", @@ -6029,12 +6033,13 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, + "license": "MIT", "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -6599,6 +6604,7 @@ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -6610,10 +6616,11 @@ "dev": true }, "node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -8233,17 +8240,18 @@ } }, "node_modules/express": { - "version": "4.18.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", - "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", "dev": true, + "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.1", + "body-parser": "1.20.2", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.5.0", + "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", @@ -8485,10 +8493,11 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, + "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" }, @@ -8635,9 +8644,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", - "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", "dev": true, "funding": [ { @@ -8645,6 +8654,7 @@ "url": "https://github.com/sponsors/RubenVerborgh" } ], + "license": "MIT", "engines": { "node": ">=4.0" }, @@ -9811,6 +9821,7 @@ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "dev": true, + "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -10306,6 +10317,7 @@ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.12.0" } @@ -12503,14 +12515,15 @@ } }, "node_modules/katex": { - "version": "0.16.9", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.9.tgz", - "integrity": "sha512-fsSYjWS0EEOwvy81j3vRA8TEAhQhKiqO+FQaKWp0m39qwOzHVBgAUBIXWj1pB+O2W3fIpNa6Y9KSKCVbfPhyAQ==", + "version": "0.16.10", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.10.tgz", + "integrity": "sha512-ZiqaC04tp2O5utMsl2TEZTXxa6WSC4yo0fv5ML++D3QZv/vx2Mct0mTlRx3O+uUkjfuAgOkzsCmq5MiUEsDDdA==", "dev": true, "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" ], + "license": "MIT", "dependencies": { "commander": "^8.3.0" }, @@ -13127,6 +13140,7 @@ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -14926,6 +14940,7 @@ "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.0.4" }, @@ -14998,10 +15013,11 @@ } }, "node_modules/raw-body": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", "dev": true, + "license": "MIT", "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", @@ -15017,6 +15033,7 @@ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -16788,6 +16805,7 @@ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, + "license": "MIT", "dependencies": { "is-number": "^7.0.0" }, @@ -16959,6 +16977,7 @@ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", "dev": true, + "license": "MIT", "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" diff --git a/src/Terminal/HelpText.ts b/src/Terminal/HelpText.ts index 17ecaee68..0359d0635 100644 --- a/src/Terminal/HelpText.ts +++ b/src/Terminal/HelpText.ts @@ -15,6 +15,8 @@ export const TerminalHelpText: string[] = [ " download [script/text file] Downloads scripts or text files to your computer", " expr [math expression] Evaluate a mathematical expression", " free Check the machine's memory (RAM) usage", + " grep [opts]... pattern [file]... Search for PATTERN (string/regular expression) in each FILE and print results to terminal", + " [-O] [target file]", " grow Spoof money in a servers bank account, increasing the amount available.", " hack Hack the current machine", " help [command] Display this help text, or the help text for a command", @@ -229,6 +231,47 @@ export const HelpTexts: Record = { "how much of it is being used.", " ", ], + grep: [ + "Usage: grep [OPTION]... PATTERN [FILE]... [-O] [OUTFILE] [-B/A/C] [NUM]", + " ", + "Search for PATTERN in each FILE and print results to terminal.", + "Example: grep -n -h 'hello world' file1.js file2.txt -O output.txt -C 10 -V", + " ", + "OPTIONS: ", + " --help output this usage message and exit", + " ", + "Search control:", + " -* --search-all search for PATTERN in each FILE on server. Ignores any FILE argument(s) passed", + " -p --pipe-terminal search for PATTERN in terminal output. Ignores any FILE argument(s) passed", + " ", + "Pattern selection and interpretation:", + " -R, --regexp PATTERN is basic regular expression. PATTERN is a string by default", + " ", + "Output control:", + " -m --max-count NUM stop after NUM selected lines", + " -H --with-filename print filename with output lines. Default when multiple FILE arguments passed", + " -h --no-filename suppress printing file name with output lines. Default when one FILE argument passed. Overrides -H", + " -n --line-number print line number with output lines", + " -q --quiet --silent suppress printing to terminal", + " -O --output OUTFILE pipe output to text file. The following argument must be a valid .txt or .json filename. Does NOT overwrite by default", + " -f --allow-overwrite combine with [-O/--output] to allow overwriting provided output file", + " ", + "Context control:", + " -B --before-context NUM print NUM lines of leading context", + " -A --after-context NUM print NUM lines of trailing context", + " -C --context NUM print NUM lines of output context", + " ", + "Miscellaneous:", + " -V --verbose print PATTERN, count of matches and FILE(s) searched after regular output", + " -v --invert-match select non-matching lines", + " ", + "Regular OPTIONs may be combined into one. Context, max-count and output OPTIONs must be separated. Example: grep test -VnH* -O output.txt -C 5", + "By default PATTERN is interpreted as a simple string.", + "At least one FILE argument must be passed, or pass -*/--search-all to search all files.", + "The argument immediately following -m, -O and -B/A/C will be interpreted as the parameter for that OPTION.", + 'If encountering difficulties with argument parsing, consider explicitly passing a string as PATTERN. Example: grep -G "(complex|regexp|\\w+)" script.js', + " ", + ], grow: [ "Usage: grow", " ", diff --git a/src/Terminal/Terminal.ts b/src/Terminal/Terminal.ts index bf6716a6f..9a11352d9 100644 --- a/src/Terminal/Terminal.ts +++ b/src/Terminal/Terminal.ts @@ -46,6 +46,7 @@ import { cp } from "./commands/cp"; import { download } from "./commands/download"; import { expr } from "./commands/expr"; import { free } from "./commands/free"; +import { grep } from "./commands/grep"; import { grow } from "./commands/grow"; import { hack } from "./commands/hack"; import { help } from "./commands/help"; @@ -760,6 +761,7 @@ export class Terminal { download: download, expr: expr, free: free, + grep: grep, grow: grow, hack: hack, help: help, diff --git a/src/Terminal/commands/grep.ts b/src/Terminal/commands/grep.ts new file mode 100644 index 000000000..0458863d9 --- /dev/null +++ b/src/Terminal/commands/grep.ts @@ -0,0 +1,449 @@ +import { Terminal } from "../../Terminal"; +import { BaseServer } from "../../Server/BaseServer"; +import { hasTextExtension } from "../../Paths/TextFilePath"; +import { ContentFile, ContentFilePath, allContentFiles } from "../../Paths/ContentFile"; +import { Settings } from "../../Settings/Settings"; +import { help } from "../commands/help"; +import { Output } from "../OutputTypes"; + +type LineParser = (options: Options, filename: string, line: string, i: number) => ParsedLine; + +const RED: string = "\x1b[31m"; +const DEFAULT: string = "\x1b[0m"; +const GREEN: string = "\x1b[32m"; +const MAGENTA: string = "\x1b[35m"; +const CYAN: string = "\x1b[36m"; +const WHITE: string = "\x1b[37m"; + +// Options and ValidArgs key names must correlate +class ArgStrings { + short: readonly string[]; + long: readonly string[]; + + constructor(validArgs: ArgStrings) { + this.long = validArgs.long; + this.short = validArgs.long; + } +} + +interface Options { + isRegExpr: boolean; + + isLineNum: boolean; + isNamed: boolean; + isNotNamed: boolean; + isInvertMatch: boolean; + isMaxMatches: boolean; + + isQuiet: boolean; + isVerbose: boolean; + + isToFile: boolean; + isOverWrite: boolean; + + isPreContext: boolean; + isContext: boolean; + isPostContext: boolean; + + isHelp: boolean; + + isSearchAll: boolean; + isPipeIn: boolean; + + // exceptions: these options are not explicitly checked against passed arguments + isMultiFile: boolean; + hasContextFlag: boolean; +} +interface ValidArgs { + isRegExpr: ArgStrings; + + isLineNum: ArgStrings; + isNamed: ArgStrings; + isNotNamed: ArgStrings; + isInvertMatch: ArgStrings; + isMaxMatches: ArgStrings; + + isQuiet: ArgStrings; + isVerbose: ArgStrings; + + isToFile: ArgStrings; + isOverWrite: ArgStrings; + + isPreContext: ArgStrings; + isContext: ArgStrings; + isPostContext: ArgStrings; + + isHelp: ArgStrings; + + isSearchAll: ArgStrings; + isPipeIn: ArgStrings; +} +const VALID_ARGS: ValidArgs = { + isRegExpr: { short: ["-R"], long: ["--regexp"] }, + + isLineNum: { short: ["-n"], long: ["--line-number"] }, + isNamed: { short: ["-H"], long: ["--with-filename"] }, + isNotNamed: { short: ["-h"], long: ["--no-filename"] }, + isInvertMatch: { short: ["-v"], long: ["--invert-match"] }, + isMaxMatches: { short: ["-m"], long: ["--max-count"] }, + + isQuiet: { short: ["-q"], long: ["--silent", "--quiet"] }, + isVerbose: { short: ["-V"], long: ["--verbose"] }, + + isToFile: { short: ["-O"], long: ["--output"] }, + isOverWrite: { short: ["-f"], long: ["--allow-overwrite"] }, + + isPreContext: { short: ["-B"], long: ["--before-context"] }, + isContext: { short: ["-C"], long: ["--context"] }, + isPostContext: { short: ["-A"], long: ["--after-context"] }, + + isSearchAll: { short: ["-*"], long: ["--search-all"] }, + isPipeIn: { short: ["-p"], long: ["--pipe-terminal"] }, + + isHelp: { short: [], long: ["--help"] }, +} as const; +// + +interface Errors { + noArgs: string; + noSearchArg: string; + badSearchFile: (str: string[]) => string; + badParameter: (opt: string, arg: string) => string; + badOutFile: (str: string) => string; + outFileExists: (str: string) => string; + truncated: () => string; +} + +const ERR: Errors = { + noArgs: "grep argument error. Usage: grep [OPTION]... PATTERN [FILE]... [-O] [OUTPUT FILE] [-B/A/C] [NUM]", + noSearchArg: + "grep argument error: At least one FILE argument must be passed, or pass -*/--search-all to search all files on server", + badSearchFile: (files: string[]) => + `grep argument error: Invalid filename(s): ${files.join( + ", ", + )}. OPTIONS with additional parameters (-O, -m, -B/A/C) must be separated from other options`, + badParameter: (option: string, arg: string) => + `grep argument error: Incorrect ${option} argument "${arg}". Must be a number.`, + outFileExists: (path: string) => + `grep file output failed: Invalid output file "${path}". Output file must not already exist. Pass -f/--allow-overwrite to overwrite.`, + badOutFile: (path: string) => + `grep file output failed: Invalid output file "${path}". Output file must be a text file.`, + truncated: () => `\n${RED}Terminal output truncated to ${Settings.MaxTerminalCapacity} lines (Max terminal capacity)`, +} as const; + +class Args { + args: string[]; + + constructor(args: (string | number | boolean)[]) { + this.args = args.map(String); + } + + initOptions: Options = { + isRegExpr: false, + + isLineNum: false, + isNamed: false, + isNotNamed: false, + isInvertMatch: false, + isMaxMatches: false, + + isQuiet: false, + isVerbose: false, + + isToFile: false, + isOverWrite: false, + + isPreContext: false, + isContext: false, + isPostContext: false, + + isSearchAll: false, + isPipeIn: false, + + isHelp: false, + + isMultiFile: false, + hasContextFlag: false, + }; + + mapArgToOpts(fullArg: string, options: Options): [Options, boolean] { + let isOption = false; + for (const key of Object.keys(VALID_ARGS)) { + const stripDash = (arg: string) => arg.replace("-", ""); + if (!fullArg.startsWith("-")) break; + // check long args + const theseArgs = VALID_ARGS[key as keyof ValidArgs]; + const allArgs = [...theseArgs.long, ...theseArgs.short]; + if (allArgs.includes(fullArg)) { + options[key as keyof Options] = true; + isOption = true; + } + // check multiflag args + const multiFlag = stripDash(fullArg); + const shortArgs = theseArgs.short.map(stripDash); + if (multiFlag.length > 1 && shortArgs.some((arg) => [...multiFlag].includes(arg))) { + options[key as keyof Options] = true; + isOption = true; + } + } + return [options, isOption]; + } + + splitOptsAndArgs(): [Options, string[], string, string, string] { + let outFile, limit, context; + + [outFile, this.args] = this.spliceOptParam(VALID_ARGS.isToFile); + [limit, this.args] = this.spliceOptParam(VALID_ARGS.isMaxMatches); + [context, this.args] = this.spliceOptParam(VALID_ARGS.isPreContext); + if (!context) [context, this.args] = this.spliceOptParam(VALID_ARGS.isContext); + if (!context) [context, this.args] = this.spliceOptParam(VALID_ARGS.isPostContext); + + const [options, otherArgs] = this.args.reduce( + ([options, otherArgs]: [Options, string[]], fullArg: string): [Options, string[]] => { + let isOption = false; + [options, isOption] = this.mapArgToOpts(fullArg, options); + return isOption ? [options, otherArgs] : [options, [...otherArgs, fullArg]]; + }, + [this.initOptions, []], + ); + const outFileStr = outFile ?? ""; + const limitNum = limit ?? ""; + const contextNum = context ?? ""; + + return [options, otherArgs, outFileStr, contextNum, limitNum]; + } + + spliceOptParam(validArgs: ArgStrings): [string, string[]] | [undefined, string[]] { + const argIndex = [...validArgs.long, ...validArgs.short].reduce((ret: number, arg: string) => { + const argIndex = this.args.indexOf(arg); + return argIndex > -1 ? argIndex : ret; + }, NaN); + + if (isNaN(argIndex)) return [undefined, this.args]; + + const nextArg = this.args.splice(argIndex + 1, 1)[0]; + + return [nextArg, this.args]; + } +} + +interface LineStrings { + rawLine: string; + prettyLine: string; +} + +interface ParsedLine { + isPrint: boolean; + isMatched: boolean; + lines: LineStrings; + filename: string; + isFileSep: boolean; +} + +class Results { + lines: ParsedLine[]; + areEdited: boolean; + numMatches: number; + options: Options; + matchCounter: number; + matchLimit: number; + + constructor(results: ParsedLine[], options: Options, matchLimit: number) { + this.lines = results; + this.options = options; + this.areEdited = results.some((line) => line.isMatched); + this.numMatches = results.reduce((acc, result) => acc + Number(result.isMatched), 0); + this.matchLimit = matchLimit; + this.matchCounter = 0; + } + + addContext(context: number): Results { + const nContext = isNaN(Number(context)) ? 0 : Number(context); + for (const [editLineIndex, line] of this.lines.entries()) { + if (!line.isMatched) continue; + for (let contextLineIndex = 0; contextLineIndex <= nContext; contextLineIndex++) { + let contextLine; + if (this.options.isPreContext) { + contextLine = this.lines[editLineIndex - contextLineIndex]; + } else if (this.options.isPostContext) { + contextLine = this.lines[editLineIndex + contextLineIndex]; + } else if (this.options.isContext) { + contextLine = this.lines[editLineIndex - Math.floor(nContext / 2) + contextLineIndex]; + } else { + contextLine = line; + } + + if (contextLine && !line.isFileSep && line.filename === contextLine.filename) contextLine.isPrint = true; + } + } + return this; + } + + splitAndFilter(): [string[], string[]] { + const rawResult = []; + const prettyResult = []; + for (const lineInfo of this.lines) { + if (lineInfo.isPrint === this.options.isInvertMatch) continue; + rawResult.push(lineInfo.lines.rawLine); + prettyResult.push(lineInfo.lines.prettyLine); + } + return [rawResult, prettyResult]; + } + + capMatches(limit: number): Results { + if (!this.options.isMaxMatches) return this; + for (const line of this.lines) { + if (line.isMatched) this.matchCounter += 1; + if (this.matchCounter > limit) line.isMatched = false; + } + return this; + } + + getVerboseInfo(files: ContentFile[], pattern: string | RegExp, options: Options): string { + if (!options.isVerbose) return ""; + const suffix = (pre: string, num: number) => pre + (num === 1 ? "" : "s"); + const totalLines = this.lines.length; + const matchCount = Math.abs((options.isInvertMatch ? totalLines : 0) - this.numMatches); + const inputStr = options.isPipeIn + ? "piped from terminal " + : `in ${files.length} ${suffix("file", files.length)}:\n`; + const filesStr = files + .map((file, i) => `${i % 2 ? WHITE : ""}${file.filename}(${file.content.split("\n").length}loc)${DEFAULT}`) + .join(", "); + + return [ + `\n${(options.isMaxMatches ? this.matchLimit : matchCount) + (options.isInvertMatch ? " INVERTED" : "")} `, + suffix("line", matchCount) + " matched ", + `against PATTERN "${pattern.toString()}" `, + `in ${totalLines} ${suffix("line", totalLines)}, `, + inputStr, + `${filesStr}`, + ].join(""); + } +} + +function getServerFiles(server: BaseServer): [ContentFile[], string[]] { + const files = []; + for (const tuple of allContentFiles(server)) { + files.push(tuple[1]); + } + return [files, []]; +} + +function getArgFiles(args: string[]): [ContentFile[], string[]] { + const notFiles = []; + const files = []; + + for (const arg of args) { + const file = hasTextExtension(arg) ? Terminal.getTextFile(arg) : Terminal.getScript(arg); + if (!file) { + notFiles.push(arg); + } else { + files.push(file); + } + } + + return [files, notFiles]; +} + +function parseLine(pattern: string | RegExp, options: Options, filename: string, line: string, i: number): ParsedLine { + const editedLine = line.replaceAll(pattern, `${RED}$&${DEFAULT}`); + + const name = options.isMultiFile || (options.isNamed && !options.isNotNamed) ? `${filename}` : ""; + const lineNo = options.isLineNum ? `${i + 1}` : ""; + + const [colName, rawName] = name ? [`${MAGENTA}${name}${CYAN}:${DEFAULT}`, `${name}:`] : ["", ""]; + const [colLineNo, rawLineNo] = lineNo ? [`${GREEN}${lineNo}${CYAN}:${DEFAULT}`, `${lineNo}:`] : ["", ""]; + const lines: LineStrings = { rawLine: rawName + rawLineNo + line, prettyLine: colName + colLineNo + editedLine }; + + const isMatched = line !== editedLine; + return { lines, filename, isMatched, isPrint: false, isFileSep: false }; +} + +function parseFile(lineParser: LineParser, options: Options, file: ContentFile, i: number): ParsedLine[] { + const parseLineFn = lineParser.bind(null, options, file.filename); + const editedContent: ParsedLine[] = file.content.split("\n").map(parseLineFn); + + const hasMatch = editedContent.some((line) => line.isMatched); + + const isPrintFileSep = options.hasContextFlag && hasMatch && i !== 0; + + const fileSeparator: ParsedLine = { + lines: { prettyLine: `${CYAN}--${DEFAULT}`, rawLine: "--" }, + isPrint: true, + isMatched: false, + isFileSep: true, + filename: "", + }; + return isPrintFileSep ? [fileSeparator, ...editedContent] : editedContent; +} + +function writeToTerminal( + prettyResult: string[], + options: Options, + results: Results, + files: ContentFile[], + pattern: string | RegExp, +): void { + const printResult = prettyResult.slice(prettyResult.length - Settings.MaxTerminalCapacity); // limit printing to terminal + const isTruncated = prettyResult.length !== printResult.length; + const verboseInfo = results.getVerboseInfo(files, pattern, options); + const truncateInfo = isTruncated ? ERR.truncated() : ""; + + if (results.areEdited) Terminal.print(printResult.join("\n") + truncateInfo); + if (options.isVerbose) Terminal.print(verboseInfo); +} + +function checkOutFile(outFileStr: string, options: Options, server: BaseServer): ContentFilePath | void { + if (!options.isToFile) return; + const outFilePath = Terminal.getFilepath(outFileStr); + if (!outFilePath || !hasTextExtension(outFilePath)) { + return Terminal.error(ERR.badOutFile(outFileStr)); + } + if (!options.isOverWrite && server.textFiles.has(outFilePath)) return Terminal.error(ERR.outFileExists(outFileStr)); + return outFilePath; +} + +function grabTerminal(): string[] { + return Terminal.outputHistory.map((line) => (line as Output).text ?? ""); +} + +export function grep(args: (string | number | boolean)[], server: BaseServer): void { + if (!args.length) return Terminal.error(ERR.noArgs); + + const [options, otherArgs, outFile, context, limit] = new Args(args).splitOptsAndArgs(); + const [files, notFiles] = options.isSearchAll ? getServerFiles(server) : getArgFiles(otherArgs.slice(1)); + const outFilePath = checkOutFile(outFile, options, server); + + options.isMultiFile = files.length > 1; + options.hasContextFlag = options.isContext || options.isPreContext || options.isPostContext; + + // error checking + if (options.isToFile && !outFilePath) return; // associated errors are printed in checkOutFile + if (options.isHelp) return help(["grep"]); + if (notFiles.length) return Terminal.error(ERR.badSearchFile(notFiles)); + if (!options.isPipeIn && !options.isSearchAll && !files.length) return Terminal.error(ERR.noSearchArg); + if (options.hasContextFlag && (context === "" || isNaN(Number(context)))) + return Terminal.error(ERR.badParameter("context", context)); + if (options.isMaxMatches && (limit === "" || isNaN(Number(limit)))) + return Terminal.error(ERR.badParameter("limit", limit)); + + const nContext = Number(context); + const nLimit = Number(limit); + + try { + const pattern = options.isRegExpr ? new RegExp(otherArgs[0], "g") : otherArgs[0]; + const lineParser = parseLine.bind(null, pattern); + const termParser = lineParser.bind(null, options, "Terminal"); + const fileParser = parseFile.bind(null, lineParser, options); + const contentToMatch = options.isPipeIn ? grabTerminal().map(termParser) : files.flatMap(fileParser); + const results = new Results(contentToMatch, options, nLimit); + const [rawResult, prettyResult] = results.capMatches(nLimit).addContext(nContext).splitAndFilter(); + + if (options.isPipeIn) files.length = 0; + if (!options.isQuiet) writeToTerminal(prettyResult, options, results, files, pattern); + if (options.isToFile && outFilePath) server.writeToContentFile(outFilePath, rawResult.join("\n")); + } catch (e) { + Terminal.error("grep processing error: " + e); + } +}