pub mod source_map;

pub use source_map::{FileId, SourceMap};

use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, atomic::AtomicU64};

use crate::interpreter::{ExportBundle, ImportSet, InterpretErrorKind, Interpreter};
use crate::parser::{Ast, Parser};
use crate::scanner::{ScanError, Scanner, Tokens};
use crate::{InterpretError, InterpretWarning, Location, Network, NexusError, Source, Stmt};

/// Why a `use NAME;` import could not be resolved to a [`FileId`].
///
/// Produced by [`Session::resolve_import`]. The interpreter translates these into
/// user-facing [`InterpretError`](crate::InterpretError) diagnostics anchored at the
/// `use` statement.
///
#[derive(Debug)]
pub(crate) enum ImportError {
    /// No file `NAME.nxs` was found in the importer's directory or any search path.
    NotFound,

    /// A located file could not be read.
    Io(std::io::Error),
}

/// Import-resolution policy for a [`Session`]; see [`Session::with_import_policy`].
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ImportPolicy {
    /// Search the importer's own directory, then configured search paths; an in-memory
    /// source with no directory of its own falls back to the current working
    /// directory. The default; matches the interpreter's historical behavior.
    #[default]
    Enabled,
    /// Search only the importer's own directory (if it has one; i.e. it was loaded
    /// from a real file) and configured search paths. In-memory sources never fall
    /// back to the current working directory. The right default for embedders
    /// evaluating untrusted source text: `use` cannot read arbitrary files off the
    /// host's cwd, but genuine file-relative imports and explicit search paths still
    /// work.
    SearchPathsOnly,
    /// Disable `use` resolution entirely; every `use` declaration fails with a "file
    /// not found" diagnostic without touching the filesystem. Appropriate for hosts
    /// with no notion of importable files at all (fuzzing, sandboxed single-string
    /// evaluation).
    Disabled,
}

/// A compilation session: owns source files, search paths, cached pipeline results,
/// and the global node/group instance-ID counter.
///
/// `Session` is the single owner of all state that spans multiple pipeline stages —
/// source texts, search paths, scan/parse caches, and the shared ID counter used to
/// mint unique `NodeId`/`GroupId` values across an entire import tree. Pipeline stages
/// (`Scanner`, `Parser`, `Interpreter`) are driven by the session and do not own
/// project-level state themselves.
///
/// # Example
///
/// ```
/// use nexus_lang::Session;
///
/// let mut session = Session::new();
/// let id = session.load_string("let x = 42;", Some("example.nxs"));
///
/// let tokens = session.tokens(id).unwrap();
/// assert!(!tokens.is_empty());
///
/// let ast = session.ast(id).unwrap();
/// assert!(!ast.is_empty());
/// ```
pub struct Session {
    sources: SourceMap,
    search_paths: Arc<[PathBuf]>,
    import_policy: ImportPolicy,

    /// Monotonic counter shared by all interpreters in this session.
    ///
    /// Every `NodeId` and `GroupId` minted anywhere in the import tree increments
    /// this counter, ensuring uniqueness across files.
    id_counter: Arc<AtomicU64>,

    token_cache: HashMap<FileId, Tokens>,
    ast_cache: HashMap<FileId, Ast>,

    /// Per-file transitive import bundle cache, so a file shared by several importers is
    /// evaluated only once per session.
    import_cache: HashMap<FileId, ImportSet>,

    /// Warnings accumulated across an evaluation (root plus all imported files).
    warnings: Vec<InterpretWarning>,
}

impl Session {
    /// Create a new, empty session with no search paths.
    ///
    /// # Example
    ///
    /// ```
    /// use nexus_lang::Session;
    ///
    /// let session = Session::new();
    /// ```
    #[must_use]
    pub fn new() -> Self {
        Self {
            sources: SourceMap::new(),
            search_paths: Arc::<[PathBuf]>::from(vec![]),
            import_policy: ImportPolicy::default(),
            id_counter: Arc::new(AtomicU64::new(0)),
            token_cache: HashMap::new(),
            import_cache: HashMap::new(),
            ast_cache: HashMap::new(),
            warnings: Vec::new(),
        }
    }

    /// Set additional library search paths for `use` import resolution.
    ///
    /// Paths are searched in order after the importing file's own directory.
    /// This method is a builder: it consumes the session and returns it with
    /// the paths set, allowing call-chaining.
    ///
    /// # Example
    ///
    /// ```
    /// use std::path::PathBuf;
    /// use nexus_lang::Session;
    ///
    /// let session = Session::new()
    ///   .with_search_paths(vec![PathBuf::from("/usr/local/lib/nexus")]);
    ///
    /// let _ = session; // Session is still fully usable.
    /// ```
    #[must_use]
    pub fn with_search_paths(mut self, paths: Vec<PathBuf>) -> Self {
        self.search_paths = Arc::from(paths);
        self
    }

    /// Set the `use` import-resolution policy (default [`ImportPolicy::Enabled`]).
    ///
    /// This method is a builder: it consumes the session and returns it with the
    /// policy set, allowing call-chaining.
    ///
    /// # Example
    ///
    /// ```
    /// use nexus_lang::{ImportPolicy, Session};
    ///
    /// let session = Session::new().with_import_policy(ImportPolicy::SearchPathsOnly);
    ///
    /// let _ = session; // Session is still fully usable.
    /// ```
    #[must_use]
    pub fn with_import_policy(mut self, policy: ImportPolicy) -> Self {
        self.import_policy = policy;
        self
    }

    /// Disable `use` import resolution entirely.
    ///
    /// Equivalent to `.with_import_policy(ImportPolicy::Disabled)`. Every `use`
    /// declaration then fails with a "file not found" diagnostic instead of touching
    /// the filesystem; the right choice for hosts evaluating untrusted source text
    /// with no notion of importable files at all (fuzzing, sandboxed single-string
    /// evaluation).
    ///
    /// # Example
    ///
    /// ```
    /// use nexus_lang::Session;
    ///
    /// let mut session = Session::new().with_imports_disabled();
    /// let id = session.load_string("use anything;", None);
    ///
    /// assert!(session.evaluate(id).is_err());
    /// ```
    #[must_use]
    pub fn with_imports_disabled(self) -> Self {
        self.with_import_policy(ImportPolicy::Disabled)
    }

    /// Intern an in-memory source string and return its [`FileId`].
    ///
    /// Each call allocates a new [`FileId`] regardless of content. The optional
    /// `name` is used as a display label in diagnostics.
    ///
    /// # Example
    ///
    /// ```
    /// use nexus_lang::Session;
    ///
    /// let mut session = Session::new();
    /// let id = session.load_string("let x = 1;", Some("inline.nxs"));
    ///
    /// assert_eq!(session.source(id).unwrap().code(), "let x = 1;");
    /// ```
    pub fn load_string(&mut self, code: &str, name: Option<&str>) -> FileId {
        self.sources.load_string(code, name)
    }

    /// Read a file from disk and return its [`FileId`].
    ///
    /// The path is canonicalized before lookup. If the same file has been loaded
    /// before, the existing [`FileId`] is returned without re-reading the file.
    ///
    /// # Errors
    ///
    /// Returns an [`io::Error`](std::io::Error) if the path cannot be canonicalized
    /// or the file cannot be read.
    ///
    /// # Example
    ///
    /// ```
    /// use nexus_lang::Session;
    ///
    /// let mut session = Session::new();
    /// let path = concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml");
    ///
    /// let id1 = session.load_file(path).unwrap();
    /// let id2 = session.load_file(path).unwrap();
    ///
    /// assert_eq!(id1, id2); // same file → same FileId
    /// ```
    pub fn load_file(&mut self, path: impl AsRef<Path>) -> std::io::Result<FileId> {
        self.sources.load_file(path)
    }

    /// Return a [`Source`] view for a previously loaded file.
    ///
    /// The returned view borrows directly from this session, so it is valid for as
    /// long as the session is not mutated or dropped.
    ///
    /// Returns `None` if `id` was not allocated by this session.
    ///
    /// # Example
    ///
    /// ```
    /// use nexus_lang::Session;
    ///
    /// let mut session = Session::new();
    /// let id = session.load_string("print 42;", None);
    ///
    /// assert!(session.source(id).is_some());
    /// assert!(session.source(id).unwrap().name().is_none());
    /// ```
    #[must_use]
    pub fn source(&self, id: FileId) -> Option<Source<'_>> {
        self.sources.get(id)
    }

    /// Scan `id` and return a reference to the cached token list.
    ///
    /// The result is computed once and memoized: subsequent calls for the same
    /// `id` return the cached value without re-scanning.
    ///
    /// # Panics
    ///
    /// Panics if `id` was not allocated by this session.
    ///
    /// # Errors
    ///
    /// Returns a [`ScanError`] if the source text cannot be tokenized.
    ///
    /// # Example
    ///
    /// ```
    /// use nexus_lang::Session;
    ///
    /// let mut session = Session::new();
    /// let id = session.load_string("let x = 42;", None);
    ///
    /// let tokens = session.tokens(id).unwrap();
    /// assert!(!tokens.is_empty());
    /// ```
    pub fn tokens(&mut self, id: FileId) -> Result<&Tokens, ScanError> {
        if !self.token_cache.contains_key(&id) {
            let source = self
                .sources
                .get(id)
                .expect("FileId does not belong to this session");
            let tokens = Scanner::new(source).scan()?;
            self.token_cache.insert(id, tokens);
        }
        Ok(self.token_cache.get(&id).expect("just inserted"))
    }

    /// Return a clone of the session's shared instance-ID counter.
    ///
    /// Passed to the interpreter's `new_from_session` constructor so every node and group
    /// instantiated during interpretation; including those in imported files; mints
    /// IDs from the same monotonic sequence, guaranteeing global uniqueness.
    ///
    pub(crate) fn node_id_counter(&self) -> Arc<AtomicU64> {
        Arc::clone(&self.id_counter)
    }

    /// Resolve a `use NAME;` import to the [`FileId`] of `NAME.nxs`.
    ///
    /// The importer's own directory is searched first, then each configured search
    /// path, in order. The first existing candidate is loaded into the [`SourceMap`]
    /// (deduplicated against previously loaded files) and its [`FileId`] returned.
    ///
    /// An importer loaded from an in-memory string has no canonical path, so it
    /// resolves relative to the current working directory; matching the standalone
    /// interpreter's behavior.
    ///
    /// # Errors
    ///
    /// Returns [`ImportError::NotFound`] if no matching file exists in any search
    /// location, or [`ImportError::Io`] if a located file cannot be read.
    ///
    pub(crate) fn resolve_import(
        &mut self,
        importer: FileId,
        name: &str,
    ) -> Result<FileId, ImportError> {
        if self.import_policy == ImportPolicy::Disabled {
            return Err(ImportError::NotFound);
        }

        let filename = format!("{name}.nxs");

        // The importer's directory, if it was loaded from a real file. An in-memory
        // (string) source has no canonical directory of its own; under `Enabled` that
        // falls back to the current working directory, but `SearchPathsOnly` disables
        // the fallback so untrusted string sources cannot read arbitrary cwd-relative
        // files via `use`.
        let importer_dir = self
            .sources
            .canonical_path(importer)
            .and_then(Path::parent)
            .map(PathBuf::from)
            .or_else(|| match self.import_policy {
                ImportPolicy::Enabled => std::env::current_dir().ok(),
                ImportPolicy::SearchPathsOnly | ImportPolicy::Disabled => None,
            });

        // Search the importer's directory first, then each configured search path.
        let canonical = importer_dir
            .iter()
            .chain(self.search_paths.iter())
            .find_map(|dir| std::fs::canonicalize(dir.join(&filename)).ok())
            .ok_or(ImportError::NotFound)?;

        self.sources.load_file(&canonical).map_err(ImportError::Io)
    }

    /// Resolve and evaluate everything `importer` transitively imports.
    ///
    /// Walks the import graph rooted at `importer`'s `use` declarations. Each imported
    /// file is evaluated exactly once (its transitive bundle is cached by [`FileId`]) and
    /// contributes only its *own* top-level bindings and connections. Files reached
    /// through more than one path (diamond imports) are deduplicated by `FileId`, so a
    /// shared dependency does not produce a spurious name collision. A genuine clash —
    /// two different files defining the same name; is reported as
    /// [`ImportNameCollision`](InterpretErrorKind::ImportNameCollision); a back-edge in
    /// the graph is reported as a circular-import error.
    ///
    /// The returned [`ExportBundle`] is what the importer's own evaluation should be
    /// seeded with. Warnings emitted by imported files accumulate on the session (see
    /// [`warnings`](Session::warnings)).
    ///
    /// # Errors
    ///
    /// Returns a [`NexusError`] if an import cannot be resolved, scanned, parsed, or
    /// evaluated, if two imports collide, or if the import graph contains a cycle.
    ///
    pub(crate) fn collect_imports(&mut self, importer: FileId) -> Result<ExportBundle, NexusError> {
        let mut in_progress = HashSet::new();
        in_progress.insert(importer);

        let acc = self.merge_children(importer, &mut in_progress)?;
        Ok(acc.into_export_bundle())
    }

    /// Resolve `file`'s direct `use` declarations, recursively pull in their transitive
    /// bundles, and merge them into one `ImportSet`.
    ///
    /// Shared DFS step used by both [`collect_imports`](Session::collect_imports) (which
    /// merges directly into its own accumulator) and [`file_bundle`](Session::file_bundle)
    /// (which uses this as the starting point before adding the file's own contribution).
    fn merge_children(
        &mut self,
        file: FileId,
        in_progress: &mut HashSet<FileId>,
    ) -> Result<ImportSet, NexusError> {
        let mut acc = ImportSet::empty();
        for (name, loc) in self.use_decls(file)? {
            let child = self
                .resolve_import(file, &name)
                .map_err(|e| self.import_error(file, &loc, &name, &e))?;

            if in_progress.contains(&child) {
                return Err(self.cycle_error(file, &loc, &name).into());
            }

            let child_set = self.file_bundle(child, in_progress)?;
            acc.merge(child_set)
                .map_err(|collide| self.collision_error(file, &loc, collide))?;
        }
        Ok(acc)
    }

    /// Compute a file's transitive import bundle: the deduplicated contributions of the
    /// file itself and everything it imports.
    ///
    /// Results are memoized in `import_cache`, so a file shared by several importers is
    /// evaluated once. `in_progress` is the set of files on the current DFS stack; a
    /// back-edge into it is a circular import. Warnings emitted while evaluating the file
    /// accumulate on the session.
    ///
    fn file_bundle(
        &mut self,
        file: FileId,
        in_progress: &mut HashSet<FileId>,
    ) -> Result<ImportSet, NexusError> {
        if let Some(cached) = self.import_cache.get(&file) {
            return Ok(cached.clone());
        }

        in_progress.insert(file);

        // First compose this file's transitive imports (depth-first).
        let mut set = self.merge_children(file, in_progress)?;

        // Then evaluate this file seeded with its imports, capturing its own contribution.
        let seed = set.to_seed();
        let ast = self.ast(file)?.clone();
        let source = self
            .sources
            .get(file)
            .expect("resolved FileId belongs to this session");
        let (own, own_warnings) =
            Interpreter::new_from_session(source, ast, self).eval_library(seed)?;
        self.warnings.extend(own_warnings);

        set.add_own(file, own);

        in_progress.remove(&file);
        self.import_cache.insert(file, set.clone());

        Ok(set)
    }

    /// Direct `use` declarations of `file`, each with its source location.
    fn use_decls(&mut self, file: FileId) -> Result<Vec<(String, Location)>, NexusError> {
        Ok(self
            .ast(file)?
            .iter()
            .filter_map(|stmt| match stmt {
                Stmt::UseDecl { name, loc } => Some((name.clone(), *loc)),
                _ => None,
            })
            .collect())
    }

    /// Build the diagnostic for a `use` whose target could not be resolved or read,
    /// anchored at the `use` statement in `file`.
    fn import_error(
        &self,
        file: FileId,
        loc: &Location,
        name: &str,
        err: &ImportError,
    ) -> InterpretError {
        let source = self
            .sources
            .get(file)
            .expect("file belongs to this session");
        let detail = match err {
            ImportError::NotFound => format!("File '{name}.nxs' not found in any search path"),
            ImportError::Io(io) => io.to_string(),
        };
        InterpretError::new(
            source.into(),
            loc,
            InterpretErrorKind::ImportError(format!("{name}.nxs")),
            Some(detail),
            None::<String>,
            Some("use".len()),
        )
    }

    /// Build the diagnostic for a circular import, anchored at the offending `use`.
    fn cycle_error(&self, file: FileId, loc: &Location, name: &str) -> InterpretError {
        let source = self
            .sources
            .get(file)
            .expect("file belongs to this session");
        InterpretError::new(
            source.into(),
            loc,
            InterpretErrorKind::ImportError(format!("{name}.nxs")),
            Some(format!("Circular import detected while importing '{name}'")),
            None::<String>,
            Some("use".len()),
        )
    }

    /// Build the diagnostic for an imported binding that collides with one already
    /// brought in, anchored at the `use` that introduced it.
    fn collision_error(
        &self,
        file: FileId,
        loc: &Location,
        binding_name: String,
    ) -> InterpretError {
        let source = self
            .sources
            .get(file)
            .expect("file belongs to this session");
        InterpretError::new(
            source.into(),
            loc,
            InterpretErrorKind::ImportNameCollision(binding_name),
            None::<String>,
            None::<String>,
            Some("use".len()),
        )
    }

    /// Parse `id` and return a reference to the cached AST.
    ///
    /// The result is computed once and memoized: subsequent calls for the same
    /// `id` return the cached value without re-scanning or re-parsing.
    ///
    /// # Panics
    ///
    /// Panics if `id` was not allocated by this session.
    ///
    /// # Errors
    ///
    /// Returns a [`NexusError`] wrapping a [`ScanError`] or [`ParseError`](crate::ParseError) if the
    /// source cannot be tokenized or parsed.
    ///
    /// # Example
    ///
    /// ```
    /// use nexus_lang::Session;
    ///
    /// let mut session = Session::new();
    /// let id = session.load_string("let x = 42;", None);
    ///
    /// let ast = session.ast(id).unwrap();
    /// assert!(!ast.is_empty());
    /// ```
    pub fn ast(&mut self, id: FileId) -> Result<&Ast, NexusError> {
        if !self.ast_cache.contains_key(&id) {
            self.tokens(id)?;
            let tokens = self.token_cache.get(&id).expect("just cached");
            let source = self
                .sources
                .get(id)
                .expect("FileId does not belong to this session");
            let ast = Parser::new(source, tokens).parse()?;
            self.ast_cache.insert(id, ast);
        }
        Ok(self.ast_cache.get(&id).expect("just inserted"))
    }

    /// Evaluate `id` as the root of a program, resolving its imports first.
    ///
    /// Resolves and evaluates everything `id` transitively imports (an internal step;
    /// there is no public accessor for it in isolation), seeds those symbols into the
    /// root's scope, then evaluates the root file, producing the resulting [`Network`].
    ///
    /// Warnings; from both imported files and the root; accumulate on the session and
    /// are read separately via [`warnings`](Session::warnings) /
    /// [`into_warnings`](Session::into_warnings).
    ///
    /// # Panics
    ///
    /// Panics if `id` was not allocated by this session.
    ///
    /// # Errors
    ///
    /// Returns a [`NexusError`] if any imported or root file fails to scan, parse, or
    /// evaluate, if imports collide, or if the import graph contains a cycle.
    ///
    pub fn evaluate(&mut self, id: FileId) -> Result<Network, NexusError> {
        let seed = self.collect_imports(id)?;

        let ast = self.ast(id)?.clone();
        let source = self
            .sources
            .get(id)
            .expect("FileId does not belong to this session");
        let (network, root_warnings) =
            Interpreter::new_from_session(source, ast, self).eval_root(seed)?;
        self.warnings.extend(root_warnings);

        Ok(network)
    }

    /// Warnings accumulated during [`evaluate`](Session::evaluate), in emission order.
    #[must_use]
    pub fn warnings(&self) -> &[InterpretWarning] {
        &self.warnings
    }

    /// Consume the session and return the warnings it accumulated.
    #[must_use]
    pub fn into_warnings(self) -> Vec<InterpretWarning> {
        self.warnings
    }
}

impl Default for Session {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use std::sync::atomic::Ordering;

    use super::*;

    #[test]
    fn id_counter_starts_at_zero() {
        let s = Session::new();
        assert_eq!(s.node_id_counter().load(Ordering::Relaxed), 0);
    }

    #[test]
    fn id_counter_shared_between_clones() {
        let s = Session::new();
        let c1 = s.node_id_counter();
        let c2 = s.node_id_counter();
        c1.fetch_add(1, Ordering::Relaxed);
        assert_eq!(c2.load(Ordering::Relaxed), 1);
    }

    /// Directory holding the `*_lib.nxs` import fixtures.
    const FIXTURE_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/interpreter/pass");

    #[test]
    fn resolve_import_finds_file_in_search_path() {
        let mut s = Session::new().with_search_paths(vec![PathBuf::from(FIXTURE_DIR)]);
        let importer = s.load_string("use use_basic_lib;", None);

        let resolved = s.resolve_import(importer, "use_basic_lib").unwrap();
        assert!(s.source(resolved).is_some());
    }

    #[test]
    fn resolve_import_dedups_to_same_file_id() {
        let mut s = Session::new().with_search_paths(vec![PathBuf::from(FIXTURE_DIR)]);
        let importer = s.load_string("", None);

        let a = s.resolve_import(importer, "use_basic_lib").unwrap();
        let b = s.resolve_import(importer, "use_basic_lib").unwrap();
        assert_eq!(a, b);
    }

    #[test]
    fn resolve_import_not_found_in_empty_session() {
        let mut s = Session::new();
        let importer = s.load_string("", None);

        let err = s
            .resolve_import(importer, "definitely_no_such_lib_98765")
            .unwrap_err();
        assert!(matches!(err, ImportError::NotFound));
    }

    #[test]
    fn import_policy_defaults_to_enabled() {
        assert_eq!(Session::new().import_policy, ImportPolicy::Enabled);
    }

    #[test]
    fn imports_disabled_rejects_use_even_with_matching_search_path() {
        // A search path that *would* resolve the import under the default policy is
        // configured; `Disabled` must ignore it entirely rather than merely lacking a
        // cwd fallback.
        let mut s = Session::new()
            .with_search_paths(vec![PathBuf::from(FIXTURE_DIR)])
            .with_imports_disabled();
        let importer = s.load_string("use use_basic_lib;", None);

        let err = s.resolve_import(importer, "use_basic_lib").unwrap_err();
        assert!(matches!(err, ImportError::NotFound));

        // The same holds through the full `collect_imports` path used by `evaluate`.
        let mut s = Session::new()
            .with_search_paths(vec![PathBuf::from(FIXTURE_DIR)])
            .with_imports_disabled();
        let importer = s.load_string("use use_basic_lib;", None);
        assert!(s.collect_imports(importer).is_err());
    }

    #[test]
    fn search_paths_only_still_resolves_configured_search_paths() {
        // `SearchPathsOnly` removes the current-working-directory fallback for
        // in-memory sources, but must not disturb ordinary search-path resolution.
        let mut s = Session::new()
            .with_search_paths(vec![PathBuf::from(FIXTURE_DIR)])
            .with_import_policy(ImportPolicy::SearchPathsOnly);
        let importer = s.load_string("use use_basic_lib;", None);

        let resolved = s.resolve_import(importer, "use_basic_lib").unwrap();
        assert!(s.source(resolved).is_some());
    }

    /// Directory holding the dedicated `collect_imports` fixtures (not scanned by the
    /// interpreter harness).
    const SESSION_FIXTURE_DIR: &str =
        concat!(env!("CARGO_MANIFEST_DIR"), "/tests/session/fixtures");

    fn session_with(dir: &str) -> Session {
        Session::new().with_search_paths(vec![PathBuf::from(dir)])
    }

    #[test]
    fn collect_imports_basic_pulls_in_lib_symbols() {
        let mut s = session_with(FIXTURE_DIR);
        let importer = s.load_string("use use_basic_lib;", None);

        let bundle = s.collect_imports(importer).unwrap();
        let names = bundle.binding_names();

        assert!(names.contains(&"double".to_owned()));
        assert!(names.contains(&"PI".to_owned()));
        assert!(names.contains(&"Router".to_owned()));
    }

    #[test]
    fn collect_imports_flattens_transitive_chain() {
        let mut s = session_with(FIXTURE_DIR);
        let importer = s.load_string("use use_transitive_b;", None);

        let bundle = s.collect_imports(importer).unwrap();
        let names = bundle.binding_names();

        assert!(names.contains(&"from_b".to_owned()));
        assert!(names.contains(&"from_c".to_owned())); // transitive via b -> c
    }

    #[test]
    fn collect_imports_diamond_dedups_shared_dependency() {
        let mut s = session_with(SESSION_FIXTURE_DIR);
        let importer = s.load_string("use diamond_a;\nuse diamond_b;", None);

        let bundle = s.collect_imports(importer).unwrap();
        let names = bundle.binding_names();

        // Name `shared` is reached via both a and b, but appears exactly once; no collision.
        assert_eq!(names.iter().filter(|n| *n == "shared").count(), 1);
        assert!(names.contains(&"from_a".to_owned()));
        assert!(names.contains(&"from_b".to_owned()));
    }

    #[test]
    fn collect_imports_genuine_collision_is_an_error() {
        let mut s = session_with(SESSION_FIXTURE_DIR);
        let importer = s.load_string("use collide_lib1;\nuse collide_lib2;", None);

        assert!(s.collect_imports(importer).is_err());
    }

    #[test]
    fn collect_imports_detects_cycle() {
        let mut s = session_with(SESSION_FIXTURE_DIR);
        let importer = s.load_string("use cycle_a;", None);

        assert!(s.collect_imports(importer).is_err());
    }
}
