Files

162 lines
5.5 KiB
TypeScript
Raw Permalink Normal View History

/**
* Shared work/ solution picker for the test and submit entries: fuzzy-select
* a solution file, then hand it to leetcode-cli.
*
* Every bucket is listed — a 3-day or 7-day re-solve is tested and submitted
* exactly like a first solve — and the hint says which one a file belongs to.
* The db's per-solution timestamps are keyed by LC number, so the buckets of
* one problem share them.
*
* Every run backfills the db's solutions table with created_at (from the
* file's birthtime, mtime when the filesystem has none) so ordering works for
* files that predate the db. Files git reports as modified, added or
* untracked come first, newest edit first — the problem being worked on is
* the focused option, so Enter runs it without a search. The rest are ordered
* per command — test lists the most recently tested first (never-tested
* after, by name); submit lists the most recently created first. Nothing is
* ever hidden. Exit 0 of leetcode-cli stamps last_tested / last_submitted.
*
* Library: side-effect-free on import — nothing scans the filesystem until
* runOnSolution() is called.
*/
import { autocomplete, cancel, isCancel, log } from "@clack/prompts";
import { statSync } from "node:fs";
import { basename, join, relative } from "node:path";
import { problemDb } from "./db";
import { ROOT, WORK as WORK_DIR, parseWorkPath } from "./work";
/** Leading LC number of a work/ file, or undefined for unnumbered names. */
function lcNumber(rel: string): number | undefined {
const m = basename(rel).match(/^(\d+)\./);
return m ? Number(m[1]) : undefined;
}
/**
* work/-relative paths of solutions git sees as modified, added or untracked,
* mapped to their mtime. Empty outside a git checkout.
*/
function dirtySolutions(scanned: Set<string>): Map<string, number> {
const git = Bun.spawnSync(
["git", "status", "--porcelain", "-z", "--untracked-files=all", "--", WORK_DIR],
{ cwd: ROOT },
);
const dirty = new Map<string, number>();
if (!git.success) return dirty;
const entries = git.stdout.toString().split("\0");
for (let i = 0; i < entries.length; i++) {
const entry = entries[i]!;
// renames/copies carry their source path as the next entry; skip it
if (/[RC]/.test(entry.slice(0, 2))) i++;
const rel = relative(WORK_DIR, join(ROOT, entry.slice(3)));
// deletions and non-solution files are not in the scan
if (scanned.has(rel)) dirty.set(rel, statSync(join(WORK_DIR, rel)).mtimeMs);
}
return dirty;
}
/** Fuzzy-pick a solution under work/ and run `leetcode <command>` on it. */
export async function runOnSolution(
command: "test" | "submit",
message: string,
): Promise<never> {
const db = problemDb();
const scanned = [
...new Bun.Glob("**/*.{js,ts,py,java,c,cpp,go,rs,rb,swift,kt,cs}").scanSync(
{ cwd: WORK_DIR },
),
];
// register creation times so files older than the db still sort correctly
for (const f of scanned) {
const num = lcNumber(f);
if (num === undefined) continue;
const stat = statSync(join(WORK_DIR, f));
db.ensureSolution(
num,
new Date(stat.birthtimeMs || stat.mtimeMs).toISOString(),
);
}
const activity = db.solutionActivity();
// test → most recently tested first; submit → most recently created first
const sortKey = (f: string) => {
const num = lcNumber(f);
const row = num === undefined ? undefined : activity.get(num);
return (command === "test" ? row?.lastTested : row?.createdAt) ?? undefined;
};
const dirty = dirtySolutions(new Set(scanned));
const files = scanned.sort((a, b) => {
const ma = dirty.get(a);
const mb = dirty.get(b);
if (ma !== mb) {
if (ma === undefined) return 1;
if (mb === undefined) return -1;
return mb - ma;
}
const ka = sortKey(a);
const kb = sortKey(b);
if (ka !== kb) {
if (ka === undefined) return 1;
if (kb === undefined) return -1;
return kb.localeCompare(ka); // ISO timestamps: lexicographic = chronological
}
return a.localeCompare(b, undefined, { numeric: true });
});
if (files.length === 0) {
log.error(
`No solution files found in ${relative(process.cwd(), WORK_DIR)}/`,
);
process.exit(1);
}
const picked = await autocomplete<string>({
message,
placeholder: "Type to search...",
maxItems: 12,
options: files.map((f) => {
const parsed = parseWorkPath(f);
const stage =
parsed?.bucket === 1 ? "first solve" : `${parsed?.bucket}-day review`;
return {
value: f,
label: basename(f),
hint: parsed
? `${dirty.has(f) ? "uncommitted · " : ""}${stage} · ${parsed.difficulty} · ${parsed.category}`
: f,
};
}),
filter: (search, option) => {
const haystack = option.value.toLowerCase();
return search
.toLowerCase()
.split(/\s+/)
.every((token) => haystack.includes(token));
},
});
if (isCancel(picked)) {
cancel("Nothing selected.");
process.exit(0);
}
const proc = Bun.spawn(
[
// bun installs workspace-dep bins into this package's node_modules, not the root's
join(import.meta.dir, "node_modules", ".bin", "leetcode"),
command,
join(WORK_DIR, picked),
],
{ cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] },
);
const code = await proc.exited;
const num = lcNumber(picked);
if (code === 0 && num !== undefined) {
if (command === "test") db.markTested(num);
else db.markSubmitted(num);
}
db.close();
process.exit(code);
}