Skip to main content

backhopper_core/model/
symbol.rs

1// Copyright (C) 2026 Michael S. Klishin and Contributors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// See LICENSE-APACHE and LICENSE-MIT for details.
4
5use std::ops::Range;
6use std::path::PathBuf;
7
8use serde::{Deserialize, Serialize};
9
10use crate::model::names::{Arity, FunctionName, Mfa, ModuleName, RecordName, TypeName};
11
12/// Where in the source a reference was extracted from. The same
13/// textual `mod:ident(...)` shape means a function call in body
14/// context and a type reference in type-attribute context, so the
15/// extractor must carry the surrounding context with each symbol it
16/// emits.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub enum RefContext {
19    /// Inside a function body or top-level expression.
20    Body,
21    /// Inside a `-spec`, `-callback`, `-type`, or `-opaque` attribute,
22    /// where `mod:ident(...)` is a type reference, not a function call.
23    TypeAttribute,
24    /// Inside any other attribute form (`-export`, `-define`, ...),
25    /// where an identifier is never a local call.
26    OtherAttribute,
27    /// Inside a `-record(...)` attribute: a field's default expression
28    /// holds calls, while the byte ranges a `LineClass` reports for the
29    /// line mark its `::` type annotation, which does not.
30    RecordAttribute,
31    /// Inside a multi-line string in an attribute, such as the
32    /// documentation prose of `-moduledoc """ ... """`. Every byte is
33    /// string content, so nothing here is a reference of any kind.
34    AttributeString,
35}
36
37impl RefContext {
38    /// True in any attribute region; a local `name(` there is a
39    /// type name, an export entry, a macro parameter, or string
40    /// content, never a call or a clause head.
41    #[must_use]
42    pub fn is_attribute(self) -> bool {
43        !matches!(self, Self::Body)
44    }
45
46    /// True where `mod:fun(...)` may hold a real call. Also true in a
47    /// record attribute: a field's default expression can call a
48    /// function, and the caller is expected to mask out the `::`
49    /// annotation ranges first so the type half is not scanned as one.
50    #[must_use]
51    pub fn holds_calls(self) -> bool {
52        matches!(
53            self,
54            Self::Body | Self::OtherAttribute | Self::RecordAttribute
55        )
56    }
57}
58
59/// One line's attribute-region classification, plus the byte ranges of
60/// any `::` type annotations on it. `type_spans` is empty outside a
61/// `-record` region; each range indexes the comment-stripped line.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct LineClass {
64    pub context: RefContext,
65    pub type_spans: Vec<Range<usize>>,
66}
67
68impl LineClass {
69    #[must_use]
70    pub fn new(context: RefContext) -> Self {
71        Self {
72            context,
73            type_spans: Vec::new(),
74        }
75    }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
79#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
80#[serde(tag = "kind", rename_all = "snake_case")]
81pub enum SymbolKind {
82    Behaviour {
83        module: ModuleName,
84    },
85    Callback {
86        module: ModuleName,
87        function: FunctionName,
88        arity: Arity,
89    },
90    Function {
91        mfa: Mfa,
92    },
93    /// A function reference whose arity could not be determined from
94    /// the source line (the call's closing paren sits on a later
95    /// line). Resolves against a snapshot when *any* export carries
96    /// the name; never produces an arity mismatch.
97    FunctionAnyArity {
98        module: ModuleName,
99        function: FunctionName,
100    },
101    Macro {
102        name: String,
103    },
104    Record {
105        name: RecordName,
106    },
107    Type {
108        module: ModuleName,
109        name: TypeName,
110        arity: Arity,
111    },
112}
113
114/// The three `SymbolKind` arms an `-export` or `-export_type` list can
115/// answer for: `Behaviour`, `Callback`, `Macro`, and `Record` resolve some
116/// other way and have no exported form to ask about.
117#[derive(Debug, Clone, Copy)]
118pub enum ExportedSymbol<'a> {
119    Function(&'a Mfa),
120    FunctionAnyArity {
121        module: &'a ModuleName,
122        function: &'a FunctionName,
123    },
124    Type {
125        module: &'a ModuleName,
126        name: &'a TypeName,
127        arity: Arity,
128    },
129}
130
131impl SymbolKind {
132    #[must_use]
133    pub fn as_exported(&self) -> Option<ExportedSymbol<'_>> {
134        match self {
135            Self::Function { mfa } => Some(ExportedSymbol::Function(mfa)),
136            Self::FunctionAnyArity { module, function } => {
137                Some(ExportedSymbol::FunctionAnyArity { module, function })
138            }
139            Self::Type {
140                module,
141                name,
142                arity,
143            } => Some(ExportedSymbol::Type {
144                module,
145                name,
146                arity: *arity,
147            }),
148            Self::Behaviour { .. }
149            | Self::Callback { .. }
150            | Self::Macro { .. }
151            | Self::Record { .. } => None,
152        }
153    }
154}
155
156/// Which side of the hunk a reference was extracted from. `Added`
157/// lines are requirements the patch introduces; `Context` lines are
158/// pre-existing facts about the target and never drive verdicts.
159/// `Added` orders first so a kind-level dedup keeps the stronger
160/// origin.
161#[derive(
162    Debug, Clone, Copy, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
163)]
164#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
165#[serde(rename_all = "snake_case")]
166pub enum RefOrigin {
167    #[default]
168    Added,
169    Context,
170}
171
172impl RefOrigin {
173    // &self is required by serde's skip_serializing_if predicate shape
174    #[allow(clippy::trivially_copy_pass_by_ref)]
175    #[must_use]
176    pub fn is_added(&self) -> bool {
177        matches!(self, Self::Added)
178    }
179}
180
181#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
182#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
183pub struct SymbolRef {
184    #[serde(flatten)]
185    pub kind: SymbolKind,
186    #[serde(default, skip_serializing_if = "RefOrigin::is_added")]
187    pub origin: RefOrigin,
188}
189
190impl SymbolRef {
191    pub fn function(mfa: Mfa) -> Self {
192        Self {
193            kind: SymbolKind::Function { mfa },
194            origin: RefOrigin::Added,
195        }
196    }
197
198    pub fn function_any_arity(module: ModuleName, function: FunctionName) -> Self {
199        Self {
200            kind: SymbolKind::FunctionAnyArity { module, function },
201            origin: RefOrigin::Added,
202        }
203    }
204
205    pub fn record(name: RecordName) -> Self {
206        Self {
207            kind: SymbolKind::Record { name },
208            origin: RefOrigin::Added,
209        }
210    }
211
212    pub fn macro_use(name: impl Into<String>) -> Self {
213        Self {
214            kind: SymbolKind::Macro { name: name.into() },
215            origin: RefOrigin::Added,
216        }
217    }
218
219    pub fn type_ref(module: ModuleName, name: TypeName, arity: Arity) -> Self {
220        Self {
221            kind: SymbolKind::Type {
222                module,
223                name,
224                arity,
225            },
226            origin: RefOrigin::Added,
227        }
228    }
229
230    #[must_use]
231    pub fn with_origin(mut self, origin: RefOrigin) -> Self {
232        self.origin = origin;
233        self
234    }
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
238#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
239pub struct Reference {
240    pub symbol: SymbolRef,
241    pub from_path: PathBuf,
242    pub line: usize,
243}