diff --git a/implement-shell-tools/cat/cat.js b/implement-shell-tools/cat/cat.js new file mode 100644 index 000000000..6ea795ad9 --- /dev/null +++ b/implement-shell-tools/cat/cat.js @@ -0,0 +1,64 @@ +import { program } from "commander"; +import { promises as fs } from "node:fs"; + +program + .name("cat") + .description("concatenate and print files") + .option("-n, --number", "number all output lines") + .option("-b, --number-nonblank", "number nonempty output lines") + .argument("", "the file paths to process"); + +program.parse(); + +const options = program.opts(); +const paths = program.args; + +const numberAll = options.number; +const numberNonBlank = options.numberNonblank; + +const shouldNumberAll = numberNonBlank ? false : numberAll; + +let lineNumber = 1; +let nonBlankNumber = 1; + +for (const filePath of paths) { + try { + const content = await fs.readFile(filePath, "utf8"); + process.stdout.write(formatContent(content)); + } catch (error) { + process.exitCode = 1; + process.stderr.write(`cat: ${filePath}: ${error.message}\n`); + } +} + +function formatContent(text) { + const normalized = text.replace(/\r\n/g, "\n"); + const endsWithNewline = normalized.endsWith("\n"); + const lines = endsWithNewline + ? normalized.slice(0, -1).split("\n") + : normalized.split("\n"); + + const output = []; + + for (const line of lines) { + if (numberNonBlank) { + if (line === "") { + output.push(""); + } else { + output.push(`${String(nonBlankNumber++).padStart(6, " ")}\t${line}`); + } + } else if (shouldNumberAll) { + output.push(`${String(lineNumber++).padStart(6, " ")}\t${line}`); + } else { + output.push(line); + } + } + + let result = output.join("\n"); + + if (endsWithNewline) { + result += "\n"; + } + + return result; +} diff --git a/implement-shell-tools/ls/README.md b/implement-shell-tools/ls/README.md index edbfb811a..d6355d0e5 100644 --- a/implement-shell-tools/ls/README.md +++ b/implement-shell-tools/ls/README.md @@ -6,9 +6,9 @@ Your task is to implement your own version of `ls`. It must act the same as `ls` would, if run from the directory containing this README.md file, for the following command lines: -* `ls -1` -* `ls -1 sample-files` -* `ls -1 -a sample-files` +- `ls -1` +- `ls -1 sample-files` +- `ls -1 -a sample-files` Matching any additional behaviours or flags are optional stretch goals. diff --git a/implement-shell-tools/ls/ls.js b/implement-shell-tools/ls/ls.js new file mode 100644 index 000000000..4490ebd0a --- /dev/null +++ b/implement-shell-tools/ls/ls.js @@ -0,0 +1,68 @@ +import { promises as fs } from "node:fs"; +import { program } from "commander"; + +program + .name("list files and directories") + .option("-a", "show hidden files") + .option("-1", "force displaying each item in a new line") + .argument("[paths...]", "path of directory"); + +program.parse(); + +const showHiddenFiles = program.opts()["a"]; +const showFilesInLines = program.opts()["1"]; +const paths = program.args.length ? program.args : ["."]; + +const fetchedDirectories = await fetchDirectoriesFunc(paths); + +console.log(formatDisplay(fetchedDirectories)); + +function formatDisplay(fetchedDirectories) { + const controlDisplaying = fetchedDirectories.map((directoryFilesInArray) => { + const joiner = showFilesInLines ? `\n\r` : ` `; + const showFolderName = + fetchedDirectories.length > 1 + ? `${directoryFilesInArray.folderName}:\n\r` + : ""; + + return `${showFolderName}${directoryFilesInArray.files.join(joiner)}`; + }); + return controlDisplaying.join("\n\r\n\r"); +} + +// returns array of objects, like: [{folderName: [file1, file2]}] +async function fetchDirectoriesFunc(directories) { + const result = []; + + for (const folderName of directories) { + let files = await fs.readdir(folderName); + + // sort by name + but those starting with . at the end + files.sort((a, b) => { + const isHiddenA = a.startsWith("."); + const isHiddenB = b.startsWith("."); + + if (isHiddenA !== isHiddenB) { + return isHiddenA ? 1 : -1; + } + + const cleanA = a.replace(/^\.+/, ""); + const cleanB = b.replace(/^\.+/, ""); + + return cleanA.localeCompare(cleanB); + }); + + if (showHiddenFiles) { + files.unshift(".", ".."); + } else { + files = files.filter((fileName) => !fileName.startsWith(".")); + } + + result.push({ + folderName, + files, + }); + } + + return result; +} diff --git a/implement-shell-tools/package-lock.json b/implement-shell-tools/package-lock.json new file mode 100644 index 000000000..8cd4b3de8 --- /dev/null +++ b/implement-shell-tools/package-lock.json @@ -0,0 +1,21 @@ +{ + "name": "implement-shell-tools", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "commander": "^14.0.3" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + } + } +} diff --git a/implement-shell-tools/package.json b/implement-shell-tools/package.json new file mode 100644 index 000000000..402b2a4e9 --- /dev/null +++ b/implement-shell-tools/package.json @@ -0,0 +1,6 @@ +{ + "dependencies": { + "commander": "^14.0.3" + }, + "type": "module" +} diff --git a/implement-shell-tools/wc/wc.js b/implement-shell-tools/wc/wc.js new file mode 100644 index 000000000..d2b5086b2 --- /dev/null +++ b/implement-shell-tools/wc/wc.js @@ -0,0 +1,82 @@ +import { promises as fs } from "node:fs"; +import { program } from "commander"; + +program + .name("print newline, word, and byte counts for each file") + .option("-l", "count lines") + .option("-w", "count words") + .option("-c", "count bytes") + .argument("", "file name"); + +program.parse(); + +const opts = program.opts(); +const flags = Object.keys(opts); +const columns = flags.length > 0 ? flags : ["l", "w", "c"]; +const paths = program.args; + +async function getFilesData(paths) { + let output = []; + + for (const filename of paths) { + try { + const file = await fs.stat(filename); + + if (file.isFile()) { + const fileContent = await fs.readFile(filename, "utf-8"); + const lineCount = (fileContent.match(/\n/g) || []).length; + const wordCount = fileContent + .trim() + .split(/\s+/) + .filter(Boolean).length; + const fileSize = file.size; + + output.push([filename, lineCount, wordCount, fileSize]); + } else { + output.push([filename, `wc: ${filename}: Is a directory`]); + } + } catch (err) { + output.push([filename, `wc: ${filename} ${err.message}`]); + } + } + + return output; +} + +function displayWcOutput(output, columns) { + const results = output.filter((entry) => entry.length === 4); + + const totalLines = results.reduce((sum, e) => sum + e[1], 0); + const totalWords = results.reduce((sum, e) => sum + e[2], 0); + const totalBytes = results.reduce((sum, e) => sum + e[3], 0); + + const allRows = + results.length > 1 + ? [...results, ["total", totalLines, totalWords, totalBytes]] + : results; + + const colMap = { l: 1, w: 2, c: 3 }; + const w = columns.map((col) => + Math.max(...allRows.map((e) => String(e[colMap[col]]).length)), + ); + + const formatRow = (entry) => + columns + .map((col, i) => String(entry[colMap[col]]).padStart(w[i])) + .join(" "); + + for (const entry of output) { + if (entry.length === 2) { + console.log(entry[1]); + } else { + console.log(`${formatRow(entry)} ${entry[0]}`); + } + } + + if (results.length > 1) { + const totalsRow = ["total", totalLines, totalWords, totalBytes]; + console.log(`${formatRow(totalsRow)} total`); + } +} + +displayWcOutput(await getFilesData(paths), columns);