Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,19 @@ on:
jobs:
build-and-test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x, 18.x]
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Use Node.js ${{ matrix.node-version }}
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
node-version: 20

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
run_install: false

- name: Install
run: pnpm i
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:

- uses: pnpm/action-setup@v4
with:
version: 9
run_install: false

- run: pnpm i
- run: pnpm build
Expand Down
21 changes: 16 additions & 5 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,24 @@ function parseArgs(args: string[]): Record<string, string | boolean> {
const k = a.slice(2);
const next = args[i + 1];
if (!next || next.startsWith("--")) out[k] = true;
else { out[k] = next; i++; }
else {
out[k] = next;
i++;
}
}
}
return out;
}

async function readInput(fromFile?: string): Promise<string> {
if (fromFile) return await fs.readFile(fromFile, "utf8");
if (fromFile) {
const buf = await fs.readFile(fromFile);
return buf.toString("utf8");
}
const chunks: string[] = [];
return await new Promise((resolve, reject) => {
process.stdin.setEncoding("utf8");
process.stdin.on("data", c => chunks.push(c));
process.stdin.on("data", (c) => chunks.push(String(c)));
process.stdin.on("end", () => resolve(chunks.join("")));
process.stdin.on("error", reject);
});
Expand Down Expand Up @@ -53,7 +59,10 @@ Example:

(async function main() {
const argv = parseArgs(process.argv.slice(2));
if (argv.help) { help(); process.exit(0); }
if (argv.help) {
help();
process.exit(0);
}

const rootDir = path.resolve(String(argv.root ?? "."));
const inferRoot = Boolean(argv["infer-root"]);
Expand All @@ -71,7 +80,9 @@ Example:
try {
const { plan, baseRoot } = parseToPlan(input, { rootDir, inferRoot });
await executePlan(plan, { dryRun, verbose, gitkeep });
const msg = dryRun ? "Dry run complete. Nothing was created." : `Structure created at: ${baseRoot}`;
const msg = dryRun
? "Dry run complete. Nothing was created."
: `Structure created at: ${baseRoot}`;
process.stdout.write(msg + "\n");
} catch (e: any) {
process.stderr.write(`[error] ${e?.message ?? String(e)}\n`);
Expand Down
94 changes: 81 additions & 13 deletions src/parser.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import path from "node:path";
import { PlanItem, ParseOptions } from "./types.js";
import { computeDepth, hasExtension, isBareKnownFile, looksLikeDirToken, safeJoin, splitPrefixContent, stripBullets, stripTrailingComments } from "./utils.js";
import {
hasExtension,
isBareKnownFile,
looksLikeDirToken,
safeJoin,
splitPrefixContent,
stripBullets,
stripTrailingComments,
} from "./utils.js";

export interface ParseResult {
plan: PlanItem[];
Expand All @@ -9,37 +17,50 @@ export interface ParseResult {

export function parseToPlan(input: string, opts: ParseOptions): ParseResult {
const rootDir = path.resolve(opts.rootDir);
let lines = input.split(/\r?\n/)
.map(l => l.replace(/\t/g, " "))
.map(l => l.replace(/\s+$/, ""))
.filter(l => l.trim().length > 0);

let lines = input
.split(/\r?\n/)
.map((l) => l.replace(/\t/g, " "))
.map((l) => l.replace(/\s+$/, ""))
.filter((l) => l.trim().length > 0);

// infer base root from a first-line token like "project/"
let baseRoot = rootDir;
if (opts.inferRoot) {
const firstContent = sanitizeContent(lines[0]);
if (!firstContent.includes("/") && firstContent.endsWith("/")) {
baseRoot = safeJoin(rootDir, firstContent.slice(0, -1));
lines = lines.slice(1);
if (opts.inferRoot && lines.length > 0) {
const first = sanitizeContent(lines[0]);
if (first.endsWith("/")) {
const token = first.replace(/\/+$/, "");
if (token && !token.includes("/")) {
baseRoot = safeJoin(rootDir, token);
lines = lines.slice(1);
}
}
}

const plan: PlanItem[] = [];
const seen = new Set<string>();

// directory stack by depth
const stack: string[] = [];
stack[0] = baseRoot;

// ensure baseRoot is part of the plan
addDir(baseRoot);

for (const raw of lines) {
const { prefix, content: unclean } = splitPrefixContent(raw);
const depth = computeDepth(prefix);
const content0 = sanitizeContent(unclean);
if (!content0) continue;

// "/foo/bar" relative to baseRoot
if (content0.startsWith("/")) {
const rel = content0.replace(/^\/+/, "");
addAny(rel, 0);
continue;
}

// token with slashes
if (content0.includes("/")) {
const isDir = content0.endsWith("/");
const rel = isDir ? content0.slice(0, -1) : content0;
Expand All @@ -57,6 +78,7 @@ export function parseToPlan(input: string, opts: ParseOptions): ParseResult {
continue;
}

// bare token
if (looksLikeDirToken(content0)) {
const parent = stack[depth] ?? baseRoot;
const abs = safeJoin(parent, content0.replace(/\/$/, ""));
Expand All @@ -75,16 +97,39 @@ export function parseToPlan(input: string, opts: ParseOptions): ParseResult {
function sanitizeContent(s: string): string {
let out = s.trim();
out = stripBullets(out);
out = out.replace(/^(?:[│├└]?[─\-])\s*/, "");
// drop a leading branch marker like "├─ " or "└─ " or "- "
out = out.replace(/^(?:[│├└]\s*)?[─-]\s*/u, "");
out = stripTrailingComments(out);
out = out.trim();
out = out.replace(/\/\s+$/, "/");
out = out.replace(/\s+\((?:opcional|optional)[^)]*\)\s*$/i, "");
return out;
}

function computeDepth(prefix: string): number {
// remove the trailing branch marker (├─ or └─) so it doesn't count as indent
let p = prefix.replace(/[├└]─\s*$/u, "");
// if box pipes exist, depth equals number of pipes
const pipes = (p.match(/│/g) || []).length;
if (pipes > 0) return pipes;
// otherwise, count groups of two spaces
p = p.replace(/[├└─]/g, " ").replace(/\t/g, " ");
let groups = 0, acc = 0;
for (const ch of p) {
if (ch === " ") {
acc++;
if (acc === 2) { groups++; acc = 0; }
} else {
acc = 0;
}
}
return groups;
}

function addAny(rel: string, depth: number) {
const isDir = rel.endsWith("/") || (!hasExtension(path.basename(rel)) && !isBareKnownFile(path.basename(rel)));
const isDir =
rel.endsWith("/") ||
(!hasExtension(path.basename(rel)) && !isBareKnownFile(path.basename(rel)));
const abs = safeJoin(baseRoot, rel.replace(/\/$/, ""));
if (isDir) {
addDir(abs);
Expand All @@ -98,12 +143,28 @@ export function parseToPlan(input: string, opts: ParseOptions): ParseResult {
}

function addDir(abs: string) {
// include all ancestor directories under baseRoot
const rel = path.relative(baseRoot, abs);
if (rel && rel !== ".") {
const parts = rel.split(path.sep).filter(Boolean);
let cur = baseRoot;
for (const seg of parts) {
cur = safeJoin(cur, seg);
pushDir(cur);
}
} else {
pushDir(baseRoot);
}
}

function pushDir(abs: string) {
const key = `d:${abs}`;
if (!seen.has(key)) {
seen.add(key);
plan.push({ kind: "dir", absPath: abs });
}
}

function addFile(abs: string) {
const key = `f:${abs}`;
if (!seen.has(key)) {
Expand All @@ -112,6 +173,13 @@ export function parseToPlan(input: string, opts: ParseOptions): ParseResult {
}
}

plan.sort((a, b) => a.kind === b.kind ? a.absPath.localeCompare(b.absPath) : (a.kind === "dir" ? -1 : 1));
plan.sort((a, b) =>
a.kind === b.kind
? a.absPath.localeCompare(b.absPath)
: a.kind === "dir"
? -1
: 1
);

return { plan, baseRoot };
}