// The ROM scanner (design §5): walk a ROM root, apply excludes, and reduce the file tree to launchable // candidates — folding multi-disc `.m3u` playlists and `.cue`/`.gdi` sheets so a disc set is ONE entry, // not one per track. The filesystem is injected (`ScanFs`) so the whole thing is unit-testable against // a synthetic tree with zero real I/O. import * as fs from "node:fs"; import * as nodePath from "node:path"; import type { Platform } from "./platforms.js"; /** A directory entry the walker sees. */ export interface DirEnt { name: string; isDir: boolean; isFile: boolean; } /** The filesystem surface the scanner needs — injected for tests. */ export interface ScanFs { readDir(dir: string): DirEnt[]; readText(file: string): string; } /** One launchable candidate produced by the scan. */ export interface RomCandidate { platform: string; /** The root directory this file was found under. */ rootDir: string; /** Absolute path to the launchable file (the `.m3u`/`.cue`/`.gdi`, or the ROM itself). */ absPath: string; /** POSIX path of `absPath` relative to `rootDir` — the `external_id` suffix. */ relPath: string; /** Lowercase extension of `absPath` (e.g. `.sfc`). */ ext: string; /** Basename of `absPath` without extension — fed to the title parser. */ stem: string; /** True for `.zip`/`.7z` — gated on emulator archive support at reconcile time. */ isArchive: boolean; } const ARCHIVE_EXT = new Set([".zip", ".7z"]); const SHEET_EXT = new Set([".cue", ".gdi", ".ccd", ".mds"]); const TRACK_EXT = new Set([".bin", ".img", ".sub", ".raw"]); const lc = (s: string): string => s.toLowerCase(); const extOf = (name: string): string => lc(nodePath.extname(name)); const stemOf = (name: string): string => nodePath.basename(name, nodePath.extname(name)); const toPosix = (p: string): string => p.split(nodePath.sep).join("/"); // ---------------------------------------------------------------- glob excludes /** Convert a gitignore-ish glob to an anchored regex (`**` = any depth, `*` = within a segment, `?`). */ const globToRegex = (glob: string): RegExp => { let re = ""; for (let i = 0; i < glob.length; i++) { const c = glob[i]; if (c === "*") { if (glob[i + 1] === "*") { re += ".*"; i++; if (glob[i + 1] === "/") i++; // `**/` swallows the slash } else { re += "[^/]*"; } } else if (c === "?") { re += "[^/]"; } else if (c && "\\^$.|+()[]{}".includes(c)) { re += `\\${c}`; } else { re += c; } } return new RegExp(`^${re}$`, "i"); }; /** * gitignore-ish evaluation: patterns are excludes; a `!`-prefixed pattern re-includes. Last match * wins. A bare pattern with no `/` matches the basename at any depth (like gitignore). */ export const makeExcluder = ( patterns: string[] | undefined, ): ((relPath: string) => boolean) => { if (!patterns || patterns.length === 0) return () => false; const compiled = patterns.map((p) => { const negate = p.startsWith("!"); const body = negate ? p.slice(1) : p; const anchored = body.includes("/"); return { negate, re: globToRegex(anchored ? body : `**/${body}`), bare: body, }; }); return (relPath: string): boolean => { let excluded = false; for (const { negate, re } of compiled) { if (re.test(relPath)) excluded = !negate; } return excluded; }; }; // ---------------------------------------------------------------- walk interface FileRec { absPath: string; relPath: string; // posix, relative to root dir: string; // absolute dir name: string; ext: string; } /** Recursively enumerate files under `rootDir`, applying excludes. Symlinked dirs are not followed. */ const walk = ( fsx: ScanFs, rootDir: string, exclude: (rel: string) => boolean, ): FileRec[] => { const out: FileRec[] = []; const recurse = (dir: string): void => { let ents: DirEnt[]; try { ents = fsx.readDir(dir); } catch { return; } for (const ent of ents) { const abs = nodePath.join(dir, ent.name); const rel = toPosix(nodePath.relative(rootDir, abs)); if (ent.isDir) { if (exclude(`${rel}/`)) continue; recurse(abs); } else if (ent.isFile) { if (exclude(rel)) continue; out.push({ absPath: abs, relPath: rel, dir, name: ent.name, ext: extOf(ent.name), }); } } }; recurse(rootDir); return out; }; // ---------------------------------------------------------------- disc folding /** Parse the disc/track paths referenced by an `.m3u` (non-comment lines) or a `.cue`/`.gdi` sheet. */ const parseReferences = (fsx: ScanFs, file: FileRec): string[] => { let text: string; try { text = fsx.readText(file.absPath); } catch { return []; } const refs: string[] = []; if (file.ext === ".m3u") { for (const line of text.split(/\r?\n/)) { const t = line.trim(); if (t && !t.startsWith("#")) refs.push(t); } } else if ( file.ext === ".cue" || file.ext === ".ccd" || file.ext === ".mds" ) { for (const m of text.matchAll(/FILE\s+"([^"]+)"/gi)) { if (m[1]) refs.push(m[1]); } } else if (file.ext === ".gdi") { // GDI: ` ` — filename may be quoted. for (const line of text.split(/\r?\n/)) { const m = /(?:"([^"]+)"|(\S+\.(?:bin|raw|iso)))/i.exec(line.trim()); const f = m?.[1] ?? m?.[2]; if (f) refs.push(f); } } // Resolve each reference relative to the sheet's directory, as a posix path key. return refs.map((r) => nodePath.join(file.dir, r)); }; /** * Fold disc images: `.m3u` playlists become one entry (their discs hidden); `.cue`/`.gdi` sheets become * one entry (their tracks hidden); sheets referenced by a playlist are hidden too. Everything else that * matches the platform's extensions is a loose candidate. */ const foldDiscs = ( fsx: ScanFs, files: FileRec[], platform: Platform, ): FileRec[] => { const referencedByM3u = new Set(); const referencedBySheet = new Set(); for (const f of files) { if (f.ext === ".m3u") { for (const r of parseReferences(fsx, f)) referencedByM3u.add(r); } } // Parse EVERY sheet for its tracks (even a sheet that a playlist references) so a `.bin` behind a // cue is always hidden — whether the cue stands alone or lives inside an `.m3u`. for (const f of files) { if (SHEET_EXT.has(f.ext)) { for (const r of parseReferences(fsx, f)) referencedBySheet.add(r); } } const extSet = new Set(platform.extensions.map(lc)); const out: FileRec[] = []; for (const f of files) { if (referencedByM3u.has(f.absPath)) continue; // disc within a playlist if (referencedBySheet.has(f.absPath)) continue; // track behind a sheet if (f.ext === ".m3u") { out.push(f); continue; } if (SHEET_EXT.has(f.ext)) { out.push(f); // a standalone sheet (its tracks are hidden above) continue; } if (TRACK_EXT.has(f.ext) && !extSet.has(f.ext)) continue; // orphan track, not a platform ROM ext if (ARCHIVE_EXT.has(f.ext) || extSet.has(f.ext)) out.push(f); } return out; }; // ---------------------------------------------------------------- scan /** Scan one ROM root into candidates. `platform` is the resolved platform for `rootDir`. */ export const scanRoot = ( fsx: ScanFs, rootDir: string, platform: Platform, excludes: string[] | undefined, ): RomCandidate[] => { const exclude = makeExcluder(excludes); const files = walk(fsx, rootDir, exclude); const extSet = new Set(platform.extensions.map(lc)); const chosen = platform.disc ? foldDiscs(fsx, files, platform) : files.filter((f) => ARCHIVE_EXT.has(f.ext) || extSet.has(f.ext)); return chosen.map((f) => ({ platform: platform.id, rootDir, absPath: f.absPath, relPath: f.relPath, ext: f.ext, stem: stemOf(f.name), isArchive: ARCHIVE_EXT.has(f.ext), })); }; // ---------------------------------------------------------------- real ScanFs /** The production filesystem, backed by node fs. Directory reads are cheap and non-recursive here. */ export const realScanFs: ScanFs = { readDir(dir) { return fs.readdirSync(dir, { withFileTypes: true }).map((d) => ({ name: d.name, isDir: d.isDirectory(), isFile: d.isFile(), })); }, readText(file) { return fs.readFileSync(file, "utf8"); }, };