diff --git a/implement-shell-tools/cat/cat.mjs b/implement-shell-tools/cat/cat.mjs new file mode 100644 index 000000000..e11c51f89 --- /dev/null +++ b/implement-shell-tools/cat/cat.mjs @@ -0,0 +1,58 @@ +import process from "node:process"; +import { promises as fs } from "node:fs"; +import { parseArgs } from "node:util"; + +const { values, positionals } = parseArgs({ + options: { + number: { type: "boolean", short: "n", default: false }, + "number-nonblank": { type: "boolean", short: "b", default: false }, + }, + allowPositionals: true, +}); + +const numberAll = values.number; +const numberNonBlank = values["number-nonblank"]; + +if (positionals.length === 0) { + console.error("Usage: node cat.js [-n] [-b] ..."); + process.exit(1); +} + +let lineNumber = 1; + +for (const path of positionals) { + let content; + try { + content = await fs.readFile(path, "utf-8"); + } catch { + console.error(`cat: ${path}: No such file or directory`); + process.exitCode = 1; + continue; + } + + if (!numberAll && !numberNonBlank) { + process.stdout.write(content); + continue; + } + + const lines = content.split("\n"); + const endsWithNewline = lines[lines.length - 1] === ""; + if (endsWithNewline) { + lines.pop(); + } + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const isLastLine = i === lines.length - 1; + const lineEnding = !isLastLine || endsWithNewline ? "\n" : ""; + + if (numberNonBlank && line === "") { + process.stdout.write(lineEnding); + } else { + process.stdout.write( + `${String(lineNumber).padStart(6)}\t${line}${lineEnding}`, + ); + lineNumber++; + } + } +} diff --git a/implement-shell-tools/ls/ls.mjs b/implement-shell-tools/ls/ls.mjs new file mode 100644 index 000000000..8cba509ed --- /dev/null +++ b/implement-shell-tools/ls/ls.mjs @@ -0,0 +1,74 @@ +import process from "node:process"; +import { promises as fs } from "node:fs"; +import { parseArgs } from "node:util"; + +const { values, positionals } = parseArgs({ + options: { + one: { type: "boolean", short: "1", default: false }, + all: { type: "boolean", short: "a", default: false }, + }, + allowPositionals: true, +}); + +const showAll = values.all; +const paths = positionals.length > 0 ? positionals : ["."]; + +function stripPunctuation(name) { + return name.replace(/[^\p{L}\p{N}]/gu, ""); +} + +function compareNames(a, b) { + const result = stripPunctuation(a).localeCompare(stripPunctuation(b)); + return result !== 0 ? result : a.localeCompare(b); +} + +const files = []; +const directories = []; + +for (const path of paths) { + try { + const stats = await fs.stat(path); + if (stats.isDirectory()) { + directories.push(path); + } else { + files.push(path); + } + } catch { + console.error(`ls: cannot access '${path}': No such file or directory`); + process.exitCode = 2; + } +} + +files.sort(compareNames); +directories.sort(compareNames); + +const needHeaders = directories.length > 1 || files.length > 0; +let printedSomething = false; + +for (const file of files) { + console.log(file); + printedSomething = true; +} + +for (const dir of directories) { + let entries = await fs.readdir(dir); + + if (showAll) { + entries = [".", "..", ...entries]; + } else { + entries = entries.filter((entry) => !entry.startsWith(".")); + } + entries.sort(compareNames); + + if (needHeaders) { + if (printedSomething) { + console.log(""); + } + console.log(`${dir}:`); + } + + for (const entry of entries) { + console.log(entry); + } + printedSomething = true; +} diff --git a/implement-shell-tools/package.json b/implement-shell-tools/package.json new file mode 100644 index 000000000..3dbc1ca59 --- /dev/null +++ b/implement-shell-tools/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/implement-shell-tools/wc/wc.mjs b/implement-shell-tools/wc/wc.mjs new file mode 100644 index 000000000..64c42bd75 --- /dev/null +++ b/implement-shell-tools/wc/wc.mjs @@ -0,0 +1,87 @@ +import process from "node:process"; +import { promises as fs } from "node:fs"; +import { parseArgs } from "node:util"; + +const { values, positionals } = parseArgs({ + options: { + lines: { type: "boolean", short: "l", default: false }, + words: { type: "boolean", short: "w", default: false }, + bytes: { type: "boolean", short: "c", default: false }, + }, + allowPositionals: true, +}); + +const noFlags = !values.lines && !values.words && !values.bytes; +const showLines = noFlags || values.lines; +const showWords = noFlags || values.words; +const showBytes = noFlags || values.bytes; + +if (positionals.length === 0) { + console.error("Usage: node wc.mjs [-l] [-w] [-c] ..."); + process.exit(1); +} + +function countLines(content) { + let count = 0; + for (const character of content) { + if (character === "\n") { + count++; + } + } + return count; +} + +function countWords(content) { + return content.split(/\s+/).filter((word) => word !== "").length; +} + +const results = []; +const totals = { lines: 0, words: 0, bytes: 0 }; + +for (const path of positionals) { + let buffer; + try { + buffer = await fs.readFile(path); + } catch { + console.error(`wc: ${path}: No such file or directory`); + process.exitCode = 1; + continue; + } + + const content = buffer.toString("utf-8"); + const counts = { + lines: countLines(content), + words: countWords(content), + bytes: buffer.length, + name: path, + }; + + results.push(counts); + totals.lines += counts.lines; + totals.words += counts.words; + totals.bytes += counts.bytes; +} + +if (results.length > 1) { + results.push({ ...totals, name: "total" }); +} + +const selectedCounts = [showLines, showWords, showBytes].filter(Boolean).length; +const skipPadding = selectedCounts === 1 && results.length === 1; + +let width = 1; +if (!skipPadding) { + for (const result of results) { + for (const key of ["lines", "words", "bytes"]) { + width = Math.max(width, String(result[key]).length); + } + } +} + +for (const result of results) { + const columns = []; + if (showLines) columns.push(String(result.lines).padStart(width)); + if (showWords) columns.push(String(result.words).padStart(width)); + if (showBytes) columns.push(String(result.bytes).padStart(width)); + console.log(`${columns.join(" ")} ${result.name}`); +}