» Make grep CLI App in Node.js » 2. Development » 2.3 Add Functionalities

Add Functionalities

You need to support these functionalities as shown in the project objectives section:

-c, --count
-i, --ignore-case
-n, --line-number
-r, --recursive
-v, --invert-match

Add dependency yargs for complex arguments parsing:

npm i yargs

bin/grepjs:

#!/usr/bin/env node

const fs = require("fs");
const yargs = require("yargs");

const { grep, grepCount, grepRecursive } = require("../lib/grep");

// Parse command-line options
const argv = yargs
  .usage("Usage: $0 [options] <pattern> <file>")
  .option("c", {
    alias: "count",
    describe: "Only a count of selected lines is written to standard output.",
    type: "boolean",
  })
  .option("h", {
    alias: "help",
    describe: "Print a brief help message.",
    type: "boolean",
  })
  .option("i", {
    alias: "ignore-case",
    describe:
      "Perform case insensitive matching. By default, it is case sensitive.",
    type: "boolean",
  })
  .option("n", {
    alias: "line-number",
    describe:
      "Each output line is preceded by its relative line number in the file, starting at line 1. The line number counter is reset for each file processed. This option is ignored if -c is specified.",
    type: "boolean",
  })
  .option("r", {
    alias: "recursive",
    describe: "Recursively search subdirectories listed.",
    type: "boolean",
  })
  .option("v", {
    alias: "invert-match",
    describe:
      "Selected lines are those not matching any of the specified patterns.",
    type: "boolean",
  })
  .demandCommand(2, "Please provide both pattern and file arguments.").argv;

const pattern = argv._[0];
const filePath = argv._[1];

if (argv.help) {
  // Print help message and exit
  console.log(argv.help);
  process.exit(0);
}

const options = {
  ignoreCase: argv["ignore-case"],
  invertMatch: argv["invert-match"],
};
const result = argv.recursive
  ? grepRecursive(pattern, filePath, options)
  : grep(pattern, filePath, options);

result
  .then((result) => {
    if (argv.count) {
      console.log(grepCount(result));
    } else {
      printResult(result, argv["line-number"]);
    }
  })
  .catch((error) => {
    console.error("Error:", error.message);
  });

function printResult(result, showLineNumber) {
  let currentFile = null;
  const fileCount = Object.keys(result).length;

  for (const [filePath, lines] of Object.entries(result)) {
    for (const [lineNumber, line] of lines) {
      if (fileCount > 1 && filePath !== currentFile) {
        currentFile = filePath;
        console.log(`\n${filePath}:`);
      }
      if (showLineNumber) {
        console.log(`${lineNumber}: ${line}`);
      } else {
        console.log(line);
      }
    }
  }
}

yargs adds all the optional arguments into the app. Function printResult parses the result object and prints the filePath and lineNumber if needed.

lib/grep.js:

const fs = require("fs");
const path = require("path");

async function grep(pattern, filePath, options = {}) {
  const { ignoreCase, invertMatch } = options;
  const lines = await _readFileLines(filePath);
  const regexFlags = ignoreCase ? "gi" : "g";
  const regex = new RegExp(pattern, regexFlags);
  if (invertMatch) {
    matchingLines = _filterLines(regex, lines, false);
  } else {
    matchingLines = _filterLines(regex, lines, true);
  }
  return { [filePath]: matchingLines };
}

async function grepRecursive(pattern, dirPath, options = {}) {
  let results = {};
  try {
    const files = await fs.promises.readdir(dirPath);
    for (const file of files) {
      const filePath = path.join(dirPath, file);
      const isSubDir = (await fs.promises.stat(filePath)).isDirectory();
      const result = !isSubDir
        ? await grep(pattern, filePath, options)
        : await grepRecursive(pattern, filePath, options);
      results = { ...results, ...result };
    }
  } catch (err) {
    console.error(err);
  }
  return results;
}

function grepCount(result) {
  return Object.values(result).reduce(
    (count, lines) => count + lines.length,
    0
  );
}

function _filterLines(regexPattern, lines, flag) {
  return lines
    .map((line, lineNumber) => {
      const match = regexPattern.test(line);
      return flag === match ? [lineNumber + 1, line.trim()] : null;
    })
    .filter(Boolean);
}

async function _readFileLines(filePath) {
  try {
    // Read the file asynchronously
    const data = await fs.promises.readFile(filePath, "utf8");
    return data.split("\n");
  } catch (error) {
    console.error("Error reading the file:", error.message);
  }
  return [];
}

module.exports = {
  grep,
  grepCount,
  grepRecursive,
};

Function grep adds logic for "case-insensitive match" and "invert match." Function grepRecursive lists all files recursively, greps them, and puts the results into the big result object.

Run the normal way:

./bin/grepjs filePath lib/grep.js 

Result lines:

async function grep(pattern, filePath, options = {}) {
const lines = await _readFileLines(filePath);
return { [filePath]: matchingLines };
const filePath = path.join(dirPath, file);
const isSubDir = (await fs.promises.stat(filePath)).isDirectory();
? await grep(pattern, filePath, options)
: await grepRecursive(pattern, filePath, options);
async function _readFileLines(filePath) {
const data = await fs.promises.readFile(filePath, "utf8");

Do the count:

./bin/grepjs -c filePath lib/grep.js

Result number is: 9.

Show the line numbers:

./bin/grepjs -n filePath lib/grep.js

Result lines:

4: async function grep(pattern, filePath, options = {}) {
6: const lines = await _readFileLines(filePath);
14: return { [filePath]: matchingLines };
22: const filePath = path.join(dirPath, file);
23: const isSubDir = (await fs.promises.stat(filePath)).isDirectory();
25: ? await grep(pattern, filePath, options)
26: : await grepRecursive(pattern, filePath, options);
51: async function _readFileLines(filePath) {
54: const data = await fs.promises.readFile(filePath, "utf8");

Use regex pattern

./bin/grepjs -n "\br[a-z]+t" lib/grep.js

Result lines:

14: return { [filePath]: matchingLines };
18: let results = {};
24: const result = !isSubDir
27: results = { ...results, ...result };
32: return results;
35: function grepCount(result) {
43: return lines
46: return flag === match ? [lineNumber + 1, line.trim()] : null;
55: return data.split("\n");
59: return [];

Do the invert match:

./bin/grepjs -vn result lib/grep.js

Result is something like this:

1: const fs = require("fs");
2: const path = require("path");
3: 
4: async function grep(pattern, filePath, options = {}) {
5: const { ignoreCase, invertMatch } = options;

...

8: const regex = new RegExp(pattern, regexFlags);
9: if (invertMatch) {

...

62: module.exports = {
63: grep,
64: grepCount,
65: grepRecursive,
66: };

Do the case insensitive match:

./bin/grepjs -i only bin/grepjs

Result is:

describe: "Only a count of selected lines is written to standard output.",

Do the big recursive match:

./bin/grepjs -r filePath .

Result lines should be like:


bin/grepjs:
const filePath = argv._[1];
? grepRecursive(pattern, filePath, options)
for (const [filePath, lines] of Object.entries(result)) {
if (fileCount > 1 && filePath !== currentFile) {
console.log(`\n${filePath}:`);

lib/grep.js:
async function grep(pattern, filePath, options = {}) {
const lines = await _readFileLines(filePath);
return { [filePath]: matchingLines };
const filePath = path.join(dirPath, file);
const isSubDir = (await fs.promises.stat(filePath)).isDirectory();
? await grep(pattern, filePath, options)
: await grepRecursive(pattern, filePath, options);
async function _readFileLines(filePath) {
const data = await fs.promises.readFile(filePath, "utf8");