backhopper_core/compat/
option_keys.rs1use 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#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum OptionKeySet {
28 Closed(BTreeSet<MapKey>),
30 Open(BTreeSet<MapKey>),
34 Unresolved,
37}
38
39impl OptionKeySet {
40 #[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#[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 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
102fn 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
135fn 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#[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
216fn 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
233pub type OptionKeySetCache =
237 std::collections::BTreeMap<(ProjectName, TagName, ModuleName, TypeName), OptionKeySet>;
238
239pub 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#[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
282pub 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}