Skip to main content

doge_compiler/
project.rs

1//! Turning a project's `doge.toml` into a [`DependencyMap`] the module loader can
2//! resolve `so <alias>` imports against. Path dependencies are resolved from disk
3//! here; git dependencies are handed to a caller-supplied `git_dir` closure (only
4//! `dogelang` shells out to git and touches the cache — this crate stays free of
5//! network and cache concerns). Resolution follows the dependency graph
6//! transitively, keying each package by its canonical root directory so a file's
7//! owning package (and thus which dependencies it can see) is unambiguous.
8
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12use crate::diagnostics::{source_line, split_source_lines, Diagnostic};
13use crate::manifest::{self, Dependency, DependencySource, GitRev, Manifest, MANIFEST_NAME};
14
15/// Every package in a resolved project: canonical package-root directory → the
16/// aliases that package declares → the canonical entry `.doge` file each binds.
17/// The loader finds a file's owning package by walking its canonical ancestors to
18/// the nearest directory present as a key here.
19pub type DependencyMap = HashMap<PathBuf, HashMap<String, PathBuf>>;
20
21const MANIFEST_HEADLINE: &str = "very manifest. much confuse.";
22const RESOLVE_HEADLINE: &str = "very dependency. much missing.";
23
24/// The nearest ancestor of `start` (inclusive) that holds a `doge.toml`, or
25/// `None` when `start` is not inside any project.
26pub fn discover_root(start: &Path) -> Option<PathBuf> {
27    let mut current = Some(start);
28    while let Some(dir) = current {
29        if dir.join(MANIFEST_NAME).is_file() {
30            return Some(dir.to_path_buf());
31        }
32        current = dir.parent();
33    }
34    None
35}
36
37/// Read and parse the `doge.toml` at `root`.
38pub fn read_manifest(root: &Path) -> Result<Manifest, Diagnostic> {
39    let path = root.join(MANIFEST_NAME);
40    let source = std::fs::read_to_string(&path).map_err(|err| {
41        Diagnostic::new(
42            path.to_string_lossy(),
43            1,
44            1,
45            "",
46            format!("doge could not read the manifest: {err}"),
47        )
48        .with_headline(MANIFEST_HEADLINE)
49        .with_hint("every project has a doge.toml at its root")
50    })?;
51    manifest::parse(&path.to_string_lossy(), &source)
52}
53
54/// Resolve `root`'s dependency graph into a [`DependencyMap`]. `git_dir` turns a
55/// git source into a local package directory (fetching or reading a cache); a
56/// path dependency is resolved relative to the package declaring it.
57pub fn resolve_project<F>(root: &Path, git_dir: &mut F) -> Result<DependencyMap, Diagnostic>
58where
59    F: FnMut(&str, &GitRev) -> Result<PathBuf, String>,
60{
61    let mut map = DependencyMap::new();
62    let root_manifest = read_manifest(root)?;
63    resolve_package(root, &root_manifest, &mut map, git_dir)?;
64    Ok(map)
65}
66
67/// Resolve one package's dependencies into `map` and recurse into each. `manifest`
68/// is the already-parsed manifest for `pkg`.
69fn resolve_package<F>(
70    pkg: &Path,
71    manifest: &Manifest,
72    map: &mut DependencyMap,
73    git_dir: &mut F,
74) -> Result<(), Diagnostic>
75where
76    F: FnMut(&str, &GitRev) -> Result<PathBuf, String>,
77{
78    let canon = std::fs::canonicalize(pkg).map_err(|err| {
79        Diagnostic::new(
80            pkg.to_string_lossy(),
81            1,
82            1,
83            "",
84            format!("doge could not open the project directory: {err}"),
85        )
86        .with_headline(RESOLVE_HEADLINE)
87        .with_hint("check the project path")
88    })?;
89    // Reserve the slot before recursing so a dependency cycle terminates instead
90    // of looping; the real entry map replaces it once the deps are resolved.
91    map.insert(canon.clone(), HashMap::new());
92
93    let manifest_path = pkg.join(MANIFEST_NAME).to_string_lossy().into_owned();
94    let mut aliases = HashMap::new();
95
96    for dep in &manifest.dependencies {
97        let dep_dir = dep_directory(pkg, &manifest_path, dep, git_dir)?;
98        let dep_manifest = read_dep_manifest(&dep_dir, &manifest_path, dep)?;
99        let entry = dep_dir.join(&dep_manifest.entry);
100        let entry_canon = std::fs::canonicalize(&entry).map_err(|_| {
101            resolve_diag(
102                &manifest_path,
103                dep,
104                &format!(
105                    "the {} package has no entry file {}",
106                    dep.alias, dep_manifest.entry
107                ),
108                "check the dependency's [package] entry",
109            )
110        })?;
111        aliases.insert(dep.alias.clone(), entry_canon);
112
113        if let Ok(dep_canon) = std::fs::canonicalize(&dep_dir) {
114            if !map.contains_key(&dep_canon) {
115                resolve_package(&dep_dir, &dep_manifest, map, git_dir)?;
116            }
117        }
118    }
119
120    map.insert(canon, aliases);
121    Ok(())
122}
123
124/// The local directory for one dependency: a path source resolved against the
125/// declaring package, or a git source handed to `git_dir`.
126fn dep_directory<F>(
127    pkg: &Path,
128    manifest_path: &str,
129    dep: &Dependency,
130    git_dir: &mut F,
131) -> Result<PathBuf, Diagnostic>
132where
133    F: FnMut(&str, &GitRev) -> Result<PathBuf, String>,
134{
135    match &dep.source {
136        DependencySource::Path(rel) => Ok(pkg.join(rel)),
137        DependencySource::Git { url, rev } => git_dir(url, rev).map_err(|message| {
138            resolve_diag(
139                manifest_path,
140                dep,
141                &message,
142                "run doge bark to fetch dependencies",
143            )
144        }),
145    }
146}
147
148/// Read a dependency package's own manifest, reporting the failure against the
149/// line that declared it in the parent manifest.
150fn read_dep_manifest(
151    dep_dir: &Path,
152    manifest_path: &str,
153    dep: &Dependency,
154) -> Result<Manifest, Diagnostic> {
155    let path = dep_dir.join(MANIFEST_NAME);
156    let source = std::fs::read_to_string(&path).map_err(|_| {
157        resolve_diag(
158            manifest_path,
159            dep,
160            &format!("doge found no package at {}", dep_dir.display()),
161            "a dependency is a directory with its own doge.toml",
162        )
163    })?;
164    manifest::parse(&path.to_string_lossy(), &source)
165}
166
167/// A dependency-resolution diagnostic anchored at the dependency's line in the
168/// declaring `doge.toml`.
169fn resolve_diag(manifest_path: &str, dep: &Dependency, message: &str, hint: &str) -> Diagnostic {
170    let line = std::fs::read_to_string(manifest_path)
171        .map(|source| source_line(&split_source_lines(&source), dep.line))
172        .unwrap_or_default();
173    Diagnostic::new(manifest_path, dep.line, 1, line, message)
174        .with_headline(RESOLVE_HEADLINE)
175        .with_hint(hint)
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    struct TempDir(PathBuf);
183
184    impl TempDir {
185        fn new(tag: &str) -> TempDir {
186            let dir = std::env::temp_dir().join(format!("doge-projtest-{tag}"));
187            let _ = std::fs::remove_dir_all(&dir);
188            std::fs::create_dir_all(&dir).expect("create temp dir");
189            TempDir(dir)
190        }
191        fn write(&self, name: &str, contents: &str) {
192            let path = self.0.join(name);
193            if let Some(parent) = path.parent() {
194                std::fs::create_dir_all(parent).expect("create fixture dir");
195            }
196            std::fs::write(&path, contents).expect("write fixture");
197        }
198    }
199
200    impl Drop for TempDir {
201        fn drop(&mut self) {
202            let _ = std::fs::remove_dir_all(&self.0);
203        }
204    }
205
206    fn canon(path: &Path) -> PathBuf {
207        std::fs::canonicalize(path).expect("path should exist")
208    }
209
210    #[test]
211    fn resolves_a_transitive_path_dependency_graph() {
212        let dir = TempDir::new("path-graph");
213        dir.write("util/doge.toml", "[package]\nname = \"util\"\n");
214        dir.write(
215            "util/main.doge",
216            "such id much n:\n    return n\nwow\nwow\n",
217        );
218        dir.write(
219            "greet/doge.toml",
220            "[package]\nname = \"greet\"\n\n[dependencies]\nutil = { path = \"../util\" }\n",
221        );
222        dir.write(
223            "greet/main.doge",
224            "so util\nsuch hi:\n    return 1\nwow\nwow\n",
225        );
226        dir.write(
227            "app/doge.toml",
228            "[package]\nname = \"app\"\n\n[dependencies]\ngreet = { path = \"../greet\" }\n",
229        );
230        dir.write("app/main.doge", "so greet\nbark greet.hi()\nwow\n");
231
232        let app = dir.0.join("app");
233        let map = resolve_project(&app, &mut |_, _| unreachable!("no git deps here"))
234            .expect("should resolve");
235
236        assert_eq!(
237            map[&canon(&app)]["greet"],
238            canon(&dir.0.join("greet/main.doge"))
239        );
240        assert_eq!(
241            map[&canon(&dir.0.join("greet"))]["util"],
242            canon(&dir.0.join("util/main.doge"))
243        );
244        assert!(map[&canon(&dir.0.join("util"))].is_empty());
245    }
246
247    #[test]
248    fn a_git_dependency_is_resolved_through_the_closure() {
249        let dir = TempDir::new("git-closure");
250        // The "fetched" git package, prepared on disk by the closure.
251        dir.write("vendor/cool/doge.toml", "[package]\nname = \"cool\"\n");
252        dir.write("vendor/cool/main.doge", "such f:\n    return 1\nwow\nwow\n");
253        dir.write(
254            "app/doge.toml",
255            "[package]\nname = \"app\"\n\n[dependencies]\ncool = { git = \"https://example.com/cool.git\", tag = \"v1\" }\n",
256        );
257        dir.write("app/main.doge", "so cool\nbark cool.f()\nwow\n");
258
259        let vendor = dir.0.join("vendor/cool");
260        let mut calls = 0;
261        let map = resolve_project(&dir.0.join("app"), &mut |url, rev| {
262            calls += 1;
263            assert_eq!(url, "https://example.com/cool.git");
264            assert_eq!(rev.as_ref_name(), Some("v1"));
265            Ok(vendor.clone())
266        })
267        .expect("should resolve");
268
269        assert_eq!(calls, 1, "the git closure runs once for the git dep");
270        assert_eq!(
271            map[&canon(&dir.0.join("app"))]["cool"],
272            canon(&vendor.join("main.doge"))
273        );
274    }
275
276    #[test]
277    fn a_missing_path_dependency_is_reported() {
278        let dir = TempDir::new("path-missing");
279        dir.write(
280            "app/doge.toml",
281            "[package]\nname = \"app\"\n\n[dependencies]\ngone = { path = \"../gone\" }\n",
282        );
283        dir.write("app/main.doge", "so gone\nwow\n");
284
285        let err = resolve_project(&dir.0.join("app"), &mut |_, _| unreachable!())
286            .expect_err("a missing path dep should fail");
287        assert_eq!(err.headline, RESOLVE_HEADLINE);
288        assert!(err.message.contains("no package"));
289    }
290}