doge-compiler 0.3.0

Compiler for the Doge programming language — lexer, parser, semantic checks, and Rust codegen.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
//! The module loader: it turns an entry `.doge` script into a whole [`Program`]
//! by following `so` imports. A bare `so <name>` first resolves against the
//! stdlib table ([`crate::stdlib`]); anything else is a user module — the file
//! `<name>.doge` next to the importing file. A string-path import
//! `so "sub/dir/mod.doge"` names a user file by a `/`-separated path relative to
//! the importing file, binding the file's stem. Loading is recursive (a module
//! may import other modules); dedup and cycle detection key on each file's
//! canonical path, so the same file reached by two routes loads once and two
//! different files may share a stem. The pipeline downstream of here — checks and
//! codegen — sees every file at once.

use std::collections::HashMap;
use std::path::{Path, PathBuf};

pub(super) use crate::ast::{Script, Stmt};
pub(super) use crate::diagnostics::Diagnostic;
pub(super) use crate::project::DependencyMap;
pub(super) use crate::stdlib::{self, Module};
pub(super) use crate::token::Span;

mod diag;
use diag::*;

#[cfg(test)]
mod tests;

pub struct Program {
    pub files: Vec<ProgramFile>,
    /// Module file ids in dependency order (a module before anything that
    /// imports it), so their constants can be initialized in a valid order. The
    /// entry is not listed — its constants initialize inline with its own
    /// top-level statements.
    pub init_order: Vec<u32>,
}

/// One source file in the program, with its parsed script and resolved imports.
pub struct ProgramFile {
    pub file_id: u32,
    pub is_entry: bool,
    /// The module name (a user module's import name, or the entry's file stem).
    pub name: String,
    pub path: String,
    pub source: String,
    pub script: Script,
    /// Imported stdlib modules: `(local name, table entry)`.
    pub stdlib_imports: Vec<(String, &'static Module)>,
    /// Imported user modules: `(local name, target file id)`.
    pub user_imports: Vec<(String, u32)>,
}

/// Load the entry script and every module it imports into a [`Program`],
/// resolving `so <name>` against the stdlib and sibling files only (no project
/// dependencies).
pub fn load_program(entry_path: &str, entry_source: &str) -> Result<Program, Diagnostic> {
    load_program_with_deps(entry_path, entry_source, DependencyMap::new())
}

/// Load a program, resolving bare imports against `deps` (a project's resolved
/// dependency graph) in addition to the stdlib and sibling files.
pub fn load_program_with_deps(
    entry_path: &str,
    entry_source: &str,
    deps: DependencyMap,
) -> Result<Program, Diagnostic> {
    let entry_script = crate::parser::parse(entry_path, entry_source)?;
    let mut loader = Loader {
        modules: Vec::new(),
        by_path: HashMap::new(),
        init_order: Vec::new(),
        active: Vec::new(),
        next_id: 1,
        entry_key: canonical_key(Path::new(entry_path)),
        deps,
    };
    let (stdlib_imports, user_imports) =
        loader.resolve_imports(entry_path, entry_source, &entry_script)?;

    let entry = ProgramFile {
        file_id: 0,
        is_entry: true,
        name: file_stem(entry_path),
        path: entry_path.to_string(),
        source: entry_source.to_string(),
        script: entry_script,
        stdlib_imports,
        user_imports,
    };

    // `modules` is in completion (dependency) order with ids assigned in
    // discovery order; place each at its file_id so `files[i].file_id == i`.
    let init_order = loader.modules.iter().map(|m| m.file_id).collect();
    let mut slots: Vec<Option<ProgramFile>> = (0..=loader.modules.len()).map(|_| None).collect();
    slots[0] = Some(entry);
    for module in loader.modules {
        let id = module.file_id as usize;
        slots[id] = Some(module);
    }
    let files = slots
        .into_iter()
        .map(|s| s.expect("compiler bug: unfilled program file slot"))
        .collect();

    Ok(Program { files, init_order })
}

/// Build a one-file [`Program`] from an already-parsed script, resolving imports
/// against the stdlib only. Used by the single-file `generate`/`check` APIs and
/// the codegen unit tests, where no user modules are on disk to load.
pub fn single_file_program(
    path: &str,
    source: &str,
    script: Script,
) -> Result<Program, Diagnostic> {
    let mut stdlib_imports = Vec::new();
    for stmt in &script.stmts {
        if let Stmt::Import {
            module,
            path: import_path,
            span,
        } = stmt
        {
            // Single-file mode has no loader, so a user module — bare or path —
            // cannot be resolved here; only stdlib imports are valid.
            match (import_path, stdlib::module(module)) {
                (None, Some(m)) => stdlib_imports.push((module.clone(), m)),
                _ => return Err(unknown_stdlib_module(path, source, module, *span)),
            }
        }
    }
    let entry = ProgramFile {
        file_id: 0,
        is_entry: true,
        name: file_stem(path),
        path: path.to_string(),
        source: source.to_string(),
        script,
        stdlib_imports,
        user_imports: Vec::new(),
    };
    Ok(Program {
        files: vec![entry],
        init_order: Vec::new(),
    })
}

/// A file's resolved imports: `(stdlib name → entry, user name → file id)`.
type ResolvedImports = (Vec<(String, &'static Module)>, Vec<(String, u32)>);

struct Loader {
    /// Loaded modules in completion (dependency) order.
    modules: Vec<ProgramFile>,
    /// Canonical module path → file id, so a file reached twice loads once even
    /// when the two routes spell its path differently.
    by_path: HashMap<PathBuf, u32>,
    /// Module file ids in dependency order (completion order).
    init_order: Vec<u32>,
    /// Modules on the current DFS path, for cycle detection.
    active: Vec<ActiveModule>,
    /// Next module file id to hand out (the entry is always 0).
    next_id: u32,
    /// The entry file's canonical path, so a module importing the entry back is
    /// caught (the entry is not a module — it has loose top-level statements).
    entry_key: PathBuf,
    /// The project's resolved dependency graph, keyed by canonical package root.
    /// Empty for a bare script with no `doge.toml`.
    deps: DependencyMap,
}

/// A module currently being loaded on the DFS path: its binding name (for a
/// readable cycle message) and its canonical path (the identity we match on).
struct ActiveModule {
    name: String,
    key: PathBuf,
}

impl Loader {
    /// Resolve one file's `so` imports, loading any user modules it names, and
    /// return its `(stdlib imports, user imports)`.
    fn resolve_imports(
        &mut self,
        importer_path: &str,
        importer_source: &str,
        script: &Script,
    ) -> Result<ResolvedImports, Diagnostic> {
        let dir = Path::new(importer_path).parent();
        let mut stdlib_imports = Vec::new();
        let mut user_imports = Vec::new();

        for stmt in &script.stmts {
            let Stmt::Import { module, path, span } = stmt else {
                continue;
            };

            // A bare `so name` may resolve to a stdlib module; a string-path
            // import always names a user file, so its stem must not collide with
            // a stdlib module (the binding would be unusable).
            match path {
                None => {
                    if let Some(entry) = stdlib::module(module) {
                        if module_file_exists(dir, module) {
                            return Err(shadow_diag(importer_path, importer_source, module, *span));
                        }
                        stdlib_imports.push((module.clone(), entry));
                        continue;
                    }
                }
                Some(_) if stdlib::module(module).is_some() => {
                    return Err(shadow_diag(importer_path, importer_source, module, *span));
                }
                Some(_) => {}
            }

            let target = match path {
                Some(raw) => path_import_path(dir, raw),
                None => match self.dep_entry(importer_path, module) {
                    // A bare, non-stdlib name may name a declared dependency. If a
                    // sibling file of the same name also exists, the import is
                    // ambiguous — a name to fix now rather than resolve silently.
                    Some(entry) => {
                        if module_file_exists(dir, module) {
                            return Err(dep_conflict_diag(
                                importer_path,
                                importer_source,
                                module,
                                *span,
                            ));
                        }
                        entry
                    }
                    None => module_path(dir, module),
                },
            };
            let target_id = self.resolve_user_module(
                importer_path,
                importer_source,
                module,
                path.as_deref(),
                &target,
                *span,
            )?;
            user_imports.push((module.clone(), target_id));
        }

        Ok((stdlib_imports, user_imports))
    }

    /// The entry file a bare `so <alias>` binds when `alias` is a dependency of
    /// the package owning `importer_path`, or `None` when it names no dependency.
    fn dep_entry(&self, importer_path: &str, alias: &str) -> Option<PathBuf> {
        let pkg = self.owning_package(importer_path)?;
        self.deps.get(&pkg)?.get(alias).cloned()
    }

    /// The canonical root of the package that owns `importer_path`: the nearest
    /// ancestor directory present in the dependency map. `None` when the file is
    /// outside every resolved package (e.g. a bare script with no project).
    fn owning_package(&self, importer_path: &str) -> Option<PathBuf> {
        let canon = std::fs::canonicalize(importer_path).ok()?;
        let mut dir = canon.parent();
        while let Some(candidate) = dir {
            if self.deps.contains_key(candidate) {
                return Some(candidate.to_path_buf());
            }
            dir = candidate.parent();
        }
        None
    }

    /// Resolve one user-module import to a file id: canonicalize its path (which
    /// also detects a missing file), reject a self-import of the entry, check the
    /// active DFS path for a cycle, reuse an already-loaded file, or load it.
    fn resolve_user_module(
        &mut self,
        importer_path: &str,
        importer_source: &str,
        name: &str,
        raw_path: Option<&str>,
        target: &Path,
        span: Span,
    ) -> Result<u32, Diagnostic> {
        let key = match std::fs::canonicalize(target) {
            Ok(key) => key,
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
                return Err(match raw_path {
                    Some(raw) => {
                        missing_path_module_diag(importer_path, importer_source, raw, target, span)
                    }
                    None => missing_module_diag(importer_path, importer_source, name, span),
                });
            }
            Err(err) => {
                return Err(read_error_diag(
                    importer_path,
                    importer_source,
                    name,
                    target,
                    &err,
                    span,
                ))
            }
        };

        if key == self.entry_key {
            return Err(entry_import_diag(importer_path, importer_source, span));
        }

        if self.active.iter().any(|m| m.key == key) {
            let chain: Vec<String> = self.active.iter().map(|m| m.name.clone()).collect();
            return Err(cycle_diag(
                importer_path,
                importer_source,
                &chain,
                name,
                span,
            ));
        }

        if let Some(id) = self.by_path.get(&key) {
            return Ok(*id);
        }

        self.load_module(name, target, key, importer_path, importer_source, span)
    }

    /// Read, parse, and recursively resolve a user module at `target` (already
    /// canonicalized to `key`). Returns the new file id.
    fn load_module(
        &mut self,
        name: &str,
        target: &Path,
        key: PathBuf,
        importer_path: &str,
        importer_source: &str,
        span: Span,
    ) -> Result<u32, Diagnostic> {
        let source = match std::fs::read_to_string(target) {
            Ok(source) => source,
            Err(err) => {
                return Err(read_error_diag(
                    importer_path,
                    importer_source,
                    name,
                    target,
                    &err,
                    span,
                ))
            }
        };

        let path_str = target.to_string_lossy().into_owned();
        let script = crate::parser::parse(&path_str, &source)?;

        let file_id = self.next_id;
        self.next_id += 1;
        self.by_path.insert(key.clone(), file_id);

        self.active.push(ActiveModule {
            name: name.to_string(),
            key,
        });
        let (stdlib_imports, user_imports) = self.resolve_imports(&path_str, &source, &script)?;
        self.active.pop();

        self.modules.push(ProgramFile {
            file_id,
            is_entry: false,
            name: name.to_string(),
            path: path_str,
            source,
            script,
            stdlib_imports,
            user_imports,
        });
        self.init_order.push(file_id);
        Ok(file_id)
    }
}

fn module_path(dir: Option<&Path>, name: &str) -> PathBuf {
    let file = format!("{name}.doge");
    match dir {
        Some(dir) => dir.join(file),
        None => PathBuf::from(file),
    }
}

/// The file a string-path import `so "raw"` targets, relative to the importing
/// file's directory. `raw` is validated by the parser (relative, `/`-separated).
fn path_import_path(dir: Option<&Path>, raw: &str) -> PathBuf {
    match dir {
        Some(dir) => dir.join(raw),
        None => PathBuf::from(raw),
    }
}

/// A file's identity for dedup and cycle detection: its canonical path when it
/// resolves, else the path as given (so identity is still stable enough to work
/// with before the file is confirmed to exist).
fn canonical_key(path: &Path) -> PathBuf {
    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}

fn module_file_exists(dir: Option<&Path>, name: &str) -> bool {
    module_path(dir, name).is_file()
}

fn file_stem(path: &str) -> String {
    Path::new(path)
        .file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or(path)
        .to_string()
}