backhopper-core 0.37.0

Core types, snapshot I/O, store, and compatibility analysis for backhopper
Documentation
// Copyright (C) 2026 Michael S. Klishin and Contributors
// SPDX-License-Identifier: Apache-2.0 OR MIT
// See LICENSE-APACHE and LICENSE-MIT for details.

use std::ops::Range;
use std::path::PathBuf;

use serde::{Deserialize, Serialize};

use crate::model::names::{Arity, FunctionName, Mfa, ModuleName, RecordName, TypeName};

/// Where in the source a reference was extracted from. The same
/// textual `mod:ident(...)` shape means a function call in body
/// context and a type reference in type-attribute context, so the
/// extractor must carry the surrounding context with each symbol it
/// emits.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RefContext {
    /// Inside a function body or top-level expression.
    Body,
    /// Inside a `-spec`, `-callback`, `-type`, or `-opaque` attribute,
    /// where `mod:ident(...)` is a type reference, not a function call.
    TypeAttribute,
    /// Inside any other attribute form (`-export`, `-define`, ...),
    /// where an identifier is never a local call.
    OtherAttribute,
    /// Inside a `-record(...)` attribute: a field's default expression
    /// holds calls, while the byte ranges a `LineClass` reports for the
    /// line mark its `::` type annotation, which does not.
    RecordAttribute,
    /// Inside a multi-line string in an attribute, such as the
    /// documentation prose of `-moduledoc """ ... """`. Every byte is
    /// string content, so nothing here is a reference of any kind.
    AttributeString,
}

impl RefContext {
    /// True in any attribute region; a local `name(` there is a
    /// type name, an export entry, a macro parameter, or string
    /// content, never a call or a clause head.
    #[must_use]
    pub fn is_attribute(self) -> bool {
        !matches!(self, Self::Body)
    }

    /// True where `mod:fun(...)` may hold a real call. Also true in a
    /// record attribute: a field's default expression can call a
    /// function, and the caller is expected to mask out the `::`
    /// annotation ranges first so the type half is not scanned as one.
    #[must_use]
    pub fn holds_calls(self) -> bool {
        matches!(
            self,
            Self::Body | Self::OtherAttribute | Self::RecordAttribute
        )
    }
}

/// One line's attribute-region classification, plus the byte ranges of
/// any `::` type annotations on it. `type_spans` is empty outside a
/// `-record` region; each range indexes the comment-stripped line.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LineClass {
    pub context: RefContext,
    pub type_spans: Vec<Range<usize>>,
}

impl LineClass {
    #[must_use]
    pub fn new(context: RefContext) -> Self {
        Self {
            context,
            type_spans: Vec::new(),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum SymbolKind {
    Behaviour {
        module: ModuleName,
    },
    Callback {
        module: ModuleName,
        function: FunctionName,
        arity: Arity,
    },
    Function {
        mfa: Mfa,
    },
    /// A function reference whose arity could not be determined from
    /// the source line (the call's closing paren sits on a later
    /// line). Resolves against a snapshot when *any* export carries
    /// the name; never produces an arity mismatch.
    FunctionAnyArity {
        module: ModuleName,
        function: FunctionName,
    },
    Macro {
        name: String,
    },
    Record {
        name: RecordName,
    },
    Type {
        module: ModuleName,
        name: TypeName,
        arity: Arity,
    },
}

/// The three `SymbolKind` arms an `-export` or `-export_type` list can
/// answer for: `Behaviour`, `Callback`, `Macro`, and `Record` resolve some
/// other way and have no exported form to ask about.
#[derive(Debug, Clone, Copy)]
pub enum ExportedSymbol<'a> {
    Function(&'a Mfa),
    FunctionAnyArity {
        module: &'a ModuleName,
        function: &'a FunctionName,
    },
    Type {
        module: &'a ModuleName,
        name: &'a TypeName,
        arity: Arity,
    },
}

impl SymbolKind {
    #[must_use]
    pub fn as_exported(&self) -> Option<ExportedSymbol<'_>> {
        match self {
            Self::Function { mfa } => Some(ExportedSymbol::Function(mfa)),
            Self::FunctionAnyArity { module, function } => {
                Some(ExportedSymbol::FunctionAnyArity { module, function })
            }
            Self::Type {
                module,
                name,
                arity,
            } => Some(ExportedSymbol::Type {
                module,
                name,
                arity: *arity,
            }),
            Self::Behaviour { .. }
            | Self::Callback { .. }
            | Self::Macro { .. }
            | Self::Record { .. } => None,
        }
    }
}

/// Which side of the hunk a reference was extracted from. `Added`
/// lines are requirements the patch introduces; `Context` lines are
/// pre-existing facts about the target and never drive verdicts.
/// `Added` orders first so a kind-level dedup keeps the stronger
/// origin.
#[derive(
    Debug, Clone, Copy, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum RefOrigin {
    #[default]
    Added,
    Context,
}

impl RefOrigin {
    // &self is required by serde's skip_serializing_if predicate shape
    #[allow(clippy::trivially_copy_pass_by_ref)]
    #[must_use]
    pub fn is_added(&self) -> bool {
        matches!(self, Self::Added)
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct SymbolRef {
    #[serde(flatten)]
    pub kind: SymbolKind,
    #[serde(default, skip_serializing_if = "RefOrigin::is_added")]
    pub origin: RefOrigin,
}

impl SymbolRef {
    pub fn function(mfa: Mfa) -> Self {
        Self {
            kind: SymbolKind::Function { mfa },
            origin: RefOrigin::Added,
        }
    }

    pub fn function_any_arity(module: ModuleName, function: FunctionName) -> Self {
        Self {
            kind: SymbolKind::FunctionAnyArity { module, function },
            origin: RefOrigin::Added,
        }
    }

    pub fn record(name: RecordName) -> Self {
        Self {
            kind: SymbolKind::Record { name },
            origin: RefOrigin::Added,
        }
    }

    pub fn macro_use(name: impl Into<String>) -> Self {
        Self {
            kind: SymbolKind::Macro { name: name.into() },
            origin: RefOrigin::Added,
        }
    }

    pub fn type_ref(module: ModuleName, name: TypeName, arity: Arity) -> Self {
        Self {
            kind: SymbolKind::Type {
                module,
                name,
                arity,
            },
            origin: RefOrigin::Added,
        }
    }

    #[must_use]
    pub fn with_origin(mut self, origin: RefOrigin) -> Self {
        self.origin = origin;
        self
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct Reference {
    pub symbol: SymbolRef,
    pub from_path: PathBuf,
    pub line: usize,
}