From 357cc568e956bd71f28a07cff852da89a14179dc Mon Sep 17 00:00:00 2001 From: muesli4brekkies <110121045+muesli4brekkies@users.noreply.github.com> Date: Fri, 28 Jun 2024 23:13:49 +0100 Subject: [PATCH] TERMINAL: Tweaks and bugfixes to grep (#1431) --- src/Terminal/commands/grep.ts | 339 +++++++++++++++++----------------- 1 file changed, 165 insertions(+), 174 deletions(-) diff --git a/src/Terminal/commands/grep.ts b/src/Terminal/commands/grep.ts index 0458863d9..4a6d1cf6e 100644 --- a/src/Terminal/commands/grep.ts +++ b/src/Terminal/commands/grep.ts @@ -13,150 +13,123 @@ const DEFAULT: string = "\x1b[0m"; const GREEN: string = "\x1b[32m"; const MAGENTA: string = "\x1b[35m"; const CYAN: string = "\x1b[36m"; +const YELLOW: string = "\x1b[33m"; const WHITE: string = "\x1b[37m"; -// Options and ValidArgs key names must correlate -class ArgStrings { +const ERR = { + noArgs: "grep argument error. Usage: grep [OPTION]... PATTERN [FILE]... [-O] [OUTPUT FILE] [-m -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", + badArgs: (args: string[]) => "grep argument error: Invalid argument(s): " + args.join(", "), + badParameter: (option: string, arg: string) => + `grep argument error: Incorrect ${option} argument "${arg}". Must be a number. OPTIONS with additional parameters (-O, -m, -B/A/C) must be separated from other options`, + 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 path must be a valid .txt file.`, + truncated: () => + `\n${YELLOW}Terminal output truncated to ${Settings.MaxTerminalCapacity} lines (Max terminal capacity)`, +} as const; + +type ArgStrings = { short: readonly string[]; long: readonly string[]; +}; - constructor(validArgs: ArgStrings) { - this.long = validArgs.long; - this.short = validArgs.long; - } -} - -interface Options { +type 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; +type Parameters = { + preContext: string; + context: string; + postContext: string; - isQuiet: ArgStrings; - isVerbose: ArgStrings; + outfile: string; + maxMatches: string; +}; - isToFile: ArgStrings; - isOverWrite: ArgStrings; +type ValidParams = { + [key in T]: ArgStrings; +}; - isPreContext: ArgStrings; - isContext: ArgStrings; - isPostContext: ArgStrings; +const VALID_PARAMS: ValidParams = { + preContext: { short: ["-B"], long: ["--before-context"] }, + context: { short: ["-C"], long: ["--context"] }, + postContext: { short: ["-A"], long: ["--after-context"] }, - isHelp: ArgStrings; + maxMatches: { short: ["-m"], long: ["--max-count"] }, + outfile: { short: ["-O"], long: ["--output"] }, +}; - isSearchAll: ArgStrings; - isPipeIn: ArgStrings; -} -const VALID_ARGS: ValidArgs = { +type ValidArgs = { + [key in T]: 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)`, + isMultiFile: { short: [], long: [] }, + hasContextFlag: { short: [], long: [] }, } as const; class Args { args: string[]; + options: Options; + params: Parameters; constructor(args: (string | number | boolean)[]) { this.args = args.map(String); + this.options = this.INIT_OPTIONS; + this.params = this.INIT_PARAMS; } - initOptions: Options = { + INIT_OPTIONS: 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, @@ -166,109 +139,124 @@ class Args { 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]; - } + INIT_PARAMS: Parameters = { + preContext: "", + context: "", + postContext: "", + maxMatches: "", + outfile: "", + }; - 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[]] { + private spliceParam(validArgs: ArgStrings): string { + console.log(validArgs); 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]; + if (isNaN(argIndex)) return ""; - const nextArg = this.args.splice(argIndex + 1, 1)[0]; + return this.args.splice(argIndex + 1, 1)[0]; + } - return [nextArg, this.args]; + private spliceOptionalParams(): Args { + for (const [key, validArgs] of Object.entries(VALID_PARAMS)) { + this.params[key as keyof Parameters] = this.spliceParam(validArgs); + } + return this; + } + + private reduceToOptionsAndFiles(): string[] { + const stripDash = (arg: string) => arg.slice(1); + const argKeys = Object.keys(VALID_ARGS).map((k) => k as keyof Options); + const paramKeys = Object.keys(VALID_PARAMS).map((k) => k as keyof Parameters); + const allValidArgs: string[] = []; + + let validFlagChars = ""; + for (const key of paramKeys) { + const argString = VALID_PARAMS[key]; + allValidArgs.push(...argString.long, ...argString.short); + } + for (const key of argKeys) { + const argString = VALID_ARGS[key]; + allValidArgs.push(...argString.long, ...argString.short); + validFlagChars += argString.short.map(stripDash).join(""); + } + + const fileArgs = this.args.reduce((fileArgs: string[], fullArg: string): string[] => { + if (!fullArg.startsWith("-")) return [...fileArgs, fullArg]; + const isLongArg = fullArg.startsWith("--"); + const isShortArg = fullArg.length === 2; + let isBadArg = false; + for (const key of argKeys) { + const argStrings = VALID_ARGS[key]; + // check for exact matches + if (isLongArg || isShortArg) { + isBadArg = !allValidArgs.includes(fullArg); + if (!isBadArg && [...argStrings.long, ...argStrings.short].includes(fullArg)) { + this.options[key] = true; + } + } else { + // or check multiflag + const flagStr = stripDash(fullArg); + const shortArgs = argStrings.short.map(stripDash); + isBadArg = [...flagStr].some((char) => !validFlagChars.includes(char)); + if (!isBadArg && shortArgs.some((arg) => [...flagStr].includes(arg))) { + this.options[key] = true; + } + } + } + return !isBadArg ? fileArgs : [...fileArgs, fullArg]; + }, []); + + return fileArgs; + } + + splitOptsAndArgs(): [string[], Options, Parameters] { + return [this.spliceOptionalParams().reduceToOptionsAndFiles(), this.options, this.params]; } } -interface LineStrings { +type LineStrings = { rawLine: string; prettyLine: string; -} +}; -interface ParsedLine { +type ParsedLine = { isPrint: boolean; isMatched: boolean; lines: LineStrings; filename: string; isFileSep: boolean; -} +}; class Results { - lines: ParsedLine[]; + results: ParsedLine[]; areEdited: boolean; numMatches: number; options: Options; - matchCounter: number; - matchLimit: number; + params: Parameters; - constructor(results: ParsedLine[], options: Options, matchLimit: number) { - this.lines = results; + constructor(results: ParsedLine[], options: Options, params: Parameters) { + this.results = results; this.options = options; + this.params = params; 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()) { + for (const [editLineIndex, line] of this.results.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]; + if (this.params.preContext) { + contextLine = this.results[editLineIndex - contextLineIndex]; + } else if (this.params.postContext) { + contextLine = this.results[editLineIndex + contextLineIndex]; + } else if (this.params.context) { + contextLine = this.results[editLineIndex - Math.floor(nContext / 2) + contextLineIndex]; } else { contextLine = line; } @@ -282,7 +270,7 @@ class Results { splitAndFilter(): [string[], string[]] { const rawResult = []; const prettyResult = []; - for (const lineInfo of this.lines) { + for (const lineInfo of this.results) { if (lineInfo.isPrint === this.options.isInvertMatch) continue; rawResult.push(lineInfo.lines.rawLine); prettyResult.push(lineInfo.lines.prettyLine); @@ -291,10 +279,12 @@ class Results { } 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; + if (!this.params.maxMatches) return this; + + let matchCounter = 0; + for (const line of this.results) { + if (line.isMatched) matchCounter += 1; + if (matchCounter > limit) line.isMatched = false; } return this; } @@ -302,7 +292,7 @@ class Results { 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 totalLines = this.results.length; const matchCount = Math.abs((options.isInvertMatch ? totalLines : 0) - this.numMatches); const inputStr = options.isPipeIn ? "piped from terminal " @@ -312,7 +302,9 @@ class Results { .join(", "); return [ - `\n${(options.isMaxMatches ? this.matchLimit : matchCount) + (options.isInvertMatch ? " INVERTED" : "")} `, + `\n${ + (this.params.maxMatches ? this.params.maxMatches : matchCount) + (options.isInvertMatch ? " INVERTED" : "") + } `, suffix("line", matchCount) + " matched ", `against PATTERN "${pattern.toString()}" `, `in ${totalLines} ${suffix("line", totalLines)}, `, @@ -385,17 +377,15 @@ function writeToTerminal( 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 printResult = prettyResult.slice(0, Math.min(prettyResult.length, Settings.MaxTerminalCapacity)); // limit printing to terminal const verboseInfo = results.getVerboseInfo(files, pattern, options); - const truncateInfo = isTruncated ? ERR.truncated() : ""; - + const truncateInfo = prettyResult.length !== printResult.length ? 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; + if (!outFileStr) return; const outFilePath = Terminal.getFilepath(outFileStr); if (!outFilePath || !hasTextExtension(outFilePath)) { return Terminal.error(ERR.badOutFile(outFileStr)); @@ -411,25 +401,26 @@ function grabTerminal(): string[] { 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 [otherArgs, options, params] = new Args(args).splitOptsAndArgs(); + if (options.isHelp) return help(["grep"]); + options.hasContextFlag = !!params.context || !!params.preContext || !!params.postContext; + + const nContext = Math.max(Number(params.preContext), Number(params.context), Number(params.postContext)); + const nLimit = Number(params.maxMatches); + + if (options.hasContextFlag && (!nContext || isNaN(Number(params.context)))) + return Terminal.error(ERR.badParameter("context", params.context)); + if (params.maxMatches && (!nLimit || isNaN(Number(params.maxMatches)))) + return Terminal.error(ERR.badParameter("limit", params.maxMatches)); + const [files, notFiles] = options.isSearchAll ? getServerFiles(server) : getArgFiles(otherArgs.slice(1)); - const outFilePath = checkOutFile(outFile, options, server); + + if (notFiles.length) return Terminal.error(ERR.badArgs(notFiles)); + if (!options.isPipeIn && !options.isSearchAll && !files.length) return Terminal.error(ERR.noSearchArg); 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); + const outFilePath = checkOutFile(params.outfile, options, server); + if (params.outfile && !outFilePath) return; // associated errors are printed in checkOutFile try { const pattern = options.isRegExpr ? new RegExp(otherArgs[0], "g") : otherArgs[0]; @@ -437,12 +428,12 @@ export function grep(args: (string | number | boolean)[], server: BaseServer): v 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 results = new Results(contentToMatch, options, params); 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")); + if (params.outfile && outFilePath) server.writeToContentFile(outFilePath, rawResult.join("\n")); } catch (e) { Terminal.error("grep processing error: " + e); }