import { existsSync, readFileSync } from "node:fs"; import * as path from "node:path"; import { parse, ParseError } from "./parser.js"; import { checkModule, moduleInfoFromNative, type CheckedModule, type ModuleInfo, } from "./checker.js"; import type { Diagnostic } from "./diagnostics.js"; import { NATIVE_MODULES } from "./builtins.js"; import type * as A from "./ast.js"; import type { ModuleBundle } from "./codegen.js"; export interface LoadOptions { /** Absolute path to the entry .xan file (or a directory containing main.xan). */ entryFile: string; /** Working directory used to derive module ids and resolve relative imports. */ cwd?: string; } export interface LoadedProject { /** module id of the entry module, e.g. "src/main" */ entryModuleId: string; /** moduleId -> { program, checked } */ modules: Map; /** moduleId -> checked module */ checked: Map; /** moduleId -> diagnostics */ diagnostics: Map; /** js module id (js:name) -> import specifier */ jsImports: Map; } /** Converts a file path into a module id: relative to cwd, forward slashes, no extension. */ export function moduleIdOf(filePath: string, cwd: string): string { let rel = path.relative(cwd, filePath); if (rel.startsWith("..") || path.isAbsolute(rel)) rel = filePath; let id = rel.split(path.sep).join("/"); if (id.toLowerCase().endsWith(".xan")) id = id.slice(0, -3); return id; } function moduleNameOf(id: string): string { const base = id.split("/").pop() ?? id; return base.replace(/^[^A-Za-z0-9_]+/, "") || base; } class Loader { private readonly opts: LoadOptions; private readonly project: LoadedProject; private readonly inProgress = new Set(); private currentId: string | null = null; constructor(opts: LoadOptions) { this.opts = opts; this.project = { entryModuleId: "", modules: new Map(), checked: new Map(), diagnostics: new Map(), jsImports: new Map(), }; } private entryPath(filePath: string): string { if (existsSync(filePath) && !existsSync(`${filePath}.xan`)) { if (filePath.endsWith(".xan")) return filePath; if (existsSync(path.join(filePath, "main.xan"))) return path.join(filePath, "main.xan"); } if (existsSync(`${filePath}.xan`)) return `${filePath}.xan`; return filePath; } load(entryFile: string): LoadedProject { const entry = this.entryPath(path.resolve(this.opts.cwd ?? process.cwd(), entryFile)); const bundle = this.loadFile(entry); if (!bundle) return this.project; this.project.entryModuleId = bundle.checked.moduleId; return this.project; } private loadFile(filePath: string): ModuleBundle | null { const id = moduleIdOf(filePath, this.opts.cwd ?? process.cwd()); const existing = this.project.modules.get(id); if (existing) return existing; if (this.inProgress.has(id)) return null; this.inProgress.add(id); let source: string; try { source = readFileSync(filePath, "utf8"); } catch { this.inProgress.delete(id); return null; } let program: A.Program; try { program = parse(source, id); } catch (e) { if (e instanceof ParseError) { this.project.diagnostics.set(id, [ { severity: "error", code: "E1007", message: e.message, span: e.span, detail: "The file could not be parsed.", }, ]); } this.inProgress.delete(id); return null; } const prev = this.currentId; this.currentId = id; const checked = checkModule({ moduleId: id, file: id, source, program, resolveImport: (ref) => this.resolveImport(ref), resolveChecked: (mid) => this.project.checked.get(mid) ?? null, }); this.currentId = prev; const bundle: ModuleBundle = { program, checked }; this.project.modules.set(id, bundle); this.project.checked.set(id, checked); this.project.diagnostics.set(id, checked.diagnostics); this.inProgress.delete(id); return bundle; } private resolveImport(ref: A.ModuleRef): ModuleInfo | null { switch (ref.kind) { case "std": { const mod = NATIVE_MODULES[`std.${ref.segments.join(".")}`]; if (!mod) return null; return moduleInfoFromNative(mod); } case "js": { this.project.jsImports.set(`js/${ref.name}`, ref.name); return { kind: "js", id: `js/${ref.name}`, name: ref.name, exports: [], jsSpecifier: ref.name }; } case "pkg": { const cwd = this.opts.cwd ?? process.cwd(); const candidates = [ path.join(cwd, "deps", ref.name, "src", "main.xan"), path.join(cwd, "deps", ref.name, "main.xan"), path.join(cwd, "deps", ref.name, `${ref.name}.xan`), ]; for (const cand of candidates) { if (existsSync(cand)) { const bundle = this.loadFile(cand); if (bundle) return this.moduleInfoFromEx(bundle.checked); } } return null; } case "rel": { const cwd = this.opts.cwd ?? process.cwd(); const from = this.currentId ? path.dirname(path.join(cwd, this.currentId)) : cwd; const target = path.resolve(from, ref.path); const candidate = ref.path.endsWith(".xan") || ref.path.endsWith(".mjs") ? target : `${target}.xan`; if (!existsSync(candidate)) return null; const bundle = this.loadFile(candidate); if (!bundle) return null; return this.moduleInfoFromEx(bundle.checked); } } } private moduleInfoFromEx(checked: CheckedModule): ModuleInfo { return { kind: "ex", id: checked.moduleId, name: moduleNameOf(checked.moduleId), exports: checked.exports, file: checked.file, }; } } export function loadProject(opts: LoadOptions): LoadedProject { return new Loader(opts).load(opts.entryFile); } export function projectDiagnostics(project: LoadedProject): Diagnostic[] { const out: Diagnostic[] = []; for (const [id, diags] of project.diagnostics) { for (const d of diags) { if (project.entryModuleId === id) out.push(d); else if (d.severity === "error") out.push({ ...d, detail: `${d.detail ?? ""} [module ${id}]`.trim() }); } } return out; }