use std::collections::BTreeSet;
use backhopper_erlang_scan::{
matching_bracket, quoted_atom_span, skip_char_literal_span, split_top_level_commas, string_span,
};
use crate::config::FamilyDefaults;
use crate::model::names::{MapKey, ModuleName, ProjectName, TagName, TypeName};
use crate::model::snapshot::{Snapshot, state};
use crate::model::symbol::LineClass;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OptionKeySet {
Closed(BTreeSet<MapKey>),
Open(BTreeSet<MapKey>),
Unresolved,
}
impl OptionKeySet {
#[must_use]
pub fn literal_keys(&self) -> &BTreeSet<MapKey> {
static EMPTY: BTreeSet<MapKey> = BTreeSet::new();
match self {
Self::Closed(keys) | Self::Open(keys) => keys,
Self::Unresolved => &EMPTY,
}
}
}
#[must_use]
pub fn parse_option_key_set(rhs: &str) -> OptionKeySet {
let trimmed = rhs.trim();
if !trimmed.starts_with("#{") {
return OptionKeySet::Unresolved;
}
let bytes = trimmed.as_bytes();
let Some(close) = matching_bracket(bytes, 1) else {
return OptionKeySet::Unresolved;
};
let after = trimmed[close + 1..].trim().trim_end_matches('.').trim();
if !after.is_empty() {
return OptionKeySet::Unresolved;
}
let inner = &trimmed[2..close];
let mut keys = BTreeSet::new();
let mut open = false;
for entry in split_top_level_commas(inner) {
let Some(key_text) = key_part(entry) else {
open = true;
continue;
};
match literal_atom_key(key_text.trim()) {
Some(atom) => {
if let Ok(k) = MapKey::new(atom) {
keys.insert(k);
}
}
None => open = true,
}
}
if open {
OptionKeySet::Open(keys)
} else {
OptionKeySet::Closed(keys)
}
}
fn key_part(entry: &str) -> Option<&str> {
let bytes = entry.as_bytes();
let mut depth: i32 = 0;
let mut i = 0usize;
while i + 1 < bytes.len() {
match bytes[i] {
b'"' | b'~' => {
i += string_span(bytes, i).unwrap_or(1);
continue;
}
b'\'' => {
i += quoted_atom_span(bytes, i).unwrap_or(1);
continue;
}
b'$' => {
i += skip_char_literal_span(bytes, i);
continue;
}
b'{' | b'[' | b'(' => depth += 1,
b'}' | b']' | b')' => depth -= 1,
b':' if depth == 0 && bytes[i + 1] == b'=' => return Some(&entry[..i]),
b'=' if depth == 0 && bytes[i + 1] == b'>' => return Some(&entry[..i]),
_ => {}
}
i += 1;
}
None
}
fn literal_atom_key(key_text: &str) -> Option<String> {
if let Some(rest) = key_text.strip_prefix('\'') {
let end = rest.find('\'')?;
return Some(rest[..end].to_owned());
}
let mut chars = key_text.chars();
let first = chars.next()?;
if !first.is_ascii_lowercase() {
return None;
}
if !key_text
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '@')
{
return None;
}
Some(key_text.to_owned())
}
#[must_use]
pub fn scan_added_map_keys(added_text: &str, line_ctx: &[LineClass]) -> BTreeSet<MapKey> {
let mut keys = BTreeSet::new();
for (line, ctx) in added_text.lines().zip(line_ctx.iter()) {
if ctx.context.is_attribute() {
continue;
}
scan_line_for_map_keys(line, &mut keys);
}
keys
}
fn scan_line_for_map_keys(line: &str, keys: &mut BTreeSet<MapKey>) {
let bytes = line.as_bytes();
let mut i = 0usize;
while i < bytes.len() {
match bytes[i] {
b'%' => break,
b'"' | b'~' => i += string_span(bytes, i).unwrap_or(1),
b'\'' => {
let span = quoted_atom_span(bytes, i).unwrap_or(1);
if span >= 2 {
let atom_text = &line[i + 1..i + span - 1];
if let Some(consumed) = arrow_after(bytes, i + span)
&& let Ok(k) = MapKey::new(atom_text.to_owned())
{
keys.insert(k);
i += span + consumed;
continue;
}
}
i += span;
}
b'$' => i += skip_char_literal_span(bytes, i),
b'a'..=b'z' => {
let start = i;
while i < bytes.len()
&& (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_' || bytes[i] == b'@')
{
i += 1;
}
let atom_text = &line[start..i];
if let Some(consumed) = arrow_after(bytes, i)
&& let Ok(k) = MapKey::new(atom_text.to_owned())
{
keys.insert(k);
i += consumed;
}
}
_ => i += 1,
}
}
}
fn arrow_after(bytes: &[u8], mut i: usize) -> Option<usize> {
let start = i;
while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
i += 1;
}
if i + 1 < bytes.len()
&& ((bytes[i] == b':' && bytes[i + 1] == b'=')
|| (bytes[i] == b'=' && bytes[i + 1] == b'>'))
{
Some(i + 2 - start)
} else {
None
}
}
pub type OptionKeySetCache =
std::collections::BTreeMap<(ProjectName, TagName, ModuleName, TypeName), OptionKeySet>;
pub fn cached_option_key_set<'a>(
cache: &'a mut OptionKeySetCache,
snapshot: &Snapshot<state::Canonical>,
module: &ModuleName,
type_name: &TypeName,
) -> Option<&'a OptionKeySet> {
let key = (
snapshot.header().project.clone(),
snapshot.header().tag.clone(),
module.clone(),
type_name.clone(),
);
if !cache.contains_key(&key) {
let rhs = snapshot
.module_named(module)?
.types
.iter()
.find(|t| &t.name == type_name)
.map(|t| t.rhs.as_str())?;
cache.insert(key.clone(), parse_option_key_set(rhs));
}
cache.get(&key)
}
#[must_use]
pub fn drifted_keys(source: &OptionKeySet, target: &OptionKeySet) -> BTreeSet<MapKey> {
match target {
OptionKeySet::Closed(target_keys) => source
.literal_keys()
.iter()
.filter(|k| !target_keys.contains(*k))
.cloned()
.collect(),
OptionKeySet::Open(_) | OptionKeySet::Unresolved => BTreeSet::new(),
}
}
pub fn drifted_key_sets(
defaults: &FamilyDefaults,
target: &Snapshot<state::Canonical>,
source: &Snapshot<state::Canonical>,
cache: &mut OptionKeySetCache,
) -> Vec<(ModuleName, TypeName, BTreeSet<MapKey>)> {
let mut out = Vec::new();
for decl in &defaults.option_types {
let Some(source_set) =
cached_option_key_set(cache, source, &decl.module, &decl.type_name).cloned()
else {
continue;
};
let Some(target_set) =
cached_option_key_set(cache, target, &decl.module, &decl.type_name).cloned()
else {
continue;
};
let drifted = drifted_keys(&source_set, &target_set);
if !drifted.is_empty() {
out.push((decl.module.clone(), decl.type_name.clone(), drifted));
}
}
out
}