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