Skip to main content

doge_compiler/modules/
mod.rs

1//! The module loader: it turns an entry `.doge` script into a whole [`Program`]
2//! by following `so` imports. A bare `so <name>` first resolves against the
3//! stdlib table ([`crate::stdlib`]); anything else is a user module — the file
4//! `<name>.doge` next to the importing file. A string-path import
5//! `so "sub/dir/mod.doge"` names a user file by a `/`-separated path relative to
6//! the importing file, binding the file's stem. Loading is recursive (a module
7//! may import other modules); dedup and cycle detection key on each file's
8//! canonical path, so the same file reached by two routes loads once and two
9//! different files may share a stem. The pipeline downstream of here — checks and
10//! codegen — sees every file at once.
11
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15pub(super) use crate::ast::{Script, Stmt};
16pub(super) use crate::diagnostics::Diagnostic;
17pub(super) use crate::project::DependencyMap;
18pub(super) use crate::stdlib::{self, Module};
19pub(super) use crate::token::Span;
20
21mod diag;
22use diag::*;
23
24#[cfg(test)]
25mod tests;
26
27pub struct Program {
28    pub files: Vec<ProgramFile>,
29    /// Module file ids in dependency order (a module before anything that
30    /// imports it), so their constants can be initialized in a valid order. The
31    /// entry is not listed — its constants initialize inline with its own
32    /// top-level statements.
33    pub init_order: Vec<u32>,
34}
35
36/// One source file in the program, with its parsed script and resolved imports.
37pub struct ProgramFile {
38    pub file_id: u32,
39    pub is_entry: bool,
40    /// The module name (a user module's import name, or the entry's file stem).
41    pub name: String,
42    pub path: String,
43    pub source: String,
44    pub script: Script,
45    /// Imported stdlib modules: `(local name, table entry)`.
46    pub stdlib_imports: Vec<(String, &'static Module)>,
47    /// Imported user modules: `(local name, target file id)`.
48    pub user_imports: Vec<(String, u32)>,
49}
50
51/// Load the entry script and every module it imports into a [`Program`],
52/// resolving `so <name>` against the stdlib and sibling files only (no project
53/// dependencies).
54pub fn load_program(entry_path: &str, entry_source: &str) -> Result<Program, Diagnostic> {
55    load_program_with_deps(entry_path, entry_source, DependencyMap::new())
56}
57
58/// Load a program, resolving bare imports against `deps` (a project's resolved
59/// dependency graph) in addition to the stdlib and sibling files.
60pub fn load_program_with_deps(
61    entry_path: &str,
62    entry_source: &str,
63    deps: DependencyMap,
64) -> Result<Program, Diagnostic> {
65    let entry_script = crate::parser::parse(entry_path, entry_source)?;
66    let mut loader = Loader {
67        modules: Vec::new(),
68        by_path: HashMap::new(),
69        init_order: Vec::new(),
70        active: Vec::new(),
71        next_id: 1,
72        entry_key: canonical_key(Path::new(entry_path)),
73        deps,
74    };
75    let (stdlib_imports, user_imports) =
76        loader.resolve_imports(entry_path, entry_source, &entry_script)?;
77
78    let entry = ProgramFile {
79        file_id: 0,
80        is_entry: true,
81        name: file_stem(entry_path),
82        path: entry_path.to_string(),
83        source: entry_source.to_string(),
84        script: entry_script,
85        stdlib_imports,
86        user_imports,
87    };
88
89    // `modules` is in completion (dependency) order with ids assigned in
90    // discovery order; place each at its file_id so `files[i].file_id == i`.
91    let init_order = loader.modules.iter().map(|m| m.file_id).collect();
92    let mut slots: Vec<Option<ProgramFile>> = (0..=loader.modules.len()).map(|_| None).collect();
93    slots[0] = Some(entry);
94    for module in loader.modules {
95        let id = module.file_id as usize;
96        slots[id] = Some(module);
97    }
98    let files = slots
99        .into_iter()
100        .map(|s| s.expect("compiler bug: unfilled program file slot"))
101        .collect();
102
103    Ok(Program { files, init_order })
104}
105
106/// Build a one-file [`Program`] from an already-parsed script, resolving imports
107/// against the stdlib only. Used by the single-file `generate`/`check` APIs and
108/// the codegen unit tests, where no user modules are on disk to load.
109pub fn single_file_program(
110    path: &str,
111    source: &str,
112    script: Script,
113) -> Result<Program, Diagnostic> {
114    let mut stdlib_imports = Vec::new();
115    for stmt in &script.stmts {
116        if let Stmt::Import {
117            module,
118            path: import_path,
119            span,
120        } = stmt
121        {
122            // Single-file mode has no loader, so a user module — bare or path —
123            // cannot be resolved here; only stdlib imports are valid.
124            match (import_path, stdlib::module(module)) {
125                (None, Some(m)) => stdlib_imports.push((module.clone(), m)),
126                _ => return Err(unknown_stdlib_module(path, source, module, *span)),
127            }
128        }
129    }
130    let entry = ProgramFile {
131        file_id: 0,
132        is_entry: true,
133        name: file_stem(path),
134        path: path.to_string(),
135        source: source.to_string(),
136        script,
137        stdlib_imports,
138        user_imports: Vec::new(),
139    };
140    Ok(Program {
141        files: vec![entry],
142        init_order: Vec::new(),
143    })
144}
145
146/// A file's resolved imports: `(stdlib name → entry, user name → file id)`.
147type ResolvedImports = (Vec<(String, &'static Module)>, Vec<(String, u32)>);
148
149struct Loader {
150    /// Loaded modules in completion (dependency) order.
151    modules: Vec<ProgramFile>,
152    /// Canonical module path → file id, so a file reached twice loads once even
153    /// when the two routes spell its path differently.
154    by_path: HashMap<PathBuf, u32>,
155    /// Module file ids in dependency order (completion order).
156    init_order: Vec<u32>,
157    /// Modules on the current DFS path, for cycle detection.
158    active: Vec<ActiveModule>,
159    /// Next module file id to hand out (the entry is always 0).
160    next_id: u32,
161    /// The entry file's canonical path, so a module importing the entry back is
162    /// caught (the entry is not a module — it has loose top-level statements).
163    entry_key: PathBuf,
164    /// The project's resolved dependency graph, keyed by canonical package root.
165    /// Empty for a bare script with no `doge.toml`.
166    deps: DependencyMap,
167}
168
169/// A module currently being loaded on the DFS path: its binding name (for a
170/// readable cycle message) and its canonical path (the identity we match on).
171struct ActiveModule {
172    name: String,
173    key: PathBuf,
174}
175
176impl Loader {
177    /// Resolve one file's `so` imports, loading any user modules it names, and
178    /// return its `(stdlib imports, user imports)`.
179    fn resolve_imports(
180        &mut self,
181        importer_path: &str,
182        importer_source: &str,
183        script: &Script,
184    ) -> Result<ResolvedImports, Diagnostic> {
185        let dir = Path::new(importer_path).parent();
186        let mut stdlib_imports = Vec::new();
187        let mut user_imports = Vec::new();
188
189        for stmt in &script.stmts {
190            let Stmt::Import { module, path, span } = stmt else {
191                continue;
192            };
193
194            // A bare `so name` may resolve to a stdlib module; a string-path
195            // import always names a user file, so its stem must not collide with
196            // a stdlib module (the binding would be unusable).
197            match path {
198                None => {
199                    if let Some(entry) = stdlib::module(module) {
200                        if module_file_exists(dir, module) {
201                            return Err(shadow_diag(importer_path, importer_source, module, *span));
202                        }
203                        stdlib_imports.push((module.clone(), entry));
204                        continue;
205                    }
206                }
207                Some(_) if stdlib::module(module).is_some() => {
208                    return Err(shadow_diag(importer_path, importer_source, module, *span));
209                }
210                Some(_) => {}
211            }
212
213            let target = match path {
214                Some(raw) => path_import_path(dir, raw),
215                None => match self.dep_entry(importer_path, module) {
216                    // A bare, non-stdlib name may name a declared dependency. If a
217                    // sibling file of the same name also exists, the import is
218                    // ambiguous — a name to fix now rather than resolve silently.
219                    Some(entry) => {
220                        if module_file_exists(dir, module) {
221                            return Err(dep_conflict_diag(
222                                importer_path,
223                                importer_source,
224                                module,
225                                *span,
226                            ));
227                        }
228                        entry
229                    }
230                    None => module_path(dir, module),
231                },
232            };
233            let target_id = self.resolve_user_module(
234                importer_path,
235                importer_source,
236                module,
237                path.as_deref(),
238                &target,
239                *span,
240            )?;
241            user_imports.push((module.clone(), target_id));
242        }
243
244        Ok((stdlib_imports, user_imports))
245    }
246
247    /// The entry file a bare `so <alias>` binds when `alias` is a dependency of
248    /// the package owning `importer_path`, or `None` when it names no dependency.
249    fn dep_entry(&self, importer_path: &str, alias: &str) -> Option<PathBuf> {
250        let pkg = self.owning_package(importer_path)?;
251        self.deps.get(&pkg)?.get(alias).cloned()
252    }
253
254    /// The canonical root of the package that owns `importer_path`: the nearest
255    /// ancestor directory present in the dependency map. `None` when the file is
256    /// outside every resolved package (e.g. a bare script with no project).
257    fn owning_package(&self, importer_path: &str) -> Option<PathBuf> {
258        let canon = std::fs::canonicalize(importer_path).ok()?;
259        let mut dir = canon.parent();
260        while let Some(candidate) = dir {
261            if self.deps.contains_key(candidate) {
262                return Some(candidate.to_path_buf());
263            }
264            dir = candidate.parent();
265        }
266        None
267    }
268
269    /// Resolve one user-module import to a file id: canonicalize its path (which
270    /// also detects a missing file), reject a self-import of the entry, check the
271    /// active DFS path for a cycle, reuse an already-loaded file, or load it.
272    fn resolve_user_module(
273        &mut self,
274        importer_path: &str,
275        importer_source: &str,
276        name: &str,
277        raw_path: Option<&str>,
278        target: &Path,
279        span: Span,
280    ) -> Result<u32, Diagnostic> {
281        let key = match std::fs::canonicalize(target) {
282            Ok(key) => key,
283            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
284                return Err(match raw_path {
285                    Some(raw) => {
286                        missing_path_module_diag(importer_path, importer_source, raw, target, span)
287                    }
288                    None => missing_module_diag(importer_path, importer_source, name, span),
289                });
290            }
291            Err(err) => {
292                return Err(read_error_diag(
293                    importer_path,
294                    importer_source,
295                    name,
296                    target,
297                    &err,
298                    span,
299                ))
300            }
301        };
302
303        if key == self.entry_key {
304            return Err(entry_import_diag(importer_path, importer_source, span));
305        }
306
307        if self.active.iter().any(|m| m.key == key) {
308            let chain: Vec<String> = self.active.iter().map(|m| m.name.clone()).collect();
309            return Err(cycle_diag(
310                importer_path,
311                importer_source,
312                &chain,
313                name,
314                span,
315            ));
316        }
317
318        if let Some(id) = self.by_path.get(&key) {
319            return Ok(*id);
320        }
321
322        self.load_module(name, target, key, importer_path, importer_source, span)
323    }
324
325    /// Read, parse, and recursively resolve a user module at `target` (already
326    /// canonicalized to `key`). Returns the new file id.
327    fn load_module(
328        &mut self,
329        name: &str,
330        target: &Path,
331        key: PathBuf,
332        importer_path: &str,
333        importer_source: &str,
334        span: Span,
335    ) -> Result<u32, Diagnostic> {
336        let source = match std::fs::read_to_string(target) {
337            Ok(source) => source,
338            Err(err) => {
339                return Err(read_error_diag(
340                    importer_path,
341                    importer_source,
342                    name,
343                    target,
344                    &err,
345                    span,
346                ))
347            }
348        };
349
350        let path_str = target.to_string_lossy().into_owned();
351        let script = crate::parser::parse(&path_str, &source)?;
352
353        let file_id = self.next_id;
354        self.next_id += 1;
355        self.by_path.insert(key.clone(), file_id);
356
357        self.active.push(ActiveModule {
358            name: name.to_string(),
359            key,
360        });
361        let (stdlib_imports, user_imports) = self.resolve_imports(&path_str, &source, &script)?;
362        self.active.pop();
363
364        self.modules.push(ProgramFile {
365            file_id,
366            is_entry: false,
367            name: name.to_string(),
368            path: path_str,
369            source,
370            script,
371            stdlib_imports,
372            user_imports,
373        });
374        self.init_order.push(file_id);
375        Ok(file_id)
376    }
377}
378
379fn module_path(dir: Option<&Path>, name: &str) -> PathBuf {
380    let file = format!("{name}.doge");
381    match dir {
382        Some(dir) => dir.join(file),
383        None => PathBuf::from(file),
384    }
385}
386
387/// The file a string-path import `so "raw"` targets, relative to the importing
388/// file's directory. `raw` is validated by the parser (relative, `/`-separated).
389fn path_import_path(dir: Option<&Path>, raw: &str) -> PathBuf {
390    match dir {
391        Some(dir) => dir.join(raw),
392        None => PathBuf::from(raw),
393    }
394}
395
396/// A file's identity for dedup and cycle detection: its canonical path when it
397/// resolves, else the path as given (so identity is still stable enough to work
398/// with before the file is confirmed to exist).
399fn canonical_key(path: &Path) -> PathBuf {
400    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
401}
402
403fn module_file_exists(dir: Option<&Path>, name: &str) -> bool {
404    module_path(dir, name).is_file()
405}
406
407fn file_stem(path: &str) -> String {
408    Path::new(path)
409        .file_stem()
410        .and_then(|s| s.to_str())
411        .unwrap_or(path)
412        .to_string()
413}