/** * Mod project root discovery for RA3 Mod XML. * * Pure TypeScript (no vscode dependency) so the detection rules can be unit * tested and reused by other tools. * * A project root is any directory containing one of the markers the mod * compiler (defaultscript.cs) actually consumes: * - `Data/Mod.xml` (static data entry) * - `Data/additionalmaps/mapmetadata_*.xml` (global data entries) * - `*.babproj` (mod SDK project file) * * Discovery works in three directions: * - upward from a folder (the workspace folder may be `Data` or a deep * subfolder of a mod); * - upward from a file (single-file opens without a workspace folder); * - shallow downward from a container folder (a folder that contains * several sibling mods). */ import { dirname, join, resolve } from "node:path"; import { readdirSync } from "node:fs"; export const DEFAULT_MAX_UPWARD_DEPTH = 12; export const DEFAULT_MAX_DOWNWARD_DEPTH = 3; export type ProjectMarkerKind = "mod" | "babproj" | "mapmetadata"; /** Directories that never contain a mod root themselves. */ const SKIP_DIRECTORY_NAMES = new Set([ "data", "art", "art1", "audio", "audio1", "builtmods", "builtmods-quantum", "sageml", "schemas", "xsd", "hlsl", "node_modules", "packages", "dist", "out", "bin", "obj", ".git", ".vs", ".vscode", ]); /** * Returns the marker kind found directly under `dir`, or null when `dir` is * not a mod project root. `Data`/`mapmetadata` lookups are case-insensitive. */ export function projectMarkerKind(dir: string): ProjectMarkerKind | null { const data = findCaseInsensitiveDir(dir, "Data"); if (data && hasFileIgnoreCase(data, ["Mod.xml"])) return "mod"; const entries = readDirNames(dir); if (entries?.some((e) => e.toLowerCase().endsWith(".babproj"))) { return "babproj"; } if (data && hasMapMetadata(data)) return "mapmetadata"; return null; } /** True when `dir` is a mod project root (any marker). */ export function isProjectRoot(dir: string): boolean { return projectMarkerKind(dir) != null; } /** * Walks upward from `startDir` (up to `maxDepth` ancestors) and returns the * nearest directory that carries a project marker, or null. */ export function findProjectRootUpward( startDir: string, maxDepth = DEFAULT_MAX_UPWARD_DEPTH, ): string | null { let dir = resolve(startDir); for (let i = 0; i < maxDepth; i++) { if (isProjectRoot(dir)) return dir; const parent = dirname(dir); if (parent === dir) break; dir = parent; } return null; } /** Upward discovery starting from a file's directory (single-file opens). */ export function findProjectRootForFile( file: string, maxDepth = DEFAULT_MAX_UPWARD_DEPTH, ): string | null { return findProjectRootUpward(dirname(resolve(file)), maxDepth); } /** * Shallow downward discovery for a workspace folder that contains one or * more mods (e.g. the SDK `Mods` folder or a personal mods container). * * Descends at most `maxDepth` levels, never descends into known non-mod * directories, and stops descending once a directory is itself a project * root (a root's own `Data`/`Art` subtrees are never project containers). * Results are de-duplicated by normalized path. */ export function discoverProjects( folder: string, maxDepth = DEFAULT_MAX_DOWNWARD_DEPTH, ): string[] { const out: string[] = []; const seen = new Set(); const visit = (dir: string, depth: number): void => { if (depth > maxDepth) return; if (isProjectRoot(dir)) { const key = normKey(dir); if (!seen.has(key)) { seen.add(key); out.push(resolve(dir)); } return; } let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { if (!entry.isDirectory()) continue; if (SKIP_DIRECTORY_NAMES.has(entry.name.toLowerCase())) continue; visit(join(dir, entry.name), depth + 1); } }; visit(resolve(folder), 0); return out; } function normKey(path: string): string { return resolve(path).toLowerCase(); } function readDirNames(dir: string): string[] | null { try { return readdirSync(dir); } catch { return null; } } /** Case-insensitive child directory lookup under `parent`. */ function findCaseInsensitiveDir(parent: string, wanted: string): string | null { const entries = readDirNames(parent); if (!entries) return null; const hit = entries.find( (e) => e.toLowerCase() === wanted.toLowerCase(), ); return hit ? join(parent, hit) : null; } /** True when `dir` contains any of `names` (case-insensitive file names). */ function hasFileIgnoreCase(dir: string, names: string[]): boolean { const entries = readDirNames(dir); if (!entries) return false; const lower = new Set(entries.map((e) => e.toLowerCase())); return names.some((n) => lower.has(n.toLowerCase())); } /** True when `dataDir/additionalmaps` contains a mapmetadata_*.xml file. */ function hasMapMetadata(dataDir: string): boolean { const maps = findCaseInsensitiveDir(dataDir, "additionalmaps"); if (!maps) return false; const entries = readDirNames(maps); if (!entries) return false; return entries.some((e) => /^mapmetadata_.*\.xml$/i.test(e)); }