diff --git a/implement-shell-tools/cat/cat.js b/implement-shell-tools/cat/cat.js new file mode 100644 index 00000000..d359e331 --- /dev/null +++ b/implement-shell-tools/cat/cat.js @@ -0,0 +1,88 @@ +#!/usr/bin/env node +// cat.js — ESM: supports -n and -b (-b overrides -n) + +import fs from "node:fs"; +import readline from "node:readline"; + +const args = process.argv.slice(2); +let numberAll = false; +let numberNonblank = false; +const files = []; + +// parse flags + files (flags can appear anywhere) +for (const a of args) { + if (a === "-n") numberAll = true; + else if (a === "-b") numberNonblank = true; + else files.push(a); +} +// precedence: -b wins over -n +if (numberNonblank) numberAll = false; + +if (files.length === 0) { + console.error("Usage: node cat.js [-n|-b] "); + process.exit(1); +} + +let hadError = false; +let lineNo = 1; + +for (const file of files) { + try { + const stat = await fs.promises.stat(file); + if (stat.isDirectory()) { + console.error(`cat: ${file}: Is a directory`); + hadError = true; + continue; + } + if (!numberAll && !numberNonblank) { + await pipeFile(file); + } else { + await numberFile(file, { nonblank: numberNonblank }); + } + } catch (err) { + if (err?.code === "ENOENT" || err?.code === "ENOTDIR") { + console.error(`cat: ${file}: No such file or directory`); + } else { + console.error(`cat: ${file}: ${err?.message || "Error"}`); + } + hadError = true; + } +} + +if (hadError) process.exitCode = 1; + +function pipeFile(file) { + return new Promise((resolve, reject) => { + const rs = fs.createReadStream(file); + rs.on("error", reject); + rs.on("end", resolve); + rs.pipe(process.stdout, { end: false }); // keep stdout open between files + }); +} + +function numberFile(file, { nonblank }) { + return new Promise((resolve, reject) => { + const rs = fs.createReadStream(file); + const rl = readline.createInterface({ input: rs, crlfDelay: Infinity }); + + rl.on("line", (line) => { + if (nonblank) { + if (line.length === 0) { + process.stdout.write("\n"); // blank line, no number + } else { + process.stdout.write(formatNum(lineNo++) + line + "\n"); + } + } else { + process.stdout.write(formatNum(lineNo++) + line + "\n"); + } + }); + rl.on("close", resolve); + rl.on("error", reject); + rs.on("error", reject); + }); +} + +function formatNum(n) { + // GNU cat style: width 6, right-aligned, then a tab + return String(n).padStart(6, " ") + "\t"; +} diff --git a/implement-shell-tools/ls/ls.js b/implement-shell-tools/ls/ls.js new file mode 100644 index 00000000..b5ba856a --- /dev/null +++ b/implement-shell-tools/ls/ls.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node + + +import fs from "node:fs"; +import { pathToFileURL } from "node:url"; + +const raw = process.argv.slice(2); + +let includeAll = false; // -a +let onePerLine = false; // -1 (format already one-per-line) +const targets = []; + +// Parse flags (supports combined like -a1 / -1a). Treat lone "-" as a path. +for (const arg of raw) { + if (arg === "-" || !arg.startsWith("-")) { + targets.push(arg); + continue; + } + if (arg === "-1") { + onePerLine = true; + continue; + } + for (const ch of arg.slice(1)) { + if (ch === "a") includeAll = true; + else if (ch === "1") onePerLine = true; + else { + // ignore unknown short flags + } + } +} + +const paths = targets.length ? targets : ["."]; +let hadError = false; + +for (let i = 0; i < paths.length; i++) { + const p = paths[i]; + try { + const st = await fs.promises.lstat(p); + + if (st.isDirectory()) { + if (paths.length > 1) console.log(`${p}:`); + + const entries = await fs.promises.readdir(p, { withFileTypes: true }); + let names = entries.map(d => d.name); + + if (!includeAll) { + names = names.filter(n => !n.startsWith(".")); + } else { + // mimic `ls -a` by including "." and ".." + names = ["." , "..", ...names]; + } + + names.sort((a, b) => a.localeCompare(b)); + for (const name of names) { + // one-per-line output; -1 flag just affirms it + console.log(name); + } + + if (paths.length > 1 && i !== paths.length - 1) console.log(""); + } else { + // file or symlink => print the argument as given + console.log(p); + } + } catch (err) { + if (err?.code === "ENOENT") { + console.error(`ls: cannot access '${p}': No such file or directory`); + } else if (err?.code === "EACCES") { + console.error(`ls: cannot open directory '${p}': Permission denied`); + } else { + console.error(`ls: ${p}: ${err?.message || "Error"}`); + } + hadError = true; + } +} + +if (hadError) process.exitCode = 1; + +// run only when executed directly +const isDirect = import.meta.url === pathToFileURL(process.argv[1]).href; +if (!isDirect) { + // allow importing in tests without auto-executing +} diff --git a/implement-shell-tools/package.json b/implement-shell-tools/package.json new file mode 100644 index 00000000..8740af3e --- /dev/null +++ b/implement-shell-tools/package.json @@ -0,0 +1,12 @@ +{ + "type": "module", + "name": "implement-shell-tools-with-node", + "version": "1.0.0", + "description": "Your task is to re-implement shell tools you have used.", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "pezhman azizi", + "license": "ISC" +} diff --git a/implement-shell-tools/wc/wc.js b/implement-shell-tools/wc/wc.js new file mode 100644 index 00000000..5067e0d2 --- /dev/null +++ b/implement-shell-tools/wc/wc.js @@ -0,0 +1,115 @@ +#!/usr/bin/env node +// wc.js — ESM: supports flags -l (lines), -w (words), -c (bytes) +// Behaviors covered by the README: +// - wc sample-files/* +// - wc -l sample-files/3.txt +// - wc -w sample-files/3.txt +// - wc -c sample-files/3.txt +// - wc -l sample-files/* +// Also works with multiple flags (e.g., -lw), like GNU wc. + +import fs from "node:fs"; +import { pathToFileURL } from "node:url"; + +async function main() { + const argv = process.argv.slice(2); + + let showLines = false; + let showWords = false; + let showBytes = false; + const files = []; + + // Parse flags + files. Support combined short flags like -lw, -cl, etc. + for (const arg of argv) { + if (arg.startsWith("-") && arg !== "-") { + for (const ch of arg.slice(1)) { + if (ch === "l") showLines = true; + else if (ch === "w") showWords = true; + else if (ch === "c") showBytes = true; + else { + // ignore unknown short options for this assignment + } + } + } else { + files.push(arg); + } + } + + if (files.length === 0) { + console.error("Usage: node wc.js [-l|-w|-c] "); + process.exit(1); + } + + // No flags → show all three like wc + const showAll = !showLines && !showWords && !showBytes; + + let hadError = false; + let total = { lines: 0, words: 0, bytes: 0 }; + const results = []; + + for (const file of files) { + try { + const st = await fs.promises.lstat(file); + if (st.isDirectory()) { + console.error(`wc: ${file}: Is a directory`); + hadError = true; + continue; + } + const counts = await countFile(file); + results.push({ file, ...counts }); + total.lines += counts.lines; + total.words += counts.words; + total.bytes += counts.bytes; + } catch (err) { + if (err?.code === "ENOENT") { + console.error(`wc: ${file}: No such file or directory`); + } else if (err?.code === "EACCES") { + console.error(`wc: ${file}: Permission denied`); + } else { + console.error(`wc: ${file}: ${err?.message || "Error"}`); + } + hadError = true; + } + } + + for (const r of results) { + console.log(formatRow(r, { showAll, showLines, showWords, showBytes })); + } + if (results.length > 1) { + console.log(formatRow({ file: "total", ...total }, { showAll, showLines, showWords, showBytes })); + } + + if (hadError) process.exitCode = 1; +} + +async function countFile(file) { + const buf = await fs.promises.readFile(file); // Buffer + const bytes = buf.length; + + // Lines: count '\n' bytes + let lines = 0; + for (let i = 0; i < buf.length; i++) if (buf[i] === 0x0a) lines++; + + // Words: sequences of non-whitespace + const text = buf.toString("utf8"); + const words = (text.match(/\S+/g) || []).length; + + return { lines, words, bytes }; +} + +function formatRow({ lines, words, bytes, file }, opts) { + const cols = []; + if (opts.showAll || opts.showLines) cols.push(pad(lines)); + if (opts.showAll || opts.showWords) cols.push(pad(words)); + if (opts.showAll || opts.showBytes) cols.push(pad(bytes)); + return `${cols.join(" ")} ${file}`; +} + +function pad(n) { + // Right-align like `wc` + return String(n).padStart(7, " "); +} + +// run only when executed directly +const isDirect = import.meta.url === pathToFileURL(process.argv[1]).href; +if (isDirect) await main();