Skip to main content

backhopper_core/compat/
option_keys.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
5//! C2: option-map key drift (`OptionKeyUnknownOnPin`). A map key found
6//! in added lines fires only when it is a recognized option-type key
7//! on the source side and absent from the same type on the target
8//! pin. Both-side membership is the entire noise filter.
9
10use std::collections::BTreeSet;
11
12use backhopper_erlang_scan::{
13    matching_bracket, quoted_atom_span, skip_char_literal_span, split_top_level_commas, string_span,
14};
15
16use crate::config::FamilyDefaults;
17use crate::model::names::{MapKey, ModuleName, ProjectName, TagName, TypeName};
18use crate::model::snapshot::{Snapshot, state};
19use crate::model::symbol::LineClass;
20
21/// The top-level key universe of a family-declared option map type,
22/// parsed from its stored `TypeDecl.rhs`. The firing rule below
23/// consumes only the `Closed` form: an `Open` or `Unresolved` set on
24/// the side being asked "is this key legal here" is unrepresentable
25/// as a drifted key, by construction rather than by a runtime flag.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum OptionKeySet {
28    /// A literal map with only literal atom keys.
29    Closed(BTreeSet<MapKey>),
30    /// A literal map with at least one wildcard key entry (`atom()`,
31    /// `term()`, `_`). The literal keys found alongside the wildcard
32    /// are still recorded, for display.
33    Open(BTreeSet<MapKey>),
34    /// The right-hand side is not a literal map at the top level (a
35    /// union, or a reference to another type).
36    Unresolved,
37}
38
39impl OptionKeySet {
40    /// The literal keys this set carries, for either closed or open
41    /// forms; empty for `Unresolved`.
42    #[must_use]
43    pub fn literal_keys(&self) -> &BTreeSet<MapKey> {
44        static EMPTY: BTreeSet<MapKey> = BTreeSet::new();
45        match self {
46            Self::Closed(keys) | Self::Open(keys) => keys,
47            Self::Unresolved => &EMPTY,
48        }
49    }
50}
51
52/// Parse a `TypeDecl.rhs` string into its top-level key universe. The
53/// whole trimmed text must be one `#{ ... }` map literal; a union or
54/// an indirection to another type name leaves the set `Unresolved`.
55///
56/// The closing brace and the top-level comma split both reuse
57/// `backhopper-erlang-scan`'s bracket and comma scanners rather than a
58/// bespoke byte walk here, so a line comment or a same-kind-only
59/// bracket match inside the map literal is handled the same way every
60/// other parser in this codebase handles it.
61#[must_use]
62pub fn parse_option_key_set(rhs: &str) -> OptionKeySet {
63    let trimmed = rhs.trim();
64    if !trimmed.starts_with("#{") {
65        return OptionKeySet::Unresolved;
66    }
67    let bytes = trimmed.as_bytes();
68    let Some(close) = matching_bracket(bytes, 1) else {
69        return OptionKeySet::Unresolved;
70    };
71    // Anything after the matching `}` (besides trailing whitespace or
72    // a bare `.`) means the map is not the entire top-level type: a
73    // union member, or trailing syntax this parser does not model.
74    let after = trimmed[close + 1..].trim().trim_end_matches('.').trim();
75    if !after.is_empty() {
76        return OptionKeySet::Unresolved;
77    }
78    let inner = &trimmed[2..close];
79    let mut keys = BTreeSet::new();
80    let mut open = false;
81    for entry in split_top_level_commas(inner) {
82        let Some(key_text) = key_part(entry) else {
83            open = true;
84            continue;
85        };
86        match literal_atom_key(key_text.trim()) {
87            Some(atom) => {
88                if let Ok(k) = MapKey::new(atom) {
89                    keys.insert(k);
90                }
91            }
92            None => open = true,
93        }
94    }
95    if open {
96        OptionKeySet::Open(keys)
97    } else {
98        OptionKeySet::Closed(keys)
99    }
100}
101
102/// The key half of one `key := value` or `key => value` map-type
103/// entry, split at the first top-level `:=` or `=>`. `None` when
104/// neither operator appears at depth zero: a malformed or unsupported
105/// entry shape, treated as a wildcard (open) rather than guessed.
106fn key_part(entry: &str) -> Option<&str> {
107    let bytes = entry.as_bytes();
108    let mut depth: i32 = 0;
109    let mut i = 0usize;
110    while i + 1 < bytes.len() {
111        match bytes[i] {
112            b'"' | b'~' => {
113                i += string_span(bytes, i).unwrap_or(1);
114                continue;
115            }
116            b'\'' => {
117                i += quoted_atom_span(bytes, i).unwrap_or(1);
118                continue;
119            }
120            b'$' => {
121                i += skip_char_literal_span(bytes, i);
122                continue;
123            }
124            b'{' | b'[' | b'(' => depth += 1,
125            b'}' | b']' | b')' => depth -= 1,
126            b':' if depth == 0 && bytes[i + 1] == b'=' => return Some(&entry[..i]),
127            b'=' if depth == 0 && bytes[i + 1] == b'>' => return Some(&entry[..i]),
128            _ => {}
129        }
130        i += 1;
131    }
132    None
133}
134
135/// A literal atom key: a bare identifier atom, or a quoted atom.
136/// Anything else (`atom()`, `term()`, `_`, a variable) is not a
137/// literal key.
138fn literal_atom_key(key_text: &str) -> Option<String> {
139    if let Some(rest) = key_text.strip_prefix('\'') {
140        let end = rest.find('\'')?;
141        return Some(rest[..end].to_owned());
142    }
143    let mut chars = key_text.chars();
144    let first = chars.next()?;
145    if !first.is_ascii_lowercase() {
146        return None;
147    }
148    if !key_text
149        .chars()
150        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '@')
151    {
152        return None;
153    }
154    Some(key_text.to_owned())
155}
156
157/// Every map key found at a key position (`atom =>` or `atom :=`) in
158/// `added_text`'s non-attribute lines. Quoted atoms count as literal
159/// keys. Lines classified as an attribute region (`-spec`, `-type`,
160/// `-opaque`, `-callback`, and every other attribute) are skipped: a
161/// mirrored option type in a carried spec must not false-positive.
162#[must_use]
163pub fn scan_added_map_keys(added_text: &str, line_ctx: &[LineClass]) -> BTreeSet<MapKey> {
164    let mut keys = BTreeSet::new();
165    for (line, ctx) in added_text.lines().zip(line_ctx.iter()) {
166        if ctx.context.is_attribute() {
167            continue;
168        }
169        scan_line_for_map_keys(line, &mut keys);
170    }
171    keys
172}
173
174fn scan_line_for_map_keys(line: &str, keys: &mut BTreeSet<MapKey>) {
175    let bytes = line.as_bytes();
176    let mut i = 0usize;
177    while i < bytes.len() {
178        match bytes[i] {
179            b'%' => break,
180            b'"' | b'~' => i += string_span(bytes, i).unwrap_or(1),
181            b'\'' => {
182                let span = quoted_atom_span(bytes, i).unwrap_or(1);
183                if span >= 2 {
184                    let atom_text = &line[i + 1..i + span - 1];
185                    if let Some(consumed) = arrow_after(bytes, i + span)
186                        && let Ok(k) = MapKey::new(atom_text.to_owned())
187                    {
188                        keys.insert(k);
189                        i += span + consumed;
190                        continue;
191                    }
192                }
193                i += span;
194            }
195            b'$' => i += skip_char_literal_span(bytes, i),
196            b'a'..=b'z' => {
197                let start = i;
198                while i < bytes.len()
199                    && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_' || bytes[i] == b'@')
200                {
201                    i += 1;
202                }
203                let atom_text = &line[start..i];
204                if let Some(consumed) = arrow_after(bytes, i)
205                    && let Ok(k) = MapKey::new(atom_text.to_owned())
206                {
207                    keys.insert(k);
208                    i += consumed;
209                }
210            }
211            _ => i += 1,
212        }
213    }
214}
215
216/// If `bytes[i..]` is optional whitespace followed by `=>` or `:=`,
217/// the number of bytes to skip to land just past the operator.
218fn arrow_after(bytes: &[u8], mut i: usize) -> Option<usize> {
219    let start = i;
220    while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
221        i += 1;
222    }
223    if i + 1 < bytes.len()
224        && ((bytes[i] == b':' && bytes[i + 1] == b'=')
225            || (bytes[i] == b'=' && bytes[i + 1] == b'>'))
226    {
227        Some(i + 2 - start)
228    } else {
229        None
230    }
231}
232
233/// Per-`(project, tag, module, type)` memoized key set: a 60-pair
234/// batch parses each declared type's `rhs` once. Callers own the map
235/// across a run; this module only defines the key shape.
236pub type OptionKeySetCache =
237    std::collections::BTreeMap<(ProjectName, TagName, ModuleName, TypeName), OptionKeySet>;
238
239/// The declared type's key set at `snapshot`'s tag, memoized in
240/// `cache` under `(project, tag, module, type)`.
241pub fn cached_option_key_set<'a>(
242    cache: &'a mut OptionKeySetCache,
243    snapshot: &Snapshot<state::Canonical>,
244    module: &ModuleName,
245    type_name: &TypeName,
246) -> Option<&'a OptionKeySet> {
247    let key = (
248        snapshot.header().project.clone(),
249        snapshot.header().tag.clone(),
250        module.clone(),
251        type_name.clone(),
252    );
253    if !cache.contains_key(&key) {
254        let rhs = snapshot
255            .module_named(module)?
256            .types
257            .iter()
258            .find(|t| &t.name == type_name)
259            .map(|t| t.rhs.as_str())?;
260        cache.insert(key.clone(), parse_option_key_set(rhs));
261    }
262    cache.get(&key)
263}
264
265/// `drifted_keys(T) = keys(source_pin, T) \ keys(target_pin, T)`,
266/// defined only for `(Closed | Open, Closed)` source-target pairs: an
267/// open target set suppresses the whole type, by match arm rather
268/// than by flag.
269#[must_use]
270pub fn drifted_keys(source: &OptionKeySet, target: &OptionKeySet) -> BTreeSet<MapKey> {
271    match target {
272        OptionKeySet::Closed(target_keys) => source
273            .literal_keys()
274            .iter()
275            .filter(|k| !target_keys.contains(*k))
276            .cloned()
277            .collect(),
278        OptionKeySet::Open(_) | OptionKeySet::Unresolved => BTreeSet::new(),
279    }
280}
281
282/// The family-declared option types with both-side `Closed`-eligible
283/// drift sets, i.e. every `(module, type_name, drifted_keys)` worth
284/// scanning the patch for.
285pub fn drifted_key_sets(
286    defaults: &FamilyDefaults,
287    target: &Snapshot<state::Canonical>,
288    source: &Snapshot<state::Canonical>,
289    cache: &mut OptionKeySetCache,
290) -> Vec<(ModuleName, TypeName, BTreeSet<MapKey>)> {
291    let mut out = Vec::new();
292    for decl in &defaults.option_types {
293        let Some(source_set) =
294            cached_option_key_set(cache, source, &decl.module, &decl.type_name).cloned()
295        else {
296            continue;
297        };
298        let Some(target_set) =
299            cached_option_key_set(cache, target, &decl.module, &decl.type_name).cloned()
300        else {
301            continue;
302        };
303        let drifted = drifted_keys(&source_set, &target_set);
304        if !drifted.is_empty() {
305            out.push((decl.module.clone(), decl.type_name.clone(), drifted));
306        }
307    }
308    out
309}