use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use syn::visit::Visit;
use crate::error::Error;
use crate::util;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FixtureKind {
Pass,
CompileFail,
}
impl From<crate::discovery::FixtureKind> for FixtureKind {
fn from(k: crate::discovery::FixtureKind) -> Self {
match k {
crate::discovery::FixtureKind::CompilePass => Self::Pass,
crate::discovery::FixtureKind::CompileFail => Self::CompileFail,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiscoveredFixture {
pub fixture_path: PathBuf,
pub relative_path: String,
pub kind: FixtureKind,
pub call_site: CallSite,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CallSite {
pub file: PathBuf,
pub line: usize,
pub enclosing_test_fn: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiscoveryUnrecognized {
pub file: PathBuf,
pub line: usize,
pub detail: String,
}
#[derive(Debug, Clone)]
pub struct DiscoveryOutput {
pub fixtures: Vec<DiscoveredFixture>,
pub unrecognized: Vec<DiscoveryUnrecognized>,
}
pub fn discover(crate_root: &Path, custom_macros: &[String]) -> Result<DiscoveryOutput, Error> {
let tests_dir = crate_root.join("tests");
let mut fixtures: Vec<DiscoveredFixture> = Vec::new();
let mut unrecognized: Vec<DiscoveryUnrecognized> = Vec::new();
let test_files = match list_top_level_test_files(&tests_dir) {
Ok(v) => v,
Err(e) => match e {
ListError::DoesNotExist => Vec::new(),
ListError::Io(err) => return Err(err),
},
};
let alias_set: Vec<&str> = custom_macros.iter().map(String::as_str).collect();
for test_file in test_files {
let source = match std::fs::read_to_string(&test_file) {
Ok(s) => s,
Err(e) => {
return Err(Error::io(
e,
"reading test file for compat discovery",
Some(test_file.clone()),
));
}
};
let ast = match syn::parse_file(&source) {
Ok(ast) => ast,
Err(parse_err) => {
let line = parse_err.span().start().line.max(1);
unrecognized.push(DiscoveryUnrecognized {
file: test_file.clone(),
line,
detail: format!("parse_failed: {parse_err}"),
});
continue;
}
};
if let Some(cfg_attr) = find_cfg_attribute(&ast.attrs) {
let attr_kind = if cfg_attr.meta.path().is_ident("cfg_attr") {
"cfg_attr"
} else {
"cfg"
};
let line = cfg_attr.pound_token.span.start().line.max(1);
unrecognized.push(DiscoveryUnrecognized {
file: test_file.clone(),
line,
detail: format!(
"file is cfg-gated at the inner attribute level (`#![{attr_kind}(...)]`); \
trybuild discovery cannot evaluate the cfg without resolution. \
Treat as unrecognized."
),
});
continue;
}
let mut visitor = DiscoveryVisitor::new(&test_file, &alias_set);
visitor.visit_file(&ast);
for hit in visitor.hits {
match resolve_literal_to_fixtures(crate_root, &test_file, &hit.literal) {
Ok(paths) => {
if paths.is_empty() {
unrecognized.push(DiscoveryUnrecognized {
file: hit.call_site.file.clone(),
line: hit.call_site.line,
detail: format!(
"literal `{}` resolved to zero fixture paths",
hit.literal
),
});
}
for path in paths {
let relative_path = relative_repo_path(crate_root, &path);
fixtures.push(DiscoveredFixture {
fixture_path: path,
relative_path,
kind: hit.kind,
call_site: hit.call_site.clone(),
});
}
}
Err(detail) => {
unrecognized.push(DiscoveryUnrecognized {
file: hit.call_site.file.clone(),
line: hit.call_site.line,
detail,
});
}
}
}
unrecognized.extend(visitor.unrecognized);
}
fixtures.sort_by(|a, b| a.relative_path.as_bytes().cmp(b.relative_path.as_bytes()));
fixtures.dedup_by(|a, b| {
a.relative_path == b.relative_path && a.kind == b.kind && a.call_site == b.call_site
});
unrecognized.sort_by(|a, b| {
let af = a.file.as_os_str().as_encoded_bytes();
let bf = b.file.as_os_str().as_encoded_bytes();
af.cmp(bf)
.then_with(|| a.line.cmp(&b.line))
.then_with(|| a.detail.as_bytes().cmp(b.detail.as_bytes()))
});
Ok(DiscoveryOutput {
fixtures,
unrecognized,
})
}
fn relative_repo_path(crate_root: &Path, absolute: &Path) -> String {
util::relative_to(absolute, crate_root).unwrap_or_else(|err| err.non_absolute_path())
}
enum ListError {
DoesNotExist,
Io(Error),
}
fn list_top_level_test_files(tests_dir: &Path) -> Result<Vec<PathBuf>, ListError> {
let entries = match std::fs::read_dir(tests_dir) {
Ok(it) => it,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(ListError::DoesNotExist);
}
Err(e) => {
return Err(ListError::Io(Error::io(
e,
"reading tests/ directory for compat discovery",
Some(tests_dir.to_path_buf()),
)));
}
};
let mut files: Vec<PathBuf> = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| {
ListError::Io(Error::io(
e,
"reading tests/ directory entry for compat discovery",
Some(tests_dir.to_path_buf()),
))
})?;
let ft = entry.file_type().map_err(|e| {
ListError::Io(Error::io(
e,
"stat-ing tests/ directory entry for compat discovery",
Some(entry.path()),
))
})?;
if !ft.is_file() {
continue;
}
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("rs") {
continue;
}
files.push(path);
}
files.sort_by(|a, b| {
a.as_os_str()
.as_encoded_bytes()
.cmp(b.as_os_str().as_encoded_bytes())
});
Ok(files)
}
struct VisitorHit {
kind: FixtureKind,
literal: String,
call_site: CallSite,
}
struct DiscoveryVisitor<'a> {
current_file: &'a Path,
alias_segments: Vec<Vec<String>>,
alias_with_new_segments: Vec<Vec<String>>,
enclosing_test_fn: Option<String>,
local_bindings: BTreeSet<String>,
aliased_testcases: BTreeSet<String>,
aliased_bindings: BTreeSet<String>,
imported_testcases: BTreeSet<String>,
hits: Vec<VisitorHit>,
unrecognized: Vec<DiscoveryUnrecognized>,
}
impl<'a> DiscoveryVisitor<'a> {
fn new(current_file: &'a Path, custom_macros: &'a [&'a str]) -> Self {
let alias_segments: Vec<Vec<String>> = custom_macros
.iter()
.map(|alias| {
alias
.split("::")
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect()
})
.collect();
let alias_with_new_segments: Vec<Vec<String>> = alias_segments
.iter()
.map(|segs| {
let mut v = segs.clone();
v.push("new".to_string());
v
})
.collect();
Self {
current_file,
alias_segments,
alias_with_new_segments,
enclosing_test_fn: None,
local_bindings: BTreeSet::new(),
aliased_testcases: BTreeSet::new(),
aliased_bindings: BTreeSet::new(),
imported_testcases: BTreeSet::new(),
hits: Vec::new(),
unrecognized: Vec::new(),
}
}
fn receiver_is_testcases(&self, expr: &syn::Expr) -> bool {
match expr {
syn::Expr::Call(call) => {
let func = &*call.func;
self.is_testcases_constructor_path(func) && call.args.is_empty()
}
syn::Expr::MethodCall(inner) => self.receiver_is_testcases(&inner.receiver),
syn::Expr::Path(path_expr) => {
if path_expr.attrs.is_empty()
&& path_expr.qself.is_none()
&& let Some(ident) = path_expr.path.get_ident()
{
return self.local_bindings.contains(&ident.to_string());
}
false
}
syn::Expr::Reference(r) => self.receiver_is_testcases(&r.expr),
syn::Expr::Paren(p) => self.receiver_is_testcases(&p.expr),
syn::Expr::Group(g) => self.receiver_is_testcases(&g.expr),
_ => false,
}
}
fn is_testcases_constructor_path(&self, expr: &syn::Expr) -> bool {
let syn::Expr::Path(p) = expr else {
return false;
};
if p.qself.is_some() || !p.attrs.is_empty() {
return false;
}
if path_matches_segments(&p.path, &["trybuild", "TestCases", "new"]) {
return true;
}
if p.path.leading_colon.is_none() && p.path.segments.len() == 2 {
let first = p.path.segments[0].ident.to_string();
if self.imported_testcases.contains(&first) && p.path.segments[1].ident == "new" {
return true;
}
}
for (alias_segs, with_new_segs) in self
.alias_segments
.iter()
.zip(self.alias_with_new_segments.iter())
{
if path_matches_string_segments(&p.path, with_new_segs)
|| path_matches_string_segments(&p.path, alias_segs)
{
return true;
}
}
false
}
fn make_call_site(&self, method: &syn::Ident) -> CallSite {
CallSite {
file: self.current_file.to_path_buf(),
line: method.span().start().line.max(1),
enclosing_test_fn: self.enclosing_test_fn.clone(),
}
}
fn extract_string_literal_arg(node: &syn::ExprMethodCall) -> Option<String> {
if node.args.len() != 1 {
return None;
}
let arg = node.args.first()?;
match arg {
syn::Expr::Lit(lit) if lit.attrs.is_empty() => match &lit.lit {
syn::Lit::Str(s) => Some(s.value()),
_ => None,
},
_ => None,
}
}
fn try_record_terminal_call(&mut self, node: &syn::ExprMethodCall) -> bool {
let method_str = node.method.to_string();
let kind = match method_str.as_str() {
"pass" => FixtureKind::Pass,
"compile_fail" => FixtureKind::CompileFail,
_ => return false,
};
if !self.receiver_is_testcases(&node.receiver) {
if self.receiver_is_aliased_testcases(&node.receiver) {
let call_site = self.make_call_site(&node.method);
self.unrecognized.push(DiscoveryUnrecognized {
file: call_site.file,
line: call_site.line,
detail: format!(
".{method_str}() called via an unregistered `use ... as` alias — \
register the originating constructor path via \
--compat-trybuild-macro so discovery can match the call site"
),
});
return true;
}
return false;
}
let call_site = self.make_call_site(&node.method);
match Self::extract_string_literal_arg(node) {
Some(literal) => {
self.hits.push(VisitorHit {
kind,
literal,
call_site,
});
}
None => {
self.unrecognized.push(DiscoveryUnrecognized {
file: call_site.file,
line: call_site.line,
detail: format!(
"non-literal or multi-argument call to .{method_str}() — \
only `<TestCases>.{method_str}(\"<path>\")` is recognized in v0.1"
),
});
}
}
true
}
}
impl<'ast, 'a> Visit<'ast> for DiscoveryVisitor<'a> {
fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
if let Some(cfg_attr) = find_cfg_attribute(&node.attrs) {
let attr_kind = if cfg_attr.meta.path().is_ident("cfg_attr") {
"cfg_attr"
} else {
"cfg"
};
let line = node.sig.ident.span().start().line.max(1);
let fn_name = node.sig.ident.to_string();
self.unrecognized.push(DiscoveryUnrecognized {
file: self.current_file.to_path_buf(),
line,
detail: format!(
"function `{fn_name}` is cfg-gated (`#[{attr_kind}(...)]`); \
trybuild discovery cannot evaluate the cfg without resolution. \
Treat as unrecognized."
),
});
return;
}
let saved_enclosing = self.enclosing_test_fn.take();
let saved_bindings = std::mem::take(&mut self.local_bindings);
let saved_aliased_bindings = std::mem::take(&mut self.aliased_bindings);
let saved_imported = self.imported_testcases.clone();
let saved_aliased = self.aliased_testcases.clone();
if is_test_attribute(&node.attrs) {
self.enclosing_test_fn = Some(node.sig.ident.to_string());
}
syn::visit::visit_item_fn(self, node);
self.enclosing_test_fn = saved_enclosing;
self.local_bindings = saved_bindings;
self.aliased_bindings = saved_aliased_bindings;
self.imported_testcases = saved_imported;
self.aliased_testcases = saved_aliased;
}
fn visit_local(&mut self, node: &'ast syn::Local) {
if let (syn::Pat::Ident(pat_ident), Some(init)) = (&node.pat, &node.init)
&& pat_ident.attrs.is_empty()
&& pat_ident.by_ref.is_none()
&& pat_ident.subpat.is_none()
{
let ident = pat_ident.ident.to_string();
if self.is_testcases_constructor_expr(&init.expr) {
self.local_bindings.insert(ident.clone());
self.aliased_bindings.remove(&ident);
} else if self.is_aliased_testcases_constructor_expr(&init.expr) {
self.aliased_bindings.insert(ident.clone());
self.local_bindings.remove(&ident);
} else {
self.local_bindings.remove(&ident);
self.aliased_bindings.remove(&ident);
}
}
syn::visit::visit_local(self, node);
}
fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) {
self.try_record_terminal_call(node);
syn::visit::visit_expr_method_call(self, node);
}
fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
if let Some(cfg_attr) = find_cfg_attribute(&node.attrs) {
let attr_kind = if cfg_attr.meta.path().is_ident("cfg_attr") {
"cfg_attr"
} else {
"cfg"
};
let line = node.sig.ident.span().start().line.max(1);
let fn_name = node.sig.ident.to_string();
self.unrecognized.push(DiscoveryUnrecognized {
file: self.current_file.to_path_buf(),
line,
detail: format!(
"function `{fn_name}` is cfg-gated (`#[{attr_kind}(...)]`); \
trybuild discovery cannot evaluate the cfg without resolution. \
Treat as unrecognized."
),
});
return;
}
let saved_enclosing = self.enclosing_test_fn.take();
let saved_bindings = std::mem::take(&mut self.local_bindings);
let saved_aliased_bindings = std::mem::take(&mut self.aliased_bindings);
let saved_imported = self.imported_testcases.clone();
let saved_aliased = self.aliased_testcases.clone();
if is_test_attribute(&node.attrs) {
self.enclosing_test_fn = Some(node.sig.ident.to_string());
}
syn::visit::visit_impl_item_fn(self, node);
self.enclosing_test_fn = saved_enclosing;
self.local_bindings = saved_bindings;
self.aliased_bindings = saved_aliased_bindings;
self.imported_testcases = saved_imported;
self.aliased_testcases = saved_aliased;
}
fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
if let Some(cfg_attr) = find_cfg_attribute(&node.attrs) {
let attr_kind = if cfg_attr.meta.path().is_ident("cfg_attr") {
"cfg_attr"
} else {
"cfg"
};
let line = node.ident.span().start().line.max(1);
let mod_name = node.ident.to_string();
self.unrecognized.push(DiscoveryUnrecognized {
file: self.current_file.to_path_buf(),
line,
detail: format!(
"module `{mod_name}` is cfg-gated (`#[{attr_kind}(...)]`); \
trybuild discovery cannot evaluate the cfg without resolution. \
Treat as unrecognized."
),
});
return;
}
let saved_imported = std::mem::take(&mut self.imported_testcases);
let saved_aliased = std::mem::take(&mut self.aliased_testcases);
syn::visit::visit_item_mod(self, node);
self.imported_testcases = saved_imported;
self.aliased_testcases = saved_aliased;
}
fn visit_item_macro(&mut self, node: &'ast syn::ItemMacro) {
if node.ident.is_some() {
syn::visit::visit_item_macro(self, node);
return;
}
let path_str = path_segments_string(&node.mac.path);
let line = node.mac.bang_token.span.start().line.max(1);
self.unrecognized.push(DiscoveryUnrecognized {
file: self.current_file.to_path_buf(),
line,
detail: format!(
"macro invocation `{path_str}!` at module level is not a recognized v0.1 \
trybuild shape (discovery operates on the source AST, not on \
post-expansion tokens)"
),
});
syn::visit::visit_item_macro(self, node);
}
fn visit_item_type(&mut self, node: &'ast syn::ItemType) {
if find_cfg_attribute(&node.attrs).is_some() {
return;
}
if let syn::Type::Path(p) = &*node.ty
&& p.qself.is_none()
&& let Some(last) = p.path.segments.last()
&& last.ident == "TestCases"
{
let alias_ident = node.ident.to_string();
let rhs = path_segments_string(&p.path);
let line = node.ident.span().start().line.max(1);
self.unrecognized.push(DiscoveryUnrecognized {
file: self.current_file.to_path_buf(),
line,
detail: format!(
"type alias `{alias_ident} = {rhs}` is not recognized; trybuild detection \
requires the canonical `trybuild::TestCases::new()` form OR a registered \
`--compat-trybuild-macro` alias for the alias's `::new` path"
),
});
}
syn::visit::visit_item_type(self, node);
}
fn visit_item_use(&mut self, node: &'ast syn::ItemUse) {
if find_cfg_attribute(&node.attrs).is_some() {
return;
}
let mut sink = UseTreeSink {
renamed: &mut self.aliased_testcases,
imported: &mut self.imported_testcases,
};
collect_use_for_testcases(
&node.tree,
Vec::new(),
node.leading_colon.is_some(),
&mut sink,
);
syn::visit::visit_item_use(self, node);
}
}
impl<'a> DiscoveryVisitor<'a> {
fn is_aliased_testcases_constructor(&self, expr: &syn::Expr) -> bool {
let syn::Expr::Path(p) = expr else {
return false;
};
if p.qself.is_some() || !p.attrs.is_empty() || p.path.leading_colon.is_some() {
return false;
}
match p.path.segments.len() {
2 => {
let first = p.path.segments[0].ident.to_string();
let second = p.path.segments[1].ident.to_string();
second == "new" && self.aliased_testcases.contains(&first)
}
1 => {
let first = p.path.segments[0].ident.to_string();
self.aliased_testcases.contains(&first)
}
_ => false,
}
}
fn receiver_is_aliased_testcases(&self, expr: &syn::Expr) -> bool {
match expr {
syn::Expr::Call(call) => {
self.is_aliased_testcases_constructor(&call.func) && call.args.is_empty()
}
syn::Expr::MethodCall(inner) => self.receiver_is_aliased_testcases(&inner.receiver),
syn::Expr::Path(path_expr) => {
if path_expr.attrs.is_empty()
&& path_expr.qself.is_none()
&& let Some(ident) = path_expr.path.get_ident()
{
return self.aliased_bindings.contains(&ident.to_string());
}
false
}
syn::Expr::Reference(r) => self.receiver_is_aliased_testcases(&r.expr),
syn::Expr::Paren(p) => self.receiver_is_aliased_testcases(&p.expr),
syn::Expr::Group(g) => self.receiver_is_aliased_testcases(&g.expr),
_ => false,
}
}
fn is_testcases_constructor_expr(&self, expr: &syn::Expr) -> bool {
match expr {
syn::Expr::Call(call) => {
self.is_testcases_constructor_path(&call.func) && call.args.is_empty()
}
syn::Expr::Paren(p) => self.is_testcases_constructor_expr(&p.expr),
syn::Expr::Group(g) => self.is_testcases_constructor_expr(&g.expr),
_ => false,
}
}
fn is_aliased_testcases_constructor_expr(&self, expr: &syn::Expr) -> bool {
match expr {
syn::Expr::Call(call) => {
self.is_aliased_testcases_constructor(&call.func) && call.args.is_empty()
}
syn::Expr::Paren(p) => self.is_aliased_testcases_constructor_expr(&p.expr),
syn::Expr::Group(g) => self.is_aliased_testcases_constructor_expr(&g.expr),
_ => false,
}
}
}
fn is_test_attribute(attrs: &[syn::Attribute]) -> bool {
attrs.iter().any(|attr| {
if attr.meta.path().is_ident("test") {
return true;
}
let segments = path_segments_string(attr.meta.path());
matches!(
segments.as_str(),
"test" | "::test" | "core::test" | "::core::test" | "std::test" | "::std::test"
)
})
}
fn find_cfg_attribute(attrs: &[syn::Attribute]) -> Option<&syn::Attribute> {
attrs.iter().find(|attr| {
let path = attr.meta.path();
path.is_ident("cfg") || path.is_ident("cfg_attr")
})
}
fn path_segments_string(path: &syn::Path) -> String {
let mut s = String::new();
if path.leading_colon.is_some() {
s.push_str("::");
}
for (i, seg) in path.segments.iter().enumerate() {
if i > 0 {
s.push_str("::");
}
s.push_str(&seg.ident.to_string());
}
s
}
fn path_matches_segments(path: &syn::Path, expected: &[&str]) -> bool {
if path.leading_colon.is_some() || path.segments.len() != expected.len() {
return false;
}
path.segments
.iter()
.zip(expected.iter())
.all(|(seg, exp)| seg.ident == *exp)
}
fn path_matches_string_segments(path: &syn::Path, expected: &[String]) -> bool {
if path.leading_colon.is_some() || path.segments.len() != expected.len() {
return false;
}
path.segments
.iter()
.zip(expected.iter())
.all(|(seg, exp)| seg.ident == *exp.as_str())
}
struct UseTreeSink<'a> {
renamed: &'a mut BTreeSet<String>,
imported: &'a mut BTreeSet<String>,
}
fn collect_use_for_testcases(
tree: &syn::UseTree,
prefix: Vec<String>,
_leading_colon: bool,
sink: &mut UseTreeSink<'_>,
) {
match tree {
syn::UseTree::Path(path) => {
let mut next = prefix;
next.push(path.ident.to_string());
collect_use_for_testcases(&path.tree, next, _leading_colon, sink);
}
syn::UseTree::Rename(rename) => {
if rename.ident == "TestCases" && !prefix.is_empty() {
sink.renamed.insert(rename.rename.to_string());
}
}
syn::UseTree::Group(group) => {
for item in &group.items {
collect_use_for_testcases(item, prefix.clone(), _leading_colon, sink);
}
}
syn::UseTree::Name(name) => {
if name.ident == "TestCases" && prefix.as_slice() == ["trybuild"] {
sink.imported.insert(name.ident.to_string());
}
}
syn::UseTree::Glob(_) => {}
}
}
fn resolve_literal_to_fixtures(
crate_root: &Path,
test_file: &Path,
literal: &str,
) -> Result<Vec<PathBuf>, String> {
if literal.contains("**") {
return Err(format!(
"glob `{literal}` uses `**` which is not supported in v0.1"
));
}
if has_glob_chars(literal) {
let mut paths = expand_glob(crate_root, test_file, literal)?;
paths.sort_by(|a, b| {
a.as_os_str()
.as_encoded_bytes()
.cmp(b.as_os_str().as_encoded_bytes())
});
Ok(paths)
} else {
let absolute = resolve_literal_path(crate_root, test_file, literal);
if absolute.is_file() {
Ok(vec![absolute])
} else {
Ok(Vec::new())
}
}
}
fn resolve_literal_path(crate_root: &Path, test_file: &Path, literal: &str) -> PathBuf {
let literal_path = Path::new(literal);
if literal_path.is_absolute() {
return literal_path.to_path_buf();
}
let candidate = crate_root.join(literal_path);
if candidate.exists() {
return candidate;
}
let test_dir = test_file.parent().unwrap_or(crate_root);
test_dir.join(literal_path)
}
fn has_glob_chars(s: &str) -> bool {
s.bytes().any(|b| matches!(b, b'*' | b'?' | b'['))
}
fn expand_glob(crate_root: &Path, test_file: &Path, pattern: &str) -> Result<Vec<PathBuf>, String> {
let pattern_path = Path::new(pattern);
let is_absolute = pattern_path.is_absolute();
let segments: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
if segments.is_empty() {
return Ok(Vec::new());
}
let mut anchors: Vec<PathBuf> = Vec::new();
if is_absolute {
anchors.push(PathBuf::from("/"));
} else {
anchors.push(crate_root.to_path_buf());
if let Some(test_dir) = test_file.parent()
&& test_dir != crate_root
{
anchors.push(test_dir.to_path_buf());
}
}
let mut results_seen: BTreeMap<Vec<u8>, PathBuf> = BTreeMap::new();
for anchor in anchors {
let resolved = walk_glob_segments(&anchor, &segments, 0)?;
for path in resolved {
results_seen.insert(path.as_os_str().as_encoded_bytes().to_vec(), path);
}
}
Ok(results_seen.into_values().collect())
}
fn walk_glob_segments(
current: &Path,
segments: &[&str],
idx: usize,
) -> Result<Vec<PathBuf>, String> {
let Some(segment) = segments.get(idx).copied() else {
if current.is_file() {
return Ok(vec![current.to_path_buf()]);
}
return Ok(Vec::new());
};
let is_last = idx + 1 == segments.len();
if has_glob_chars(segment) {
let pattern_bytes = segment.as_bytes();
let entries = match std::fs::read_dir(current) {
Ok(it) => it,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => {
return Err(format!("glob walk error in `{}`: {e}", current.display()));
}
};
let mut sorted_entries: Vec<PathBuf> = Vec::new();
for entry in entries {
let entry = entry
.map_err(|e| format!("glob walk entry error in `{}`: {e}", current.display()))?;
let name_os = entry.file_name();
let name_bytes = name_os.as_encoded_bytes();
if name_bytes.first() == Some(&b'.') {
continue;
}
if !glob_segment_matches(pattern_bytes, name_bytes) {
continue;
}
sorted_entries.push(entry.path());
}
sorted_entries.sort_by(|a, b| {
a.as_os_str()
.as_encoded_bytes()
.cmp(b.as_os_str().as_encoded_bytes())
});
let mut results: Vec<PathBuf> = Vec::new();
for path in sorted_entries {
if is_last {
if path.is_file() {
results.push(path);
}
} else if path.is_dir() {
results.extend(walk_glob_segments(&path, segments, idx + 1)?);
}
}
Ok(results)
} else {
let next = current.join(segment);
if is_last {
if next.is_file() {
Ok(vec![next])
} else {
Ok(Vec::new())
}
} else if next.is_dir() {
walk_glob_segments(&next, segments, idx + 1)
} else {
Ok(Vec::new())
}
}
}
pub(super) fn glob_segment_matches(pattern: &[u8], name: &[u8]) -> bool {
fn rec(p: &[u8], n: &[u8]) -> bool {
let mut pi = 0;
let mut ni = 0;
let mut star_idx: Option<(usize, usize)> = None;
while ni < n.len() {
match p.get(pi).copied() {
Some(b'*') => {
star_idx = Some((pi, ni));
pi += 1;
}
Some(b'?') => {
pi += 1;
ni += 1;
}
Some(b'[') => {
let close = match p.iter().skip(pi + 1).position(|c| *c == b']') {
Some(off) => pi + 1 + off,
None => return false,
};
let class = &p[pi + 1..close];
if class.contains(&n[ni]) {
pi = close + 1;
ni += 1;
} else if let Some((sp, sn)) = star_idx {
pi = sp + 1;
ni = sn + 1;
star_idx = Some((sp, sn + 1));
} else {
return false;
}
}
Some(byte) => {
if byte == n[ni] {
pi += 1;
ni += 1;
} else if let Some((sp, sn)) = star_idx {
pi = sp + 1;
ni = sn + 1;
star_idx = Some((sp, sn + 1));
} else {
return false;
}
}
None => {
if let Some((sp, sn)) = star_idx {
pi = sp + 1;
ni = sn + 1;
star_idx = Some((sp, sn + 1));
} else {
return false;
}
}
}
}
while p.get(pi) == Some(&b'*') {
pi += 1;
}
pi == p.len()
}
rec(pattern, name)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compat::report;
use std::path::Path;
#[test]
fn glob_segment_matches_star() {
assert!(glob_segment_matches(b"*.rs", b"foo.rs"));
assert!(glob_segment_matches(b"*.rs", b".rs")); assert!(!glob_segment_matches(b"*.rs", b"foo.txt"));
assert!(glob_segment_matches(b"*", b"anything"));
}
#[test]
fn glob_segment_matches_question_mark() {
assert!(glob_segment_matches(b"foo?.rs", b"foo1.rs"));
assert!(glob_segment_matches(b"foo?.rs", b"fooX.rs"));
assert!(!glob_segment_matches(b"foo?.rs", b"foo.rs"));
assert!(!glob_segment_matches(b"foo?.rs", b"foo12.rs"));
}
#[test]
fn glob_segment_matches_character_class() {
assert!(glob_segment_matches(b"fix[abc].rs", b"fixa.rs"));
assert!(glob_segment_matches(b"fix[abc].rs", b"fixb.rs"));
assert!(glob_segment_matches(b"fix[abc].rs", b"fixc.rs"));
assert!(!glob_segment_matches(b"fix[abc].rs", b"fixd.rs"));
}
#[test]
fn glob_segment_matches_literal_dot_in_pattern() {
assert!(glob_segment_matches(b"foo.rs", b"foo.rs"));
assert!(!glob_segment_matches(b"foo.rs", b"fooArs"));
}
#[test]
fn glob_segment_matches_combined_star_class() {
assert!(glob_segment_matches(b"*[xy].rs", b"foox.rs"));
assert!(glob_segment_matches(b"*[xy].rs", b"fooy.rs"));
assert!(!glob_segment_matches(b"*[xy].rs", b"fooz.rs"));
}
#[test]
fn has_glob_chars_negative() {
assert!(!has_glob_chars("tests/ui/foo.rs"));
assert!(!has_glob_chars(""));
}
#[test]
fn has_glob_chars_positive() {
assert!(has_glob_chars("tests/ui/*.rs"));
assert!(has_glob_chars("tests/ui/f?o.rs"));
assert!(has_glob_chars("tests/ui/f[abc].rs"));
}
#[test]
fn path_segments_string_no_leading() {
let p: syn::Path = syn::parse_str("trybuild::TestCases::new").unwrap();
assert_eq!(path_segments_string(&p), "trybuild::TestCases::new");
}
#[test]
fn path_segments_string_with_leading() {
let p: syn::Path = syn::parse_str("::trybuild::TestCases::new").unwrap();
assert_eq!(path_segments_string(&p), "::trybuild::TestCases::new");
}
#[test]
fn is_test_attribute_bare() {
let attr: syn::Attribute = syn::parse_quote!(#[test]);
assert!(is_test_attribute(&[attr]));
}
#[test]
fn is_test_attribute_qualified() {
let attr: syn::Attribute = syn::parse_quote!(#[core::test]);
assert!(is_test_attribute(&[attr]));
}
#[test]
fn is_test_attribute_rejects_other() {
let attr: syn::Attribute = syn::parse_quote!(#[cfg(test)]);
assert!(!is_test_attribute(&[attr]));
}
fn envelope_with_mismatch_fixture(fixture: String) -> report::CompatEnvelope {
report::CompatEnvelope {
schema_version: 1,
mode: "compat".into(),
crate_name: "demo".into(),
commit: String::new(),
commands: report::Commands {
baseline: "cargo test".into(),
lihaaf: "cargo lihaaf --compat --compat-root .".into(),
},
results: report::Results {
baseline: report::BaselineCounts {
pass: 0,
fail: 1,
unknown_count: 0,
exit_code: 0,
dur_ms: 0,
},
lihaaf: report::LihaafCounts {
pass: 0,
fail: 0,
exit_code: 0,
dur_ms: 0,
toolchain: "rustc 1.95.0 (abc 2026-01-01)".into(),
},
mismatch_count: 1,
},
mismatch_examples: vec![report::MismatchExample {
fixture,
mismatch_type: "baseline_only_fail".into(),
notes: String::new(),
}],
errors: Vec::new(),
excluded_fixtures: Vec::new(),
generated_paths: Vec::new(),
overlay: report::OverlayMetadata {
generated: true,
dropped_comments: Vec::new(),
upstream_already_has_dylib: false,
},
toolchain: "rustc 1.95.0 (abc 2026-01-01)".into(),
}
}
#[test]
fn outside_root_absolute_fixture_does_not_serialize_as_absolute_mismatch_fixture() {
let crate_root = tempfile::tempdir().unwrap();
let outside_root = tempfile::tempdir().unwrap();
let tests_dir = crate_root.path().join("tests");
std::fs::create_dir(&tests_dir).unwrap();
let outside_fixture = outside_root.path().join("external.rs");
std::fs::write(&outside_fixture, "fn main() {}\n").unwrap();
std::fs::write(
tests_dir.join("trybuild.rs"),
format!(
r#"
#[test]
fn ui() {{
trybuild::TestCases::new().compile_fail("{}");
}}
"#,
outside_fixture.display()
),
)
.unwrap();
let output = discover(crate_root.path(), &[]).unwrap();
assert_eq!(output.fixtures.len(), 1);
let fixture = output.fixtures[0].relative_path.clone();
assert!(
!Path::new(&fixture).is_absolute(),
"discovery must not hand an absolute fixture path to the envelope; got `{fixture}`"
);
let mut envelope = envelope_with_mismatch_fixture(fixture);
let report_path = crate_root.path().join("compat-report.json");
report::write_envelope(&mut envelope, &report_path).unwrap();
let text = std::fs::read_to_string(report_path).unwrap();
assert!(
!text.contains(r#""fixture": "/"#),
"mismatch_examples[].fixture must not serialize as an absolute POSIX path; got:\n{text}"
);
assert!(
text.contains(r#""fixture": "outside-base/"#),
"out-of-root absolute fixtures must use the explicit non-absolute fallback; got:\n{text}"
);
}
}