solverforge-macros 0.12.1

Derive macros for SolverForge constraint solver
Documentation
fn resolve_root(root: &LitStr) -> Result<PathBuf> {
    let value = root.value();
    let root_path = PathBuf::from(&value);
    if root_path.is_absolute() {
        return Ok(root_path);
    }

    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").map_err(|_| {
        Error::new_spanned(root, "planning_model! could not resolve CARGO_MANIFEST_DIR")
    })?;
    let manifest_root = PathBuf::from(&manifest_dir).join(&root_path);
    if manifest_root.exists() {
        return Ok(manifest_root);
    }
    for ancestor in PathBuf::from(&manifest_dir).ancestors() {
        let candidate = ancestor.join(&root_path);
        if candidate.exists() {
            return Ok(candidate);
        }
    }

    if let Ok(current_dir) = std::env::current_dir() {
        let current_root = current_dir.join(&root_path);
        if current_root.exists() {
            return Ok(current_root);
        }
        for ancestor in current_dir.ancestors() {
            let candidate = ancestor.join(&root_path);
            if candidate.exists() {
                return Ok(candidate);
            }
        }
    }

    Ok(PathBuf::from(manifest_dir).join(root_path))
}

fn read_modules(items: &[ManifestItem], root: &Path) -> Result<Vec<ModuleSource>> {
    let mut modules = Vec::new();
    for item in items {
        let ManifestItem::Mod(item_mod) = item else {
            continue;
        };
        let ident = item_mod.ident.clone();
        let path = module_path(root, &ident).ok_or_else(|| {
            Error::new_spanned(
                item_mod,
                format!(
                    "planning_model! module `{ident}` must resolve to `{}/{ident}.rs` or `{}/{ident}/mod.rs`",
                    root.display(),
                    root.display(),
                ),
            )
        })?;
        let source = std::fs::read_to_string(&path).map_err(|err| {
            Error::new_spanned(
                item_mod,
                format!(
                    "planning_model! could not read module `{ident}` at `{}`: {err}",
                    path.display(),
                ),
            )
        })?;
        let file = syn::parse_file(&source).map_err(|err| {
            Error::new_spanned(
                item_mod,
                format!(
                    "planning_model! could not parse module `{ident}` at `{}`: {err}",
                    path.display(),
                ),
            )
        })?;
        modules.push(ModuleSource { ident, path, file });
    }
    Ok(modules)
}

fn module_path(root: &Path, ident: &Ident) -> Option<PathBuf> {
    let file_path = root.join(format!("{ident}.rs"));
    if file_path.exists() {
        return Some(file_path);
    }
    let mod_path = root.join(ident.to_string()).join("mod.rs");
    mod_path.exists().then_some(mod_path)
}

fn collect_model_metadata(
    items: &[ManifestItem],
    modules: &[ModuleSource],
) -> Result<ModelMetadata> {
    let mut solution: Option<SolutionMetadata> = None;
    let mut entities = BTreeMap::new();
    let mut facts = BTreeSet::new();
    let mut raw_aliases = BTreeMap::new();

    for item in items {
        if let ManifestItem::Use(item_use) = item {
            collect_use_aliases(item_use, &mut raw_aliases)?;
        }
    }

    for module in modules {
        for item in &module.file.items {
            match item {
                Item::Struct(item_struct) => {
                    if has_attribute(&item_struct.attrs, "planning_solution") {
                        if let Some(existing) = &solution {
                            return Err(Error::new_spanned(
                                item_struct,
                                format!(
                                    "planning_model! found duplicate #[planning_solution]; `{}` is already the model solution",
                                    existing.ident
                                ),
                            ));
                        }
                        solution = Some(parse_solution(module, item_struct)?);
                    }
                    if has_attribute(&item_struct.attrs, "planning_entity") {
                        let metadata = parse_entity(module, item_struct)?;
                        entities.insert(metadata.type_name.clone(), metadata);
                    }
                    if has_attribute(&item_struct.attrs, "problem_fact") {
                        if let Some(attr) = get_attribute(&item_struct.attrs, "problem_fact") {
                            validate_problem_fact_attribute(attr)?;
                        }
                        validate_problem_fact_fields(item_struct)?;
                        facts.insert(item_struct.ident.to_string());
                    }
                }
                Item::Type(item_type) => {
                    if let Some(target) = alias_target_name(item_type) {
                        insert_alias(
                            &mut raw_aliases,
                            item_type.ident.to_string(),
                            target,
                            item_type,
                        )?;
                    }
                }
                Item::Use(item_use) if matches!(item_use.vis, Visibility::Public(_)) => {
                    collect_use_aliases(item_use, &mut raw_aliases)?;
                }
                _ => {}
            }
        }
    }

    let Some(solution) = solution else {
        return Err(Error::new(
            proc_macro2::Span::call_site(),
            "planning_model! requires exactly one #[planning_solution] in the listed modules",
        ));
    };

    let aliases = resolve_aliases(&raw_aliases)?;
    validate_collections(&solution, &entities, &facts, &aliases)?;
    validate_list_element_sources(&solution, &entities, &aliases)?;

    Ok(ModelMetadata {
        solution,
        entities,
        aliases,
    })
}

fn alias_target_name(item_type: &ItemType) -> Option<String> {
    type_name(&item_type.ty)
}

fn collect_use_aliases(item_use: &ItemUse, aliases: &mut BTreeMap<String, String>) -> Result<()> {
    fn walk(
        tree: &UseTree,
        aliases: &mut BTreeMap<String, String>,
        span: &impl ToTokens,
    ) -> Result<()> {
        match tree {
            UseTree::Path(path) => walk(&path.tree, aliases, span),
            UseTree::Rename(rename) => insert_alias(
                aliases,
                rename.rename.to_string(),
                rename.ident.to_string(),
                span,
            ),
            UseTree::Group(group) => {
                for item in &group.items {
                    walk(item, aliases, span)?;
                }
                Ok(())
            }
            UseTree::Name(_) | UseTree::Glob(_) => Ok(()),
        }
    }

    walk(&item_use.tree, aliases, item_use)
}

fn insert_alias(
    aliases: &mut BTreeMap<String, String>,
    alias: String,
    target: String,
    span: &impl ToTokens,
) -> Result<()> {
    if alias == target {
        return Ok(());
    }
    if let Some(existing) = aliases.get(&alias) {
        if existing != &target {
            return Err(Error::new_spanned(
                span,
                format!(
                    "planning_model! alias `{alias}` points to both `{existing}` and `{target}`",
                ),
            ));
        }
        return Ok(());
    }
    aliases.insert(alias, target);
    Ok(())
}

fn resolve_aliases(raw_aliases: &BTreeMap<String, String>) -> Result<BTreeMap<String, String>> {
    fn resolve_one(
        name: &str,
        raw_aliases: &BTreeMap<String, String>,
        stack: &mut Vec<String>,
    ) -> Result<String> {
        let Some(target) = raw_aliases.get(name) else {
            return Ok(name.to_string());
        };
        if stack.iter().any(|seen| seen == name) {
            stack.push(name.to_string());
            return Err(Error::new(
                proc_macro2::Span::call_site(),
                format!(
                    "planning_model! alias cycle detected: {}",
                    stack.join(" -> "),
                ),
            ));
        }
        stack.push(name.to_string());
        let resolved = resolve_one(target, raw_aliases, stack)?;
        stack.pop();
        Ok(resolved)
    }

    let mut resolved = BTreeMap::new();
    for alias in raw_aliases.keys() {
        resolved.insert(
            alias.clone(),
            resolve_one(alias, raw_aliases, &mut Vec::new())?,
        );
    }
    Ok(resolved)
}

fn canonical_type_name<'a>(aliases: &'a BTreeMap<String, String>, type_name: &'a str) -> &'a str {
    aliases
        .get(type_name)
        .map(String::as_str)
        .unwrap_or(type_name)
}