diff --git a/implement-shell-tools/.gitignore b/implement-shell-tools/.gitignore new file mode 100644 index 00000000..c3c80962 --- /dev/null +++ b/implement-shell-tools/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +*.log +.DS_Store +.env \ No newline at end of file diff --git a/implement-shell-tools/cat/cat.js b/implement-shell-tools/cat/cat.js new file mode 100644 index 00000000..ae33600c --- /dev/null +++ b/implement-shell-tools/cat/cat.js @@ -0,0 +1,65 @@ +import { program } from "commander"; +import { promises as fs } from "node:fs"; +import process from "node:process"; + +// configure the CLI program with its name, description, arguments, options, and actions (the help instructions) +program + .name("cat") + .description("An alternative to the 'cat' command") + .argument("", "The file(s) to process") + .option("-n, --number", "Number all output lines") + .option("-b, --number-nonblank", "Number non-blank output lines") + .action(async (files, options) => { + try { + await newCat(files, options); + } catch (err) { + console.error(`Error: ${err.message}`); + } + }); + +program.parse(process.argv); + +//helper function to format output +function printLine(line, lineNumber, padWidth) { + if (lineNumber !== null) { + console.log(`${lineNumber.toString().padStart(padWidth, ' ')} ${line}`); + } else { + console.log(line); + } +} + +async function newCat(files, options) { + let lineNumber = 1; + const padWidth = 6; + + for (const file of files) { + // read each file into a single text string + try { + const data = await fs.readFile(file, "utf8"); + // split that string into an array at \n where each element is a line from the file + // e.g. lines = ["Line 1", "Line 2", "Line 3"] + const lines = data.split("\n") + + // remove trailing blank line caused by a trailing newline + if (lines[lines.length - 1] === "") { + lines.pop(); + } + + lines.forEach(line => { + //line trim: truthy = text, falsy = blank + if (options.numberNonblank && line.trim()) { + // number non-blank lines only + printLine(line, lineNumber++, padWidth); + } else if (options.number){ + // number all lines + printLine(line, lineNumber++, padWidth); + } else { + // neither flag, print normally + printLine(line, null, padWidth) + } + }); + } catch (err) { + console.error(`Error reading file ${file}: ${err.message}`); + } + } +} \ No newline at end of file diff --git a/implement-shell-tools/ls/ls.js b/implement-shell-tools/ls/ls.js new file mode 100644 index 00000000..6c6f40f4 --- /dev/null +++ b/implement-shell-tools/ls/ls.js @@ -0,0 +1,94 @@ +import { program } from "commander"; +import { promises as fs } from "node:fs"; +import process from "node:process"; + +// configure the CLI program with its name, description, arguments, options, and actions (the help instructions) +program + .name("ls") + .description("An alternative to the 'ls' command") + .argument("[directory]", "The directory to list") + // Commander stores -1 as a string key that is accessed using options['1'] + .option("-1", "List all files, one per line") + .option("-a, --all", "Include hidden files (those starting with .) in the listing") + .action(async (directory, options) => { + try { + // default to current directory if none is specified + const dir = directory || "."; + + await newLs(dir, options['1'], options.all); + } catch (err) { + console.error(`Error: ${err.message}`); + } + }); + +program.parse(process.argv); + + +// filter files based on visibility (includeHidden = true includes all files) +function filterFiles(entries, includeHidden) { + return entries.filter(name => + includeHidden ? true : !name.startsWith(".") + ); +} + +// sort entries: directories first, then files, +function sortEntries(entries) { + const dirs = entries.filter(entry => { + try { + return fs.statSync(entry).isDirectory(); + } catch (err) { + return false; + } + }); + + const files = entries.filter(entry => { + try { + return fs.statSync(entry).isFile(); + } catch (err) { + return false; + } + }); + // localeCompare = take into account rules of system language/region for ordering + // undefined = uses the system default, numeric = regular number sorting, base = ignore case & accents + return entries.sort((a, b) => + a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }) + ); +} + + +// print each entry on its own line (used for -1 flag) +function printEntries(entries) { + entries.forEach(entry => console.log(entry)); +} + + +async function newLs(directory, oneFlag, allFlag) { + try { + // check if path exists and determine if file or directory + const stats = await fs.stat(directory); + + // if a file, just print the name + if (stats.isFile()) { + console.log(directory); + return; + } + + // reads directory contents + const entries = await fs.readdir(directory); + + // filter out hidden files if no -a flag + const filteredEntries = filterFiles(entries, allFlag); + + // sort the entries using the sortEntries helper + const sortedEntries = sortEntries(filteredEntries); + + // print entries based on -1 flag + if (oneFlag) { + printEntries(sortedEntries); // one per line + } else { + console.log(sortedEntries.join(" ")); // all on one line, separated by spaces + } + } catch (err) { + console.error(`ls: cannot access '${directory}': ${err.message}`); + } +} \ No newline at end of file diff --git a/implement-shell-tools/package-lock.json b/implement-shell-tools/package-lock.json new file mode 100644 index 00000000..38a22987 --- /dev/null +++ b/implement-shell-tools/package-lock.json @@ -0,0 +1,25 @@ +{ + "name": "implement-shell-tools", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "implement-shell-tools", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "commander": "^14.0.2" + } + }, + "node_modules/commander": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", + "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "license": "MIT", + "engines": { + "node": ">=20" + } + } + } +} diff --git a/implement-shell-tools/package.json b/implement-shell-tools/package.json new file mode 100644 index 00000000..32d31991 --- /dev/null +++ b/implement-shell-tools/package.json @@ -0,0 +1,8 @@ +{ + "name": "implement-shell-tools", + "version": "1.0.0", + "type": "module", + "dependencies": { + "commander": "^14.0.2" + } +} diff --git a/implement-shell-tools/wc/wc.js b/implement-shell-tools/wc/wc.js new file mode 100644 index 00000000..7b977617 --- /dev/null +++ b/implement-shell-tools/wc/wc.js @@ -0,0 +1,85 @@ +import { program } from "commander"; +import { promises as fs } from "node:fs"; +import process from "node:process"; + +// configure the CLI program with its name, description, arguments, options, and actions (the help instructions) +program + .name("wc") + .description("An alternative to the 'wc' command") + .argument("", "The file(s) to count lines/words/bytes") + .option("-l, --lines", "Print the newline counts") + .option("-w, --words", "Print the word counts") + .option("-c, --bytes", "Print the byte counts") + .action(async (files, options) => { + try { + // call newWc for all files + await newWc(files, options) + } catch (err) { + console.error(`Error: ${err.message}`); + } + }); + + program.parse(process.argv); + +//helper function to format string for output +function formatCount(count) { + const paddingStart = 3 + return count.toString().padStart(paddingStart); +} + + +// helper function to print the wc outputs per case +function printWcOutput(lineCount, wordCount, byteCount, file, options, noFlags) { + const parts = []; + + if (noFlags || options.lines) parts.push(formatCount(lineCount)); + if (noFlags || options.words) parts.push(formatCount(wordCount)); + if (noFlags || options.bytes) parts.push(formatCount(byteCount)); + + parts.push(file); + console.log(parts.join(" ")); +} + +async function newWc(files, options) { + + const noFlags = + !options.lines && + !options.words && + !options.bytes; + + // set the counts variables + let totalLines = 0; + let totalWords = 0; + let totalBytes = 0; + + for (const file of files) { + try { + // read each file into a single text string + const content = await fs.readFile(file, "utf8"); + + // count lines by splitting on '\n' and subtracting 1 because + // each newline creates an extra array element, so length-1 equals the number of newline characters + const lineCount = content.split("\n").length - 1; + + // .filter(Boolean) ensures that falsy values like "" (empty string), null, undefined, 0, false are removed + const wordCount = content.split(/\s+/).filter(Boolean).length; + + // calculates the number of bytes the file uses when encoded as UTF-8. + // different than just counting chars as some chars (like emojis, accented letters, etc) take more than 1 byte + const byteCount = Buffer.byteLength(content, "utf8"); + + // update the count + totalLines += lineCount; + totalWords += wordCount; + totalBytes += byteCount; + + printWcOutput(lineCount, wordCount, byteCount, file, options, noFlags); + } catch (err) { + console.error(`Error reading file ${file}: ${err.message}`); + } + } + if (files.length > 1) { + // print the totals as wc does + printWcOutput(totalLines, totalWords, totalBytes, "total", options, noFlags); +} +}