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