1use std::collections::{BTreeMap, BTreeSet};
19use std::marker::PhantomData;
20use std::path::{Path, PathBuf};
21use std::str;
22
23use thiserror::Error;
24
25use backhopper_erlang_scan::{quoted_atom_span, skip_char_literal_span, string_span};
26
27use crate::compat::otp::is_otp_module;
28use crate::compat::target_tree_index::TargetTreeIndex;
29use crate::model::names::{Arity, FunctionName, ModuleName, RelativePath};
30use crate::model::verdict::{Reason, TestCallSite};
31
32pub mod state {
33 mod sealed {
34 pub trait Sealed {}
35 impl Sealed for super::Raw {}
36 impl Sealed for super::Parsed {}
37 impl Sealed for super::Resolved {}
38 }
39
40 pub trait SuiteState: sealed::Sealed {}
41
42 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43 pub struct Raw;
44 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
45 pub struct Parsed;
46 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
47 pub struct Resolved;
48
49 impl SuiteState for Raw {}
50 impl SuiteState for Parsed {}
51 impl SuiteState for Resolved {}
52}
53
54use state::{Parsed, Raw, Resolved, SuiteState};
55
56#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct HelperCall {
61 pub module: ModuleName,
62 pub function: FunctionName,
63 pub arity: Arity,
64 pub line: u32,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct MissingHelper {
71 pub module: ModuleName,
72 pub call_sites: Vec<TestCallSite>,
73}
74
75#[non_exhaustive]
79#[derive(Debug, Error, PartialEq, Eq)]
80pub enum ParseError {
81 #[error("suite source is empty: {0}")]
82 EmptySource(RelativePath),
83}
84
85pub fn is_stdlib_or_otp(module: &ModuleName) -> bool {
96 if is_otp_module(module) {
97 return true;
98 }
99 matches!(
100 module.as_str(),
101 "ct" | "ct_helper"
102 | "ct_property_test"
103 | "common_test"
104 | "meck"
105 | "proper"
106 | "proper_types"
107 )
108}
109
110#[derive(Debug, Clone)]
111pub struct TestSuiteFile<S: SuiteState> {
112 suite_path: RelativePath,
113 source: String,
114 calls: Vec<HelperCall>,
115 missing: Vec<MissingHelper>,
116 _state: PhantomData<S>,
117}
118
119impl TestSuiteFile<Raw> {
120 pub fn new(suite_path: RelativePath, source: String) -> Self {
121 Self {
122 suite_path,
123 source,
124 calls: Vec::new(),
125 missing: Vec::new(),
126 _state: PhantomData,
127 }
128 }
129
130 pub fn parse(self) -> Result<TestSuiteFile<Parsed>, ParseError> {
135 if self.source.trim().is_empty() {
136 return Err(ParseError::EmptySource(self.suite_path));
137 }
138 let calls = scan_helper_calls(&self.source);
139 Ok(TestSuiteFile {
140 suite_path: self.suite_path,
141 source: self.source,
142 calls,
143 missing: Vec::new(),
144 _state: PhantomData,
145 })
146 }
147
148 pub fn suite_path(&self) -> &RelativePath {
149 &self.suite_path
150 }
151
152 pub fn source(&self) -> &str {
153 &self.source
154 }
155}
156
157impl TestSuiteFile<Parsed> {
158 pub fn referenced_modules(&self) -> Vec<ModuleName> {
162 let mut seen = BTreeSet::new();
163 let mut out = Vec::new();
164 for c in &self.calls {
165 if seen.insert(c.module.clone()) {
166 out.push(c.module.clone());
167 }
168 }
169 out
170 }
171
172 pub fn calls(&self) -> &[HelperCall] {
173 &self.calls
174 }
175
176 pub fn resolve(
182 self,
183 target: &TargetTreeIndex,
184 search_path_globs: &[String],
185 ) -> TestSuiteFile<Resolved> {
186 let resolver = HelperResolver::new(target, search_path_globs);
187 let mut by_module: BTreeMap<ModuleName, Vec<TestCallSite>> = BTreeMap::new();
188 for call in &self.calls {
189 if is_stdlib_or_otp(&call.module) {
190 continue;
191 }
192 if resolver.resolves(&call.module) {
193 continue;
194 }
195 by_module
196 .entry(call.module.clone())
197 .or_default()
198 .push(TestCallSite {
199 function: call.function.clone(),
200 arity: call.arity,
201 line: call.line,
202 });
203 }
204 let missing = by_module
205 .into_iter()
206 .map(|(module, call_sites)| MissingHelper { module, call_sites })
207 .collect();
208 TestSuiteFile {
209 suite_path: self.suite_path,
210 source: self.source,
211 calls: self.calls,
212 missing,
213 _state: PhantomData,
214 }
215 }
216
217 pub fn suite_path(&self) -> &RelativePath {
218 &self.suite_path
219 }
220}
221
222impl TestSuiteFile<Resolved> {
223 pub fn suite_path(&self) -> &RelativePath {
224 &self.suite_path
225 }
226
227 pub fn missing_modules(&self) -> &[MissingHelper] {
228 &self.missing
229 }
230
231 pub fn into_findings(self) -> ResolvedFindings {
235 if self.missing.is_empty() {
236 return ResolvedFindings {
237 reasons: Vec::new(),
238 diagnostic_entry: None,
239 };
240 }
241 let mut reasons = Vec::with_capacity(self.missing.len());
242 let mut counts: BTreeMap<ModuleName, usize> = BTreeMap::new();
243 for m in self.missing {
244 counts.insert(m.module.clone(), m.call_sites.len());
245 reasons.push(Reason::TestModuleSymbolMissing {
246 suite_path: self.suite_path.clone(),
247 missing_module: m.module,
248 call_sites: m.call_sites,
249 });
250 }
251 ResolvedFindings {
252 reasons,
253 diagnostic_entry: Some((self.suite_path, counts)),
254 }
255 }
256}
257
258#[non_exhaustive]
262#[derive(Debug, Clone, Default)]
263pub struct ResolvedFindings {
264 pub reasons: Vec<Reason>,
265 pub diagnostic_entry: Option<(RelativePath, BTreeMap<ModuleName, usize>)>,
266}
267
268fn scan_helper_calls(source: &str) -> Vec<HelperCall> {
275 let bytes = source.as_bytes();
276 let mut out = Vec::new();
277 let mut i = 0;
278 let mut line = 1u32;
279 while i < bytes.len() {
280 let b = bytes[i];
281 if b == b'\n' {
282 line += 1;
283 i += 1;
284 continue;
285 }
286 if b == b'%' {
287 while i < bytes.len() && bytes[i] != b'\n' {
288 i += 1;
289 }
290 continue;
291 }
292 if b == b'"' || b == b'~' {
293 let span = string_span(bytes, i).unwrap_or(1);
294 line += newline_count(&bytes[i..i + span]);
295 i += span;
296 continue;
297 }
298 if b == b'$' {
299 let span = skip_char_literal_span(bytes, i);
300 line += newline_count(&bytes[i..i + span]);
301 i += span;
302 continue;
303 }
304 if b == b'\'' {
305 let span = quoted_atom_span(bytes, i).unwrap_or(1);
306 line += newline_count(&bytes[i..i + span]);
307 i += span;
308 continue;
309 }
310 if b.is_ascii_uppercase() || b == b'_' {
312 while i < bytes.len() && is_atom_continue(bytes[i]) {
313 i += 1;
314 }
315 continue;
316 }
317 if b == b'?' {
319 i += 1;
320 while i < bytes.len() && is_atom_continue(bytes[i]) {
321 i += 1;
322 }
323 continue;
324 }
325 if is_atom_start(b) {
326 let start = i;
327 while i < bytes.len() && is_atom_continue(bytes[i]) {
328 i += 1;
329 }
330 let atom = str::from_utf8(&bytes[start..i]).unwrap_or("");
331 if i < bytes.len() && bytes[i] == b':' {
332 let after_colon = i + 1;
333 let mut j = after_colon;
334 while j < bytes.len() && is_atom_continue(bytes[j]) {
335 j += 1;
336 }
337 if j > after_colon {
338 let func = str::from_utf8(&bytes[after_colon..j]).unwrap_or("");
339 if !func.starts_with(|c: char| c.is_ascii_lowercase()) {
340 i = j;
341 continue;
342 }
343 let lookahead = skip_spaces(bytes, j);
344 if lookahead < bytes.len() && bytes[lookahead] == b'(' {
345 let arity = count_top_level_args(bytes, lookahead);
346 if let Ok(arity_u8) = u8::try_from(arity)
347 && let (Ok(module), Ok(function)) =
348 (ModuleName::new(atom), FunctionName::new(func))
349 {
350 out.push(HelperCall {
351 module,
352 function,
353 arity: Arity::new(arity_u8),
354 line,
355 });
356 }
357 i = lookahead;
358 continue;
359 }
360 }
361 i = j;
362 continue;
363 }
364 continue;
365 }
366 i += 1;
367 }
368 out
369}
370
371fn is_atom_start(b: u8) -> bool {
372 b.is_ascii_lowercase()
373}
374
375fn is_atom_continue(b: u8) -> bool {
376 b.is_ascii_alphanumeric() || b == b'_' || b == b'@'
377}
378
379fn newline_count(span: &[u8]) -> u32 {
380 let mut n = 0;
381 for &b in span {
382 if b == b'\n' {
383 n += 1;
384 }
385 }
386 n
387}
388
389fn skip_spaces(bytes: &[u8], mut i: usize) -> usize {
390 while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
391 i += 1;
392 }
393 i
394}
395
396fn count_top_level_args(bytes: &[u8], open: usize) -> u32 {
400 debug_assert_eq!(bytes[open], b'(');
401 let mut depth = 1i32;
402 let mut i = open + 1;
403 let mut saw_token = false;
404 let mut commas = 0u32;
405 while i < bytes.len() && depth > 0 {
406 let b = bytes[i];
407 match b {
408 b'(' | b'[' | b'{' => {
409 depth += 1;
410 saw_token = true;
411 }
412 b')' | b']' | b'}' => {
413 depth -= 1;
414 if depth == 0 {
415 break;
416 }
417 }
418 b',' if depth == 1 => {
419 commas += 1;
420 }
421 b'"' | b'~' => {
422 i += string_span(bytes, i).unwrap_or(1);
423 saw_token = true;
424 continue;
425 }
426 b'$' => {
427 i += skip_char_literal_span(bytes, i);
428 saw_token = true;
429 continue;
430 }
431 b'\'' => {
432 i += quoted_atom_span(bytes, i).unwrap_or(1);
433 saw_token = true;
434 continue;
435 }
436 b'%' => {
437 while i < bytes.len() && bytes[i] != b'\n' {
438 i += 1;
439 }
440 continue;
441 }
442 b if !b.is_ascii_whitespace() => {
443 saw_token = true;
444 }
445 _ => {}
446 }
447 i += 1;
448 }
449 if saw_token { commas + 1 } else { 0 }
450}
451
452pub fn module_resolves(
457 target: &TargetTreeIndex,
458 search_path_globs: &[String],
459 module: &ModuleName,
460) -> bool {
461 HelperResolver::new(target, search_path_globs).resolves(module)
462}
463
464struct HelperResolver<'a> {
472 target: &'a TargetTreeIndex,
473 literal_roots: Vec<PathBuf>,
474 wildcard_roots: Vec<WildcardRoot>,
475}
476
477#[derive(Debug, Clone)]
478struct WildcardRoot {
479 prefix: PathBuf,
480 suffix: PathBuf,
481}
482
483impl<'a> HelperResolver<'a> {
484 fn new(target: &'a TargetTreeIndex, search_path_globs: &[String]) -> Self {
485 let mut literal_roots = Vec::new();
486 let mut wildcard_roots = Vec::new();
487 for g in search_path_globs {
488 match split_single_wildcard(g) {
489 Some((prefix, suffix)) => wildcard_roots.push(WildcardRoot { prefix, suffix }),
490 None => literal_roots.push(PathBuf::from(g)),
491 }
492 }
493 Self {
494 target,
495 literal_roots,
496 wildcard_roots,
497 }
498 }
499
500 fn resolves(&self, module: &ModuleName) -> bool {
503 let filename = format!("{}.erl", module.as_str());
504 for root in &self.literal_roots {
505 if self.target.contains_path(&root.join(&filename)) {
506 return true;
507 }
508 }
509 if self.wildcard_roots.is_empty() {
510 return false;
511 }
512 if let Some(path) = self.target.module_erl_path(module) {
514 return self
515 .wildcard_roots
516 .iter()
517 .any(|w| path_matches_wildcard(path, &w.prefix, &w.suffix));
518 }
519 for path in self.target.present_paths() {
521 if path.file_name().and_then(|s| s.to_str()) != Some(filename.as_str()) {
522 continue;
523 }
524 for w in &self.wildcard_roots {
525 if path_matches_wildcard(path, &w.prefix, &w.suffix) {
526 return true;
527 }
528 }
529 }
530 false
531 }
532}
533
534fn split_single_wildcard(glob: &str) -> Option<(PathBuf, PathBuf)> {
539 let segs: Vec<&str> = glob.split('/').collect();
540 let mut star_at = None;
541 for (i, s) in segs.iter().enumerate() {
542 if s.contains('*') {
543 if *s != "*" || star_at.is_some() {
544 return None;
545 }
546 star_at = Some(i);
547 }
548 }
549 let star_at = star_at?;
550 let prefix: PathBuf = segs[..star_at].iter().collect();
551 let suffix: PathBuf = segs[star_at + 1..].iter().collect();
552 Some((prefix, suffix))
553}
554
555fn path_matches_wildcard(path: &Path, prefix: &Path, suffix: &Path) -> bool {
559 let Some(parent) = path.parent() else {
560 return false;
561 };
562 let parent_comps: Vec<_> = parent.components().collect();
563 let prefix_comps: Vec<_> = prefix.components().collect();
564 let suffix_comps: Vec<_> = suffix.components().collect();
565 if parent_comps.len() != prefix_comps.len() + 1 + suffix_comps.len() {
566 return false;
567 }
568 if parent_comps[..prefix_comps.len()] != prefix_comps[..] {
569 return false;
570 }
571 let suffix_start = prefix_comps.len() + 1;
572 parent_comps[suffix_start..] == suffix_comps[..]
573}