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