resolve.rs raw

//! The conventional filesystem [`ImportResolver`]: a search path of
//! directories probed in order for `Name.gdc`, the matching probe for
//! `(asset …)` references, and the eager load of the `.gdw` widget manifests
//! that make up a crate's extended widget vocabulary.
//!
//! The compiler itself never touches the filesystem — [`compile_with`]
//! (crate root) takes any resolver — but every host (the proc-macro, the
//! dev-mode hot-reload runtime, tests) resolves the same way, so the one
//! implementation lives here. Only `std::fs` is involved; the crate stays
//! dependency-free.
//!
//! [`compile_with`]: crate::compile_with

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

use crate::diagnostics;
use crate::manifest::compile_manifest;
use crate::registry::Registry;
use crate::{ImportResolver, WIDGET_MANIFEST_EXT, component_file_name};

/// Locate an `(asset "…")` reference: probe each asset-search directory in
/// order for the relative path, first hit wins.
///
/// Both consumers of an asset path go through this — the proc-macro turning
/// it into an `include_bytes!` target, the dev runtime reading it from disk —
/// so a dev mount finds exactly the file the typed build embedded. The error
/// lists everywhere that was looked, since "file not found" without the
/// search path is the least useful message a build can give.
pub fn find_asset(dirs: &[PathBuf], relative: &str) -> Result<PathBuf, String> {
    for dir in dirs {
        let candidate = dir.join(relative);
        if candidate.is_file() {
            return Ok(candidate);
        }
    }
    Err(format!(
        "no `{relative}` in the asset search path ({})",
        describe_dirs(dirs)
    ))
}

/// Load every `.gdw` on the widget search path into one [`Registry`].
///
/// Unlike a component, a widget manifest is never resolved by reference:
/// the vocabulary decides what counts as a widget name at all, so it has to be
/// complete before a single `.gdc` is read. Every manifest on the path is
/// therefore loaded up front — which is also what lets "unknown widget" name
/// the application's own widgets, and what makes a name claimed by two
/// manifests an error instead of a silent first-one-wins.
///
/// Files load in path order, and in sorted order within a directory, so a
/// duplicate is always reported against the same one of the pair. Directories
/// that do not exist are simply empty: configuring a widget path and not using
/// it yet is not an error. `visited` collects every manifest read, in load
/// order, for the rebuild tracking a host needs.
pub fn load_widgets(dirs: &[PathBuf], visited: &mut Vec<PathBuf>) -> Result<Registry, String> {
    let mut registry = Registry::default();
    for dir in dirs {
        let Ok(entries) = std::fs::read_dir(dir) else {
            continue;
        };
        let mut manifests: Vec<PathBuf> = entries
            .filter_map(|entry| entry.ok().map(|e| e.path()))
            .filter(|path| {
                path.extension()
                    .is_some_and(|ext| ext == WIDGET_MANIFEST_EXT)
            })
            .collect();
        manifests.sort();
        for path in manifests {
            let display = path.display().to_string();
            let source = std::fs::read_to_string(&path)
                .map_err(|e| format!("cannot read widget manifest {display}: {e}"))?;
            let broken = |diags: &[diagnostics::Diagnostic]| {
                format!(
                    "invalid widget manifest\n{}",
                    diagnostics::render(diags, &source, &display)
                )
            };
            let widgets = compile_manifest(&source).map_err(|diags| broken(&diags))?;
            for widget in widgets {
                registry.insert(widget).map_err(|diag| broken(&[diag]))?;
            }
            visited.push(path);
        }
    }
    Ok(registry)
}

fn describe_dirs(dirs: &[impl AsRef<Path>]) -> String {
    if dirs.is_empty() {
        return "empty".to_owned();
    }
    dirs.iter()
        .map(|d| d.as_ref().display().to_string())
        .collect::<Vec<_>>()
        .join(", ")
}

/// Resolves component names by probing `<dir>/Name.gdc` for each directory
/// in order; the first hit wins. Every file read is recorded in `visited`,
/// for rebuild tracking (proc-macro) and change watching (dev runtime).
pub struct SearchPathResolver {
    dirs: Vec<PathBuf>,
    /// Every file successfully read, in resolution order.
    pub visited: Vec<PathBuf>,
}

impl SearchPathResolver {
    pub fn new(dirs: impl IntoIterator<Item = PathBuf>) -> Self {
        Self {
            dirs: dirs.into_iter().collect(),
            visited: Vec::new(),
        }
    }

    /// The directories probed, in order.
    pub fn dirs(&self) -> &[PathBuf] {
        &self.dirs
    }
}

impl ImportResolver for SearchPathResolver {
    fn resolve(&mut self, name: &str) -> Result<(String, String), String> {
        let file = component_file_name(name);
        for dir in &self.dirs {
            let candidate = dir.join(&file);
            if let Ok(source) = std::fs::read_to_string(&candidate) {
                self.visited.push(candidate.clone());
                return Ok((candidate.display().to_string(), source));
            }
        }
        Err(format!(
            "no `{file}` in the component search path ({})",
            describe_dirs(&self.dirs)
        ))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn probes_directories_in_order_and_records_visits() {
        let base = std::env::temp_dir().join(format!("guiduck-resolve-{}", std::process::id()));
        let (first, second) = (base.join("first"), base.join("second"));
        std::fs::create_dir_all(&first).unwrap();
        std::fs::create_dir_all(&second).unwrap();
        std::fs::write(first.join("Both.gdc"), "from first").unwrap();
        std::fs::write(second.join("Both.gdc"), "from second").unwrap();
        std::fs::write(second.join("OnlySecond.gdc"), "second only").unwrap();

        let mut resolver = SearchPathResolver::new([first.clone(), second.clone()]);
        let (_, source) = resolver.resolve("Both").expect("resolves");
        assert_eq!(source, "from first", "first directory wins");
        let (_, source) = resolver.resolve("OnlySecond").expect("resolves");
        assert_eq!(source, "second only");

        let missing = resolver.resolve("Nowhere").expect_err("missing");
        assert!(missing.contains("Nowhere.gdc"), "{missing}");
        assert!(
            missing.contains(&first.display().to_string()),
            "error lists the probed directories: {missing}"
        );

        assert_eq!(
            resolver.visited,
            vec![first.join("Both.gdc"), second.join("OnlySecond.gdc")]
        );
        std::fs::remove_dir_all(&base).ok();
    }
}