diff --git a/implement-shell-tools/cat/cat.js b/implement-shell-tools/cat/cat.js new file mode 100644 index 000000000..2c8159255 --- /dev/null +++ b/implement-shell-tools/cat/cat.js @@ -0,0 +1,67 @@ +import { program } from "commander"; +import { promises as fs } from "node:fs"; + +program + .name("cat") + .description("Concatenate and print files") + .argument("", "The file paths to process") + .option("-n", "Number lines") + .option("-b", "Number non-blank lines"); + +program.parse(); + +function parseNumberMode(options) { + if (options.b) { + return "non-blank"; + } else if (options.n) { + return "all"; + } else { + return "none"; + } +} + +function calculatePrefix( + numberMode, + nonBlankLineNumber, + lineNumberIncludingBlanks, + thisLineIsBlank, +) { + if (numberMode === "none") { + return ""; + } + let lineNumber; + if (numberMode === "all") { + lineNumber = lineNumberIncludingBlanks; + } else { + if (thisLineIsBlank) { + return ""; + } else { + lineNumber = nonBlankLineNumber; + } + } + return lineNumber.toString().padStart(6, " ") + " "; +} + +const numberMode = parseNumberMode(program.opts()); + +for (const path of program.args) { + const content = await fs.readFile(path, "utf-8"); + const lines = content.split("\n"); + let nonBlankLineNumber = 1; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (i === lines.length - 1 && line === "") { + break; + } + const prefix = calculatePrefix( + numberMode, + nonBlankLineNumber, + i + 1, + line === "", + ); + console.log(`${prefix}${line}`); + if (line !== "") { + nonBlankLineNumber += 1; + } + } +} diff --git a/implement-shell-tools/cat/package-lock.json b/implement-shell-tools/cat/package-lock.json new file mode 100644 index 000000000..5cc805d7d --- /dev/null +++ b/implement-shell-tools/cat/package-lock.json @@ -0,0 +1,21 @@ +{ + "name": "cat", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "commander": "^15.0.0" + } + }, + "node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + } + } +} diff --git a/implement-shell-tools/cat/package.json b/implement-shell-tools/cat/package.json new file mode 100644 index 000000000..cddd041aa --- /dev/null +++ b/implement-shell-tools/cat/package.json @@ -0,0 +1,6 @@ +{ + "type": "module", + "dependencies": { + "commander": "^15.0.0" + } +} diff --git a/implement-shell-tools/ls/ls.js b/implement-shell-tools/ls/ls.js new file mode 100644 index 000000000..5b9f861b6 --- /dev/null +++ b/implement-shell-tools/ls/ls.js @@ -0,0 +1,47 @@ +import { promises as fs } from "node:fs"; +import process from "node:process"; + +const argv = process.argv.slice(2); + +const flags = []; +const paths = []; + +for (const arg of argv) { + if (arg.startsWith("-")) { + flags.push(arg); + } else { + paths.push(arg); + } +} + +const showOnePerLine = flags.includes("-1"); +const showHiddenFiles = flags.includes("-a"); + +let targets = paths; +if (targets.length === 0) { + targets = ["."]; +} + +for (const target of targets) { + const info = await fs.stat(target); + + let namesToShow; + + if (info.isDirectory()) { + namesToShow = await fs.readdir(target); + + if (showHiddenFiles) { + namesToShow = [".", "..", ...namesToShow]; + } else { + namesToShow = namesToShow.filter((name) => !name.startsWith(".")); + } + + namesToShow.sort(); + } else { + namesToShow = [target]; + } + + for (const name of namesToShow) { + console.log(name); + } +} diff --git a/implement-shell-tools/ls/package-lock.json b/implement-shell-tools/ls/package-lock.json new file mode 100644 index 000000000..09674c718 --- /dev/null +++ b/implement-shell-tools/ls/package-lock.json @@ -0,0 +1,21 @@ +{ + "name": "ls", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "commander": "^15.0.0" + } + }, + "node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + } + } +} diff --git a/implement-shell-tools/ls/package.json b/implement-shell-tools/ls/package.json new file mode 100644 index 000000000..cddd041aa --- /dev/null +++ b/implement-shell-tools/ls/package.json @@ -0,0 +1,6 @@ +{ + "type": "module", + "dependencies": { + "commander": "^15.0.0" + } +} diff --git a/implement-shell-tools/wc/package-lock.json b/implement-shell-tools/wc/package-lock.json new file mode 100644 index 000000000..116f3635e --- /dev/null +++ b/implement-shell-tools/wc/package-lock.json @@ -0,0 +1,21 @@ +{ + "name": "wc", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "commander": "^15.0.0" + } + }, + "node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + } + } +} diff --git a/implement-shell-tools/wc/package.json b/implement-shell-tools/wc/package.json new file mode 100644 index 000000000..cddd041aa --- /dev/null +++ b/implement-shell-tools/wc/package.json @@ -0,0 +1,6 @@ +{ + "type": "module", + "dependencies": { + "commander": "^15.0.0" + } +} diff --git a/implement-shell-tools/wc/wc.js b/implement-shell-tools/wc/wc.js new file mode 100644 index 000000000..0ccacfeaa --- /dev/null +++ b/implement-shell-tools/wc/wc.js @@ -0,0 +1,70 @@ +import { program } from "commander"; +import { promises as fs } from "node:fs"; + +const argv = process.argv.slice(2); + +const flags = argv.filter((arg) => arg.startsWith("-")); +const paths = argv.filter((arg) => !arg.startsWith("-")); + +const showLines = flags.includes("-l"); +const showWords = flags.includes("-w"); +const showBytes = flags.includes("-c"); + +const noFlagsGiven = !showLines && !showWords && !showBytes; + +const columns = []; +if (noFlagsGiven || showLines) columns.push("lines"); +if (noFlagsGiven || showWords) columns.push("words"); +if (noFlagsGiven || showBytes) columns.push("bytes"); + +function countStats(content) { + const lines = (content.match(/\n/g) || []).length; + const words = content.split(/\s+/).filter((w) => w.length > 0).length; + const bytes = Buffer.byteLength(content, "utf-8"); + return { lines, words, bytes }; +} + +// Read every file and collect its stats +const rows = []; +for (const path of paths) { + const content = await fs.readFile(path, "utf-8"); + rows.push({ path, stats: countStats(content) }); +} + +// If there's more than one file, we also need a "total" row at the end +if (rows.length > 1) { + const total = { lines: 0, words: 0, bytes: 0 }; + for (const { stats } of rows) { + total.lines += stats.lines; + total.words += stats.words; + total.bytes += stats.bytes; + } + rows.push({ path: "total", stats: total }); +} + +const needsAlignment = columns.length > 1 || rows.length > 1; + +let width = 0; +if (needsAlignment) { + for (const { stats } of rows) { + for (const col of columns) { + width = Math.max(width, String(stats[col]).length); + } + } + + width = Math.max(width, 3); +} + +for (const { path, stats } of rows) { + const parts = columns.map((col, index) => { + const text = String(stats[col]); + if (!needsAlignment) { + return text; + } + const padded = text.padStart(width, " "); + + return index === 0 ? padded : " " + padded; + }); + + console.log(`${parts.join("")} ${path}`); +}