hunyi 0.3.0

渾儀 (Hunyi) — Tianheng's semantic (AST/syn) observation dimension, the complement of the static import boundary. Declare in Rust how a module's public surface must behave: what its API must not expose (types — including named public re-exports and, opt-in, a trait impl's impl-site positions — and no dyn / impl Trait or async fn seam), where a trait may be implemented, that it declares no bare pub, and which markers a type must not acquire — observed via syn, reacted in CI. The heavy syn dependency is quarantined here, never in the core.
Documentation
//! Module resolution: descends a module path from crate root to its items and source files.
//! Handles inline `mod x { ... }`, file `mod x;`, and `#[path]` remaps.

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

use crate::errors::{
    missing_module_file_error, unknown_module_error, unparseable_source_error,
    unreadable_source_error,
};
use crate::resolve::strip_raw;
use crate::syn_util::{direct_path_value, has_cfg_attr, has_path_attr};

/// The path segments of a module relative to the crate root.
fn module_segments(module: &str) -> Vec<String> {
    module
        .split("::")
        .map(strip_raw)
        .enumerate()
        .filter(|(i, seg)| !(*i == 0 && seg == "crate"))
        .map(|(_, seg)| seg)
        .filter(|seg| !seg.is_empty())
        .collect()
}

/// Resolves a module path to its items, paired with the source file and branch index of origin.
pub(crate) fn resolve_module_items_with_files(
    src_dir: &Path,
    root_file: &Path,
    module: &str,
    crate_package: &str,
) -> Result<Vec<(syn::Item, PathBuf, usize)>, String> {
    let branches = resolve_module_branches(src_dir, root_file, module, crate_package)?;
    let mut items = Vec::new();
    for (branch_index, (branch_items, file, ..)) in branches.iter().enumerate() {
        items.extend(
            branch_items
                .iter()
                .cloned()
                .map(|item| (item, file.clone(), branch_index)),
        );
    }
    Ok(items)
}
/// Resolves a module path to its primary source file (test-only helper).
#[cfg(test)]
pub(crate) fn resolve_module_file(
    src_dir: &Path,
    root_file: &Path,
    module: &str,
    crate_package: &str,
) -> Result<PathBuf, String> {
    resolve_module(src_dir, root_file, module, crate_package).map(|(_items, file)| file)
}

/// The shared module resolution backing [`resolve_module_file`] (test-only): the items a module
/// owns **and** the file they live in, from one descent, so the two views come from the same
/// traversal and never drift (a `mod`-resolution divergence is the false-negative class the
/// project forbids).
#[cfg(test)]
fn resolve_module(
    src_dir: &Path,
    root_file: &Path,
    module: &str,
    crate_package: &str,
) -> Result<(Vec<syn::Item>, PathBuf), String> {
    resolve_module_root(src_dir, root_file, module, crate_package)
        .map(|(items, file, _child_dir, _path_base)| (items, file))
}

/// Resolves a module path to its items, file, child directory, and path base (test-only helper).
#[cfg(test)]
pub(crate) fn resolve_module_root(
    src_dir: &Path,
    root_file: &Path,
    module: &str,
    crate_package: &str,
) -> Result<(Vec<syn::Item>, PathBuf, PathBuf, PathBuf), String> {
    let branches = resolve_module_branches(src_dir, root_file, module, crate_package)?;
    let mut items = Vec::new();
    for (branch_items, ..) in &branches {
        items.extend(branch_items.iter().cloned());
    }
    let (_, file, child_dir, path_base) = &branches[0];
    Ok((items, file.clone(), child_dir.clone(), path_base.clone()))
}

/// The full descent result: every surviving [`Branch`] on its own, each keeping its own items
/// paired with the directories they must be resolved against. A subtree walk that continues
/// descending below the anchor needs this — never the single, unioned-items/first-branch-only
/// shape the test-only `resolve_module_root` returns, which is correct only for a single-module
/// violation's "one file" report and actively wrong for further descent (a non-first branch's own
/// child would resolve against a directory pair that isn't its own).
#[allow(clippy::type_complexity)]
pub(crate) fn resolve_module_branches(
    src_dir: &Path,
    root_file: &Path,
    module: &str,
    crate_package: &str,
) -> Result<Vec<(Vec<syn::Item>, PathBuf, PathBuf, PathBuf)>, String> {
    let root = read_parse(root_file)?;
    let segments = module_segments(module);
    let initial = Branch {
        items: root.items,
        current_file: root_file.to_path_buf(),
        child_dir: src_dir.to_path_buf(),
        // The crate root is mod-rs-like: its own directory (`src_dir`) is the `#[path]` base too.
        path_base: src_dir.to_path_buf(),
    };
    let branches = descend(vec![initial], &segments, module, crate_package)?;
    Ok(branches
        .into_iter()
        .map(|b| (b.items, b.current_file, b.child_dir, b.path_base))
        .collect())
}

/// One candidate continuation of the descent: the items visible at this position, the file they
/// live in, and the two directories a further segment resolves from (`child_dir` for a
/// conventional file-form child, `path_base` for a `#[path]` written at this position — see the
/// module-level doc for why these can differ). Ordinarily there is exactly one branch; a
/// mutually-exclusive `#[cfg]` split (an inline variant paired with a file-form sibling) produces
/// two **independent** branches rather than merging their items into one, because each has its
/// own correct directories for anything nested *beneath* the split — merging into one shared pair
/// of directories silently mis-resolved a further segment whenever the file-form sibling's own
/// directories differed from the inline accumulation (the false negative this design fixes).
/// The test-only `resolve_module_root` merges every surviving branch's items back into one list
/// at the leaf; production callers use [`resolve_module_items_with_files`] instead, which keeps
/// each item paired with its own branch's file rather than collapsing to the first.
struct Branch {
    items: Vec<syn::Item>,
    current_file: PathBuf,
    child_dir: PathBuf,
    path_base: PathBuf,
}

// `path_base` is the directory a non-inline `#[path]` at the current position resolves from: the
// containing file's own directory at file scope, but with each enclosing inline `mod` name
// accumulated onto it (rustc adds the inline-module chain as directory components). It equals
// `current_file`'s parent at file scope and diverges from it only after descending an inline block —
// which is exactly the case `current_file.parent()` alone got wrong (a false negative when a
// `#[path]` relocated inside an inline block was resolved from the enclosing file's dir).
fn descend(
    branches: Vec<Branch>,
    segments: &[String],
    module: &str,
    crate_package: &str,
) -> Result<Vec<Branch>, String> {
    let Some(seg) = segments.first() else {
        return Ok(branches);
    };
    let mut next_branches = Vec::new();
    for branch in &branches {
        // Every same-named **inline** `mod x { … }` for this segment produces its OWN branch, not
        // merged into a shared one: a `#[cfg(..)] mod x {..}` / `#[cfg(..)] mod x {..}` pair parses
        // as two separate inline items (syn does not evaluate `cfg`), and while both are
        // OBSERVED (matching the crate-wide scan's observe-all, cfg-blind policy —
        // `scan::resolve_child_modules`), merging their items into one shared items list also
        // merges everything a downstream caller derives from those items — a `use`-map, a
        // child-module-name shadow set — even though the two arms are never simultaneously open in
        // any real build. That conflation is the identical false-negative class this whole
        // resolver exists to prevent, just one hop past item observation itself (found on a
        // round-8 adversarial review; see `PROJECT.md`'s Decisions): merging genuinely produces
        // every item, but a caller resolving one arm's own bare reference through the OTHER arm's
        // `use`/child-module declaration silently misresolves it. Keeping every inline occurrence
        // as its own independent branch — exactly like the file-form loop below already does —
        // means `resolve_module_items_with_files`' per-branch pairing keeps each arm's items
        // (and, once the caller groups by branch rather than file, each arm's resolution context)
        // distinct even though both arms share the identical enclosing `current_file`. Inline
        // items live in the enclosing file, so `current_file` is unchanged; file-children live
        // under `<child_dir>/x/` by default — UNLESS an unconditional `#[path = "…"]` precedes
        // this inline header, which relocates that base (rustc's rule for an inline module too;
        // it is NOT a no-op merely because the header has a body — verified against a real
        // build), resolved per-occurrence so two inline arms can each carry their own relocation
        // (or lack thereof) without one overwriting the other. A `cfg_attr`-wrapped `path` is not
        // followed (the same cfg-conditional bound as the file-form case below), so it does not
        // relocate.
        for item in &branch.items {
            if let syn::Item::Mod(module_item) = item {
                if strip_raw(&module_item.ident.to_string()) != *seg {
                    continue;
                }
                let Some((_, inner)) = &module_item.content else {
                    continue; // a file-form declaration of this name; handled below
                };
                let relocated_base =
                    direct_path_value(&module_item.attrs).map(|rel| branch.path_base.join(rel));
                let inline_dir = relocated_base.unwrap_or_else(|| branch.child_dir.join(seg));
                next_branches.push(Branch {
                    items: inner.clone(),
                    current_file: branch.current_file.clone(),
                    child_dir: inline_dir.clone(),
                    path_base: inline_dir,
                });
            }
        }
        // Resolve EVERY file-form `mod seg;` too — ALWAYS attempted, not only when no inline
        // variant was found above, and never stopping at the first match: a mutually-exclusive
        // `#[cfg]` per-platform shim can legitimately pair an inline variant with a file-form
        // variant, or pair a PLAIN `mod seg;` with an unconditional `#[path]`-remapped `mod seg;`
        // of the same name — two declarations that, once `#[path]` is followed, need not name the
        // same file at all. Matching `resolve_child_modules`'s own crate-wide policy (which never
        // breaks after one match either), every non-inline declaration for this segment produces
        // its own branch; picking only the first was a real false negative (a forbidden item
        // declared only in the sibling that lost the race passed unobserved, nondeterministically
        // depending on source order).
        let mut file_forms: Vec<(Vec<syn::Item>, PathBuf, PathBuf, PathBuf)> = Vec::new();
        // Deduped by the resolved file's CANONICAL path: two mutually-exclusive `#[cfg]` arms
        // that both plainly declare `mod seg;` (no `#[path]`, so both are found via the identical
        // `locate_module_file` lookup) are the same real file compiled twice by neither build —
        // pushing a branch per occurrence would duplicate that file's items in the merged result,
        // inflating one real violation into two apparently-distinct findings with no way for
        // exact-string finding dedup to collapse them back (their internal unsupported-syntax
        // sentinels can differ before the public observation path rejects them).
        let mut seen_files: HashSet<PathBuf> = HashSet::new();
        for item in &branch.items {
            if let syn::Item::Mod(module_item) = item {
                if module_item.content.is_some() {
                    continue; // an inline body for this name is already collected above
                }
                if strip_raw(&module_item.ident.to_string()) != *seg {
                    continue;
                }
                // Follow an **unconditional** `#[path = "…"]` file module. rustc resolves a
                // non-inline `#[path]` relative to `path_base` — the containing file's own
                // directory, with each enclosing inline-`mod` name accumulated onto it — NOT
                // `child_dir` (the conventional-child base `<dir>/seg/` for a non-mod-rs file),
                // the false-negative the whole-crate walk shares. Load `<path_base>/<rel>`, and
                // since a `#[path]`-loaded file is mod-rs-like, its own children (both
                // conventional and any further `#[path]`) resolve from ITS OWN directory too — so
                // `path_base` and the child-continuation directory are the SAME value here (unlike
                // the plain, non-`#[path]` case below, where they differ for a flat `seg.rs`). An
                // inline `#[path]` (has a body) or a `cfg_attr`-wrapped `#[path]` is not followed
                // by this targeted resolver — a narrow **fail-loud** bound (exit 2 "cannot
                // judge"), never a silent pass; the whole-crate walks follow the unconditional
                // form.
                if let Some(rel) = direct_path_value(&module_item.attrs) {
                    let file = branch.path_base.join(&rel);
                    if !file.is_file() {
                        // A BARE `#[cfg(pred)]` co-occurring with this unconditional `#[path]`
                        // (e.g. `#[cfg(windows)] #[path = "windows_impl.rs"] mod imp;`) removes
                        // the whole item, `#[path]` included, when `pred` is false — rustc never
                        // attempts to resolve the target on such a build (verified against a real
                        // build: this compiles cleanly with the target entirely absent). Tolerate
                        // exactly like the plain-missing-file case below; an unconditional item
                        // with no accompanying `#[cfg]` still fails loud.
                        if has_cfg_attr(&module_item.attrs) {
                            continue;
                        }
                        return Err(missing_module_file_error(module, crate_package));
                    }
                    if !xingbiao::try_visit(&mut seen_files, &file)? {
                        continue;
                    }
                    let parsed = read_parse(&file)?;
                    let next_dir = file
                        .parent()
                        .map(Path::to_path_buf)
                        .unwrap_or_else(|| branch.child_dir.clone());
                    file_forms.push((parsed.items, file, next_dir.clone(), next_dir));
                    continue;
                }
                if has_path_attr(&module_item.attrs) {
                    continue;
                }
                // A `#[cfg]`-gated plain module may legitimately have no source file when the
                // predicate is off (a standard optional-feature pattern) — matching
                // `scan::resolve_child_modules`'s identical tolerance for the crate-wide walk, so
                // this single-module-anchored descent no longer disagrees with its own sibling
                // walker on the identical shape (the 0.2.2 lesson: the two walkers' missing-file
                // policies had silently drifted apart). An unconditional missing file stays a real
                // scan error (exit 2).
                let Some(file) = locate_module_file(&branch.child_dir, seg) else {
                    if has_cfg_attr(&module_item.attrs) {
                        continue;
                    }
                    return Err(missing_module_file_error(module, crate_package));
                };
                if !xingbiao::try_visit(&mut seen_files, &file)? {
                    continue;
                }
                let parsed = read_parse(&file)?;
                // The loaded file's own directory is the base for a `#[path]` written at its top
                // level (`<dir>` for `seg.rs`, `<dir>/seg` for `seg/mod.rs`); its CONVENTIONAL
                // children (a further plain `mod y;`) always live under `<child_dir>/seg`
                // regardless — the two conventions only diverge for `#[path]`-resolution
                // purposes, never for where a plain child nests.
                let own_dir = file
                    .parent()
                    .map(Path::to_path_buf)
                    .unwrap_or_else(|| branch.child_dir.join(seg));
                file_forms.push((parsed.items, file, own_dir, branch.child_dir.join(seg)));
            }
        }
        for (file_items, file, path_base, child_dir) in file_forms {
            next_branches.push(Branch {
                items: file_items,
                current_file: file,
                child_dir,
                path_base,
            });
        }
    }
    if next_branches.is_empty() {
        return Err(unknown_module_error(module, crate_package));
    }
    descend(next_branches, &segments[1..], module, crate_package)
}

pub(crate) fn locate_module_file(child_dir: &Path, seg: &str) -> Option<PathBuf> {
    let flat = child_dir.join(format!("{seg}.rs"));
    if flat.is_file() {
        return Some(flat);
    }
    let nested = child_dir.join(seg).join("mod.rs");
    if nested.is_file() {
        return Some(nested);
    }
    None
}

pub(crate) fn read_parse(file: &Path) -> Result<syn::File, String> {
    let text = std::fs::read_to_string(file)
        .map_err(|err| unreadable_source_error(file, &err.to_string()))?;
    syn::parse_file(&text).map_err(|err| unparseable_source_error(file, &err.to_string()))
}