#![no_std]
#![doc = include_str!("../README.md")]
extern crate alloc;
#[cfg(any(test, feature = "std"))]
extern crate std;
use alloc::borrow::Cow;
use alloc::collections::{BTreeMap, BTreeSet};
use alloc::format;
use alloc::string::String;
use alloc::string::ToString;
use alloc::vec::Vec;
#[cfg(feature = "std")]
use std::eprintln;
#[cfg(feature = "std")]
use std::path::{Path, PathBuf};
mod cfg;
#[cfg(feature = "std")]
mod config;
#[cfg(feature = "std")]
mod context_cache;
mod context_index;
#[cfg(feature = "std")]
mod context_manifest;
#[cfg(feature = "std")]
mod context_network;
mod context_scope;
#[cfg(feature = "std")]
mod context_upstream;
mod contexts;
#[cfg(feature = "std")]
mod dependencies;
#[cfg(feature = "std")]
mod discover;
#[cfg(feature = "std")]
mod exclude;
pub mod extract;
mod fence;
mod functions;
mod normalize;
mod organization;
#[cfg(all(test, feature = "std"))]
mod organization_matching;
#[cfg(feature = "std")]
mod organization_snapshot;
#[cfg(feature = "std")]
mod organization_source;
mod report;
mod reuse;
#[cfg(feature = "std")]
mod source_archive;
type IncludeRead<'a> = dyn Fn(&str, &str) -> Option<(String, String)> + 'a;
pub use context_scope::ContextScope;
pub use organization::{
OrganizationCoverage, OrganizationPackage, OrganizationReport, OrganizationRepository,
};
#[cfg(feature = "std")]
pub use organization_source::{OwnerError, repository_owner};
pub use report::{annotations, human, json};
pub use reuse::{
ContextAccess, DependencyCandidate, DependencyCoverage, DependencySearch, ExistingIssue,
ReuseAction, ReuseGroup, ReuseReport,
};
pub use syn_canon::ContextKind;
#[derive(Debug, Clone, Default)]
#[expect(clippy::struct_excessive_bools, reason = "independent scan switches")]
pub struct Echoes {
package: Option<String>,
all_targets: bool,
threshold: Option<usize>,
min_tokens: Option<usize>,
functions: bool,
fn_min_tokens: Option<usize>,
context_blocks: bool,
context_min_tokens: Option<usize>,
context_select: Option<ContextScope>,
context_search: Option<ContextScope>,
context_dependencies: DependencySearch,
scan_generated: bool,
generated_markers: Vec<String>,
#[cfg(feature = "std")]
exclude: Vec<exclude::Exclude>,
}
impl Echoes {
#[must_use]
pub fn package(mut self, name: impl Into<String>) -> Self {
self.package = Some(name.into());
self
}
#[must_use]
pub fn all_targets(mut self) -> Self {
self.all_targets = true;
self
}
#[must_use]
pub fn threshold(mut self, n: usize) -> Self {
self.threshold = Some(n);
self
}
#[must_use]
pub fn min_tokens(mut self, n: usize) -> Self {
self.min_tokens = Some(n);
self
}
#[must_use]
pub fn functions(mut self) -> Self {
self.functions = true;
self
}
#[must_use]
pub fn fn_min_tokens(mut self, n: usize) -> Self {
self.fn_min_tokens = Some(n);
self
}
#[must_use]
pub fn context_blocks(mut self) -> Self {
self.context_blocks = true;
self
}
#[must_use]
pub fn context_min_tokens(mut self, n: usize) -> Self {
self.context_min_tokens = Some(n);
self
}
#[must_use]
pub fn context_select(mut self, scope: ContextScope) -> Self {
self.context_select = Some(scope);
self
}
#[must_use]
pub fn context_search(mut self, scope: ContextScope) -> Self {
self.context_search = Some(scope);
self
}
#[cfg(feature = "std")]
#[must_use]
pub fn context_dependencies(mut self, options: DependencySearch) -> Self {
self.context_dependencies = options.fill_from(&self.context_dependencies);
self
}
#[cfg(feature = "std")]
#[must_use]
pub fn context_dependency_depth(mut self, depth: usize) -> Self {
self.context_dependencies.depth = Some(depth);
self
}
#[must_use]
pub fn scan_generated(mut self) -> Self {
self.scan_generated = true;
self
}
#[must_use]
pub fn generated_marker(mut self, marker: impl Into<String>) -> Self {
self.generated_markers.push(marker.into());
self
}
#[cfg(feature = "std")]
pub fn exclude(mut self, pattern: &str) -> Result<Self> {
self.exclude.push(exclude::Exclude::new(pattern)?);
Ok(self)
}
#[cfg(feature = "std")]
pub fn config(self, path: impl Into<PathBuf>) -> Result<Self> {
self.fill_from(&config::load(&path.into())?)
}
#[cfg(feature = "std")]
fn fill_from(self, file: &config::Config) -> Result<Self> {
let mut exclude = self.exclude;
for pattern in file.exclude.iter().flatten() {
exclude.push(exclude::Exclude::new(pattern)?);
}
Ok(Self {
threshold: self.threshold.or(file.threshold),
min_tokens: self.min_tokens.or(file.min_tokens),
functions: self.functions || file.functions == Some(true),
fn_min_tokens: self.fn_min_tokens.or(file.fn_min_tokens),
context_blocks: self.context_blocks || file.context_blocks == Some(true),
context_min_tokens: self.context_min_tokens.or(file.context_min_tokens),
context_select: self.context_select.or_else(|| file.context_select.clone()),
context_search: self.context_search.or_else(|| file.context_search.clone()),
context_dependencies: self
.context_dependencies
.fill_from(&file.context_dependencies),
scan_generated: self.scan_generated || file.scan_generated == Some(true),
generated_markers: self
.generated_markers
.into_iter()
.chain(file.generated_markers.iter().flatten().cloned())
.collect(),
exclude,
..self
})
}
#[cfg(feature = "std")]
pub fn run(self, root: impl AsRef<Path>) -> Result<Report> {
let root = root.as_ref();
on_large_stack(GROUP_STACK, || self.run_here(root))
}
#[cfg(feature = "std")]
fn run_here(self, root: &Path) -> Result<Report> {
let workspace = discover::workspace(root, None, self.all_targets)?;
let cfg = config::load(&workspace.root.join(".echoes.toml"))?;
let scan = self.fill_from(&cfg)?;
if let Some(name) = scan.package.as_deref()
&& !workspace
.targets
.iter()
.any(|target| target.package == name)
{
return Err(Error::UnknownPackage(name.to_string()));
}
let root_str = workspace.root.to_string_lossy().into_owned();
let read = |file: &str, p: &str| -> Option<(String, String)> {
let dir = Path::new(file).parent()?;
let inc = dir.join(p);
match std::fs::read_to_string(&inc) {
Ok(text) => Some((inc.to_string_lossy().into_owned(), text)),
Err(err) => {
eprintln!("echoes: cannot read doc include {}: {err}", inc.display());
None
}
}
};
let mut targets = Vec::new();
let mut context_targets = Vec::new();
let mut legacy_roots = BTreeSet::new();
let mut discovery_failures = Vec::new();
let expand_contexts = scan.context_select.is_some() || scan.context_search.is_some();
for target in workspace.targets.iter().filter(|target| {
expand_contexts
|| scan
.package
.as_deref()
.is_none_or(|name| name == target.package)
}) {
if scan
.package
.as_deref()
.is_none_or(|name| name == target.package)
{
legacy_roots.insert(target.src.to_string_lossy().into_owned());
}
let discovered = discover::module_tree(target, None);
if scan.context_target_requested(target, &root_str) {
discovery_failures.extend(
discovered
.failures
.into_iter()
.map(|failure| failure.message),
);
}
let (linked, every) =
split_unlinked(discovered.files, &discovered.unlinked, expand_contexts);
if let Some(files) = every {
context_targets.push(TargetScan {
name: target.name.clone(),
files,
library: target.library,
});
}
targets.push(TargetScan {
name: target.name.clone(),
files: linked,
library: target.library,
});
}
let mut report = scan.scan(
&root_str,
&targets,
Some(&Discovered {
legacy_roots: &legacy_roots,
context_targets: if expand_contexts {
&context_targets
} else {
&targets
},
}),
ScanMode::Workspace,
&read,
group_here,
);
if !discovery_failures.is_empty()
&& let Some(reuse) = report.reuse.as_mut()
{
reuse.coverage.complete = false;
reuse.coverage.failures.extend(discovery_failures);
}
Ok(report)
}
#[cfg(feature = "std")]
pub fn run_selected(
self,
organizations: &[impl AsRef<str>],
repositories: &[impl AsRef<str>],
) -> Result<Report> {
if organizations.is_empty() && repositories.is_empty() {
return Err(Error::EmptySelection);
}
if self.context_dependencies.depth.unwrap_or(0) > 0 {
return Err(Error::OrganizationDependencies);
}
let selection = organization_source::Selection {
owners: organizations
.iter()
.map(|name| String::from(name.as_ref()))
.collect(),
repositories: repositories
.iter()
.map(|name| String::from(name.as_ref()))
.collect(),
};
on_large_stack(GROUP_STACK, || {
let cache = self
.context_dependencies
.cache_dir
.as_deref()
.map(Path::new);
let corpus = organization_source::load(&selection, cache, self.all_targets);
self.run_organization_corpus(corpus)
})
}
#[must_use]
pub fn run_targets(self, root: &str, targets: &[TargetScan], read: &IncludeRead<'_>) -> Report {
self.scan(root, targets, None, ScanMode::SuppliedTargets, read, group)
}
fn scan(
&self,
root: &str,
targets: &[TargetScan],
discovered: Option<&Discovered<'_>>,
mode: ScanMode,
read: &IncludeRead<'_>,
group_doctests: fn(&[DocTest], usize, usize) -> Report,
) -> Report {
let all_targets = discovered.map_or(targets, |discovered| discovered.context_targets);
let targets: Vec<&TargetScan> = targets
.iter()
.filter(|target| {
discovered.map_or_else(
|| {
self.package
.as_deref()
.is_none_or(|name| name == target.name)
},
|discovered| {
target
.files
.first()
.is_some_and(|file| discovered.legacy_roots.contains(&file.path))
},
)
})
.collect();
let threshold = self.threshold.unwrap_or(DEFAULT_THRESHOLD);
#[cfg(feature = "std")]
let kept = |file: &SourceFile| {
!exclude::excluded(&self.exclude, mode.organization(), root, &file.path)
};
#[cfg(not(feature = "std"))]
let kept = |_: &SourceFile| true;
let scanned = |file: &SourceFile| {
(self.scan_generated || !is_generated(&file.text, &self.generated_markers))
&& kept(file)
};
let mut report = group_doctests(
&extract_all(targets.iter().copied(), root, read, &scanned),
threshold,
self.min_tokens.unwrap_or(DEFAULT_MIN_TOKENS),
);
if self.functions {
let mut roots = BTreeSet::new();
let crates = targets
.iter()
.filter(|target| roots.insert(target.files.first().map(|f| f.path.as_str())));
let sites = crates.flat_map(|target| {
let crate_root = target.files.first().map_or("", |f| {
f.path
.strip_prefix(root)
.map_or(f.path.as_str(), |p| p.trim_start_matches('/'))
});
let context = syn_canon::SourceContext::new(
target
.files
.iter()
.map(|file| (file.segments.as_slice(), &file.parsed)),
);
target
.files
.iter()
.filter(|file| scanned(file))
.flat_map(move |file| {
functions::functions(
&module_prefix(target, file),
file,
root,
crate_root,
target.library,
&context,
)
})
});
let (total, unique, groups) = group_functions(
sites,
threshold,
self.fn_min_tokens.unwrap_or(DEFAULT_FN_MIN_TOKENS),
mode.organization(),
);
report.functions = total;
report.unique_functions = unique;
report.groups.extend(groups);
}
self.scan_contexts(
root,
all_targets,
&targets,
mode.discover_dependencies(),
&scanned,
&mut report,
);
report
}
#[cfg(feature = "std")]
fn run_organization_corpus(self, corpus: organization_source::Corpus) -> Result<Report> {
if let Some(name) = self.package.as_deref()
&& corpus.report.coverage.complete
&& !corpus
.report
.packages
.iter()
.any(|package| package.name == name)
{
return Err(Error::UnknownOrganizationPackage(name.to_string()));
}
let expand_contexts = self.context_select.is_some() || self.context_search.is_some();
let repo_lookup: BTreeMap<&str, usize> = corpus
.report
.repositories
.iter()
.enumerate()
.map(|(index, repo)| (repo.name.as_str(), index))
.collect();
let OrganizationTargets {
targets,
context_targets,
mut ownership,
mut failures,
} = organization_targets(&self, &corpus, expand_contexts);
let include_failures = core::cell::RefCell::new(Vec::new());
let include_edges = core::cell::RefCell::new(Vec::new());
let legacy_roots: BTreeSet<String> = targets
.iter()
.filter_map(|target| target.files.first().map(|file| file.path.clone()))
.collect();
let mut report = {
let read = organization_read(
&repo_lookup,
&corpus.roots,
&include_failures,
&include_edges,
);
self.scan(
"",
&targets,
Some(&Discovered {
legacy_roots: &legacy_roots,
context_targets: if expand_contexts {
&context_targets
} else {
&targets
},
}),
ScanMode::Organization,
&read,
group,
)
};
failures.extend(include_failures.into_inner());
let mut contributions: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
for (containing, included) in include_edges.into_inner() {
contributions
.entry(included)
.or_default()
.extend(&ownership[&containing]);
}
for (included, mut owners) in contributions {
ownership.entry(included).or_default().append(&mut owners);
}
let file_packages = organization_file_packages(&report, &mut ownership);
let mut coverage = corpus.report.coverage;
if !failures.is_empty() {
coverage.complete = false;
coverage.failures.extend(failures);
}
report.organization = Some(OrganizationReport {
name: corpus.report.name,
repositories: corpus.report.repositories,
packages: corpus.report.packages,
file_packages,
coverage,
});
Ok(report)
}
#[cfg(feature = "std")]
fn context_target_requested(&self, target: &discover::Target, root: &str) -> bool {
let explicit = self.context_select.is_some() || self.context_search.is_some();
let matches = |scope: Option<&ContextScope>| match scope {
None => {
explicit
|| self
.package
.as_deref()
.is_none_or(|name| name == target.package)
}
Some(ContextScope::Workspace | ContextScope::File(_)) => true,
Some(ContextScope::Crate(name)) => {
name == &target.name
|| Path::new(name) == target.src.strip_prefix(root).unwrap_or(&target.src)
}
Some(ContextScope::Module(name) | ContextScope::ModuleExact(name)) => {
name.split("::").next() == Some(target.name.as_str())
}
};
matches(self.context_select.as_ref()) || matches(self.context_search.as_ref())
}
fn scan_contexts(
&self,
root: &str,
all_targets: &[TargetScan],
targets: &[&TargetScan],
discover_dependencies: bool,
scanned: &dyn Fn(&SourceFile) -> bool,
report: &mut Report,
) {
let threshold = self.threshold.unwrap_or(DEFAULT_THRESHOLD);
if self.context_blocks
|| self.context_select.is_some()
|| self.context_search.is_some()
|| self.context_dependencies.depth.unwrap_or(0) > 0
{
let entries: Vec<_> = all_targets
.iter()
.flat_map(|target| context_index::extract(target, root, scanned))
.collect();
let explicit_scope = self.context_select.is_some() || self.context_search.is_some();
let legacy_crates: BTreeSet<Cow<'_, str>> = targets
.iter()
.filter_map(|target| target.files.first())
.map(|file| {
file.path
.strip_prefix(root)
.map_or(file.path.as_str(), |path| {
path.trim_start_matches(['/', '\\'])
})
})
.map(|path| {
if path.contains('\\') {
Cow::Owned(path.replace('\\', "/"))
} else {
Cow::Borrowed(path)
}
})
.collect();
let selected: Vec<_> = entries
.iter()
.filter(|entry| {
self.context_select.as_ref().map_or_else(
|| explicit_scope || legacy_crates.contains(entry.crate_root.as_str()),
|scope| scope.matches(entry),
)
})
.collect();
let corpus: Vec<_> = entries
.iter()
.filter(|entry| {
self.context_search.as_ref().map_or_else(
|| explicit_scope || legacy_crates.contains(entry.crate_root.as_str()),
|scope| scope.matches(entry),
)
})
.collect();
let floor = self
.context_min_tokens
.unwrap_or(DEFAULT_CONTEXT_MIN_TOKENS);
let (total, unique, groups) = contexts::group(&selected, &corpus, floor, threshold);
report.context_blocks = total;
report.unique_context_blocks = unique;
report.context_groups = groups;
#[cfg(feature = "std")]
if self.context_dependencies.depth.unwrap_or(0) > 0 {
report.reuse = Some(if discover_dependencies {
dependencies::analyze(
Path::new(root),
all_targets,
&selected,
&self.context_dependencies,
floor,
self.scan_generated,
&self.generated_markers,
)
} else {
ReuseReport {
coverage: DependencyCoverage {
complete: false,
failures: alloc::vec![
"Dependency discovery requires Echoes::run".into(),
],
..DependencyCoverage::default()
},
..ReuseReport::default()
}
});
}
}
#[cfg(not(feature = "std"))]
let _ = discover_dependencies;
}
}
#[derive(Clone, Copy)]
enum ScanMode {
#[cfg(feature = "std")]
Workspace,
SuppliedTargets,
#[cfg(feature = "std")]
Organization,
}
impl ScanMode {
fn organization(self) -> bool {
match self {
#[cfg(feature = "std")]
Self::Organization => true,
_ => false,
}
}
fn discover_dependencies(self) -> bool {
match self {
#[cfg(feature = "std")]
Self::Workspace => true,
_ => false,
}
}
}
struct Discovered<'a> {
legacy_roots: &'a BTreeSet<String>,
context_targets: &'a [TargetScan],
}
#[derive(Debug, Clone)]
pub struct TargetScan {
pub name: String,
pub files: Vec<SourceFile>,
pub library: bool,
}
#[derive(Clone)]
pub struct SourceFile {
pub path: String,
pub segments: Vec<String>,
pub cfg: Vec<String>,
pub parsed: syn::File,
pub text: String,
pub rustdoc: bool,
}
impl core::fmt::Debug for SourceFile {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("SourceFile")
.field("path", &self.path)
.field("segments", &self.segments)
.finish_non_exhaustive()
}
}
const DEFAULT_THRESHOLD: usize = 2;
const DEFAULT_MIN_TOKENS: usize = 0;
const DEFAULT_FN_MIN_TOKENS: usize = 30;
const DEFAULT_CONTEXT_MIN_TOKENS: usize = 30;
const DOCTEST_DOMAIN: u8 = 1;
const TEXT_DOMAIN: u8 = 2;
const FUNCTION_DOMAIN: u8 = 3;
fn module_prefix(target: &TargetScan, file: &SourceFile) -> String {
match file.segments.first() {
Some(_) => format!("{}::{}", target.name, file.segments.join("::")),
None => target.name.clone(),
}
}
fn is_generated(text: &str, markers: &[String]) -> bool {
text.lines().take(5).any(|line| {
let line = line.trim();
line.contains("@generated")
|| (line.starts_with("// Code generated ") && line.ends_with(" DO NOT EDIT."))
|| markers.iter().any(|marker| line.contains(marker.as_str()))
})
}
fn extract_all<'t>(
targets: impl IntoIterator<Item = &'t TargetScan>,
root: &str,
read: &IncludeRead<'_>,
scanned: &dyn Fn(&SourceFile) -> bool,
) -> Vec<DocTest> {
let mut blocks = Vec::new();
for target in targets {
for file in target.files.iter().filter(|f| f.rustdoc && scanned(f)) {
blocks.extend(extract::extract(
&module_prefix(target, file),
&file.path,
&file.parsed,
root,
read,
));
}
}
blocks
}
#[cfg(feature = "std")]
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("workspace: {0}")]
Workspace(#[from] cargo_metadata::Error),
#[error("workspace: no workspace member named `{0}`")]
UnknownPackage(String),
#[error("organization: no package named `{0}`")]
UnknownOrganizationPackage(String),
#[error("I/O: {0}")]
Io(#[from] std::io::Error),
#[error("config: {0}")]
Config(#[from] toml::de::Error),
#[error("organization: dependency traversal is not supported")]
OrganizationDependencies,
#[error("organization: {0}")]
Owner(#[from] OwnerError),
#[error("organization: no organization or repository was named")]
EmptySelection,
#[error("exclude: `{pattern}` {reason}")]
InvalidExclude {
pattern: String,
reason: String,
},
}
#[cfg(feature = "std")]
pub type Result<T, E = Error> = std::result::Result<T, E>;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct DocTest {
pub file: String,
pub line: u32,
pub end: Option<u32>,
pub item: String,
pub info: Vec<String>,
pub code: String,
#[serde(skip)]
pub allow: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub self_type: Option<String>,
#[serde(skip_serializing_if = "core::ops::Not::not")]
pub public: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
Doctest,
Function,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Remedy {
GenericOrMacro,
HelperOrMacro,
MergeCfg,
Delete,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Group {
pub kind: Kind,
pub id: String,
pub hash: String,
pub unparsed: bool,
pub tokens: usize,
pub sites: Vec<DocTest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub remedy: Option<Remedy>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ContextSite {
pub file: String,
pub line: u32,
pub column: usize,
pub end: u32,
pub end_column: usize,
pub item: String,
#[serde(
serialize_with = "contexts::serialize_kind",
deserialize_with = "contexts::deserialize_kind"
)]
pub kind: ContextKind,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct ContextGroup {
pub id: String,
pub hash: String,
pub tokens: usize,
pub sites: Vec<ContextSite>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Report {
pub total: usize,
pub unique: usize,
pub functions: usize,
pub unique_functions: usize,
pub groups: Vec<Group>,
pub context_blocks: usize,
pub unique_context_blocks: usize,
pub context_groups: Vec<ContextGroup>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reuse: Option<ReuseReport>,
#[serde(skip_serializing_if = "Option::is_none")]
pub organization: Option<OrganizationReport>,
}
#[cfg(feature = "std")]
#[must_use = "return the status from `main`"]
pub fn exit_code(report: &Report, no_fail: bool) -> std::process::ExitCode {
use std::process::ExitCode;
if report
.organization
.as_ref()
.is_some_and(|organization| !organization.coverage.complete)
{
return ExitCode::from(2);
}
if report
.reuse
.as_ref()
.is_some_and(|reuse| !reuse.coverage.complete)
{
return ExitCode::from(2);
}
let gated_reuse = report
.reuse
.as_ref()
.is_some_and(|reuse| reuse.fail_on_findings && !reuse.groups.is_empty());
if no_fail || (report.groups.is_empty() && report.context_groups.is_empty() && !gated_reuse) {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
#[must_use]
pub fn group(blocks: &[DocTest], threshold: usize, min_tokens: usize) -> Report {
#[cfg(feature = "std")]
{
on_large_stack(GROUP_STACK, || group_here(blocks, threshold, min_tokens))
}
#[cfg(not(feature = "std"))]
group_here(blocks, threshold, min_tokens)
}
#[cfg(feature = "std")]
fn split_unlinked(
files: Vec<SourceFile>,
unlinked: &BTreeSet<String>,
keep: bool,
) -> (Vec<SourceFile>, Option<Vec<SourceFile>>) {
if keep {
let linked = files
.iter()
.filter(|file| !unlinked.contains(&file.path))
.cloned()
.collect();
(linked, Some(files))
} else {
let linked = files
.into_iter()
.filter(|file| !unlinked.contains(&file.path))
.collect();
(linked, None)
}
}
#[cfg(feature = "std")]
fn on_large_stack<T: Send>(stack: usize, f: impl FnOnce() -> T + Send) -> T {
let mut job = Some(f);
let result = std::thread::scope(|scope| {
match std::thread::Builder::new()
.stack_size(stack)
.spawn_scoped(scope, || job.take().unwrap()())
{
Ok(worker) => Some(worker.join().expect("the scan does not panic")),
Err(_) => None,
}
});
result.unwrap_or_else(|| job.take().unwrap()())
}
#[cfg(any(test, feature = "std"))]
const GROUP_STACK: usize = 1 << 30;
fn group_here(blocks: &[DocTest], threshold: usize, min_tokens: usize) -> Report {
let mut by_site: BTreeMap<(&str, u32), &DocTest> = BTreeMap::new();
for block in blocks {
by_site
.entry((block.file.as_str(), block.line))
.or_insert(block);
}
let total = by_site.len();
let entries = by_site.into_values().filter(|b| !b.allow).map(|block| {
let canonical = if block.info.iter().any(|tag| tag == "compile_fail") {
normalize::canonicalize_failing(&block.code)
} else {
normalize::canonicalize(&block.code)
};
let mut hasher = blake3::Hasher::new();
hasher.update(&[if canonical.unparsed {
TEXT_DOMAIN
} else {
DOCTEST_DOMAIN
}]);
let length = u64::try_from(canonical.key.len()).expect("the key length fits u64");
hasher.update(&length.to_be_bytes());
hasher.update(canonical.key.as_bytes());
for tag in checked_attributes(&block.info) {
hasher.update(b"\n");
hasher.update(tag.as_bytes());
}
let hash = hasher.finalize().to_hex().to_string();
(
hash,
canonical.unparsed,
canonical.tokens,
(block.clone(), ()),
)
});
let (unique, groups) = bucket(Kind::Doctest, entries, threshold, min_tokens, |_| None);
Report {
total,
unique,
functions: 0,
unique_functions: 0,
groups,
context_blocks: 0,
unique_context_blocks: 0,
context_groups: Vec::new(),
reuse: None,
organization: None,
}
}
fn checked_attributes(info: &[String]) -> Vec<&str> {
let mut tags: Vec<&str> = info
.iter()
.map(String::as_str)
.filter(|tag| {
matches!(*tag, "compile_fail" | "should_panic" | "test_harness")
|| tag.starts_with("edition")
|| (tag.len() == 5
&& tag.starts_with('E')
&& tag[1..].bytes().all(|b| b.is_ascii_digit()))
})
.collect();
tags.sort_unstable();
tags
}
fn group_functions(
sites: impl Iterator<Item = functions::FnSite>,
threshold: usize,
min_tokens: usize,
organization: bool,
) -> (usize, usize, Vec<Group>) {
let mut total = 0;
let mut by_hash: BTreeMap<String, Vec<functions::FnSite>> = BTreeMap::new();
let mut sites: Vec<functions::FnSite> = sites.collect();
if organization {
sites.sort_by(|a, b| {
(a.site.file.as_str(), a.site.line).cmp(&(b.site.file.as_str(), b.site.line))
});
sites.dedup_by(|a, b| a.site.file == b.site.file && a.site.line == b.site.line);
}
for f in sites
.into_iter()
.inspect(|_| total += 1)
.filter(|f| !f.site.allow)
{
let mut hasher = blake3::Hasher::new();
hasher.update(&[FUNCTION_DOMAIN]);
if !organization {
let length = u64::try_from(f.context.scope.len()).expect("the scope length fits u64");
hasher.update(&length.to_be_bytes());
hasher.update(f.context.scope.as_bytes());
}
hasher.update(f.form.key().as_bytes());
by_hash
.entry(hasher.finalize().to_hex().to_string())
.or_default()
.push(f);
}
let entries = by_hash.into_iter().flat_map(|(hash, members)| {
let split = owner_roles_conflict(&members);
members.into_iter().map(move |f| {
let hash = if split {
let mut hasher = blake3::Hasher::new();
hasher.update(hash.as_bytes());
hasher.update(f.form.owner_role_key().unwrap_or_default().as_bytes());
hasher.finalize().to_hex().to_string()
} else {
hash.clone()
};
(hash, false, f.form.body_units(), (f.site, f.context))
})
});
let remedy = if organization {
functions::remedy_organization
} else {
functions::remedy
};
let (unique, groups) = bucket(Kind::Function, entries, threshold, min_tokens, |sites| {
Some(remedy(sites))
});
(total, unique, groups)
}
fn owner_roles_conflict(members: &[functions::FnSite]) -> bool {
let mut roles: BTreeMap<(&str, &str), Option<&str>> = BTreeMap::new();
members.iter().any(|f| {
f.site.self_type.as_deref().is_some_and(|owner| {
let role = f.form.owner_role_key();
*roles
.entry((f.context.scope.as_str(), owner))
.or_insert(role)
!= role
})
})
}
#[cfg(feature = "std")]
fn snapshot_relative(path: &Path, root: &Path) -> Option<PathBuf> {
match path.strip_prefix(root) {
Ok(relative) => Some(relative.to_path_buf()),
Err(_) => Some(
std::fs::canonicalize(path)
.ok()?
.strip_prefix(root)
.ok()?
.to_path_buf(),
),
}
}
#[cfg(feature = "std")]
struct OrganizationTargets {
targets: Vec<TargetScan>,
context_targets: Vec<TargetScan>,
ownership: BTreeMap<String, BTreeSet<usize>>,
failures: Vec<String>,
}
#[cfg(feature = "std")]
fn organization_targets(
echoes: &Echoes,
corpus: &organization_source::Corpus,
expand_contexts: bool,
) -> OrganizationTargets {
let mut failures = Vec::new();
let mut targets = Vec::new();
let mut context_targets = Vec::new();
let mut ownership: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
for source in &corpus.packages {
let record = &corpus.report.packages[source.package];
let repo_name = corpus.report.repositories[record.repository].name.as_str();
let root = corpus.roots[record.repository]
.as_deref()
.expect("packages come from repositories with snapshots");
let selected = echoes
.package
.as_deref()
.is_none_or(|want| want == record.name);
if !selected && !expand_contexts {
continue;
}
let root_str = root.to_string_lossy().into_owned();
for target in &source.targets {
let tree = discover::module_tree(target, Some(root));
failures.extend(
tree.failures
.into_iter()
.map(|failure| failure.message.replace(&root_str, repo_name)),
);
let (linked, every) = split_unlinked(tree.files, &tree.unlinked, expand_contexts);
let mut qualify = |files: Vec<SourceFile>| -> Vec<SourceFile> {
files
.into_iter()
.map(|mut file| {
let relative = snapshot_relative(Path::new(&file.path), root);
file.path = relative.map_or(file.path, |relative| {
format!("{repo_name}/{}", organization::path_label(&relative))
});
ownership
.entry(file.path.clone())
.or_default()
.insert(source.package);
file
})
.collect()
};
if selected {
targets.push(TargetScan {
name: target.name.clone(),
files: qualify(linked),
library: target.library,
});
}
if let Some(files) = every {
context_targets.push(TargetScan {
name: target.name.clone(),
files: qualify(files),
library: target.library,
});
}
}
}
OrganizationTargets {
targets,
context_targets,
ownership,
failures,
}
}
#[cfg(feature = "std")]
fn organization_read<'a>(
repo_lookup: &'a BTreeMap<&str, usize>,
repo_roots: &'a [Option<PathBuf>],
include_failures: &'a core::cell::RefCell<Vec<String>>,
include_edges: &'a core::cell::RefCell<Vec<(String, String)>>,
) -> impl Fn(&str, &str) -> Option<(String, String)> + 'a {
move |file: &str, written: &str| -> Option<(String, String)> {
let snapshot = file.match_indices('/').nth(1).and_then(|(separator, _)| {
let (repo_name, relative) = file.split_at(separator);
let root = repo_roots[*repo_lookup.get(repo_name)?].as_deref()?;
Some((repo_name, &relative[1..], root))
});
let Some((repo_name, relative, root)) = snapshot else {
include_failures.borrow_mut().push(format!(
"{file}: doc include {written} has no repository snapshot"
));
return None;
};
let parent = relative.rsplit_once('/').map_or("", |(parent, _)| parent);
let mut candidate =
PathBuf::with_capacity(root.as_os_str().len() + parent.len() + written.len() + 2);
candidate.push(root);
candidate.push(parent);
candidate.push(written);
let canonical = match std::fs::canonicalize(&candidate) {
Ok(canonical) => canonical,
Err(err) => {
include_failures
.borrow_mut()
.push(format!("{file}: cannot read doc include {written} ({err})"));
return None;
}
};
let Ok(rel) = canonical.strip_prefix(root) else {
include_failures.borrow_mut().push(format!(
"{file}: doc include {written} escapes the repository snapshot"
));
return None;
};
let text = match std::fs::read_to_string(&canonical) {
Ok(text) => text,
Err(err) => {
include_failures
.borrow_mut()
.push(format!("{file}: cannot read doc include {written} ({err})"));
return None;
}
};
let qualified = format!("{repo_name}/{}", organization::path_label(rel));
include_edges
.borrow_mut()
.push((file.to_string(), qualified.clone()));
Some((qualified, text))
}
}
#[cfg(feature = "std")]
fn organization_file_packages(
report: &Report,
ownership: &mut BTreeMap<String, BTreeSet<usize>>,
) -> BTreeMap<String, Vec<usize>> {
let mut finding_paths = BTreeSet::new();
for group in &report.groups {
for site in &group.sites {
finding_paths.insert(site.file.clone());
}
}
for group in &report.context_groups {
for site in &group.sites {
finding_paths.insert(site.file.clone());
}
}
finding_paths
.into_iter()
.filter_map(|path| {
ownership
.remove(&path)
.map(|owners| (path, owners.into_iter().collect()))
})
.collect()
}
type Bucket<C> = (bool, usize, Vec<(DocTest, C)>);
fn bucket<C>(
kind: Kind,
entries: impl Iterator<Item = (String, bool, usize, (DocTest, C))>,
threshold: usize,
min_tokens: usize,
remedy: impl Fn(&[(DocTest, C)]) -> Option<Remedy>,
) -> (usize, Vec<Group>) {
let mut unique: BTreeSet<String> = BTreeSet::new();
let mut by_hash: BTreeMap<String, Bucket<C>> = BTreeMap::new();
for (hash, unparsed, tokens, site) in entries {
unique.insert(hash.clone());
if tokens >= min_tokens {
by_hash
.entry(hash)
.or_insert_with(|| (unparsed, tokens, Vec::new()))
.2
.push(site);
}
}
let groups = by_hash
.into_iter()
.filter(|(_, (_, _, sites))| sites.len() >= threshold)
.map(|(hash, (unparsed, tokens, mut sites))| {
sites.sort_by(|(a, _), (b, _)| {
(a.file.as_str(), a.line).cmp(&(b.file.as_str(), b.line))
});
sites.sort_by_cached_key(|(site, _)| !names_its_item(site));
let remedy = remedy(&sites);
Group {
kind,
id: hash[..8].to_string(),
hash,
unparsed,
tokens,
sites: sites.into_iter().map(|(site, _)| site).collect(),
remedy,
}
})
.collect();
(unique.len(), groups)
}
fn names_its_item(site: &DocTest) -> bool {
let word = |c: char| c.is_alphanumeric() || c == '_';
site.item
.rsplit(|c: char| !word(c))
.find(|name| !name.is_empty())
.is_some_and(|name| site.code.split(|c: char| !word(c)).any(|w| w == name))
}
#[cfg(test)]
mod tests {
use super::*;
use alloc::vec;
#[test]
#[cfg(feature = "std")]
fn context_blocks_are_off_by_default() {
let report = Echoes::default().run("tests/fixtures/contextws").unwrap();
assert_eq!(report.context_blocks, 0);
assert_eq!(report.unique_context_blocks, 0);
assert_eq!(report.context_groups.len(), 0);
assert_eq!(
crate::exit_code(&report, false),
std::process::ExitCode::SUCCESS
);
}
fn dt(file: &str, line: u32, item: &str, code: &str, allow: bool) -> DocTest {
DocTest {
file: file.to_string(),
line,
end: None,
item: item.to_string(),
info: Vec::new(),
code: code.to_string(),
allow,
self_type: None,
public: false,
}
}
#[test]
fn attributes_that_change_what_rustdoc_checks_split_a_group() {
let pair = |a: &[&str], b: &[&str]| {
let mut one = dt("a.rs", 1, "m::f", "let x = 1;", false);
let mut two = dt("b.rs", 2, "m::g", "let x = 1;", false);
one.info = a.iter().map(ToString::to_string).collect();
two.info = b.iter().map(ToString::to_string).collect();
group(&[one, two], 2, 0).groups.len()
};
for split in [
(&["compile_fail"][..], &[][..]),
(&["should_panic"][..], &[][..]),
(&["test_harness"][..], &[][..]),
(&["edition2015"][..], &["edition2021"][..]),
(
&["compile_fail", "E0277"][..],
&["compile_fail", "E0308"][..],
),
] {
assert_eq!(pair(split.0, split.1), 0, "{split:?}");
}
assert_eq!(pair(&["no_run"], &[]), 1);
for unchecked in ["Eabcd", "12345", "E02777"] {
assert_eq!(pair(&[unchecked], &[]), 1, "{unchecked}");
}
assert_eq!(
pair(&["should_panic", "no_run"], &["no_run", "should_panic"]),
1
);
}
#[test]
fn a_compile_fail_doctest_keeps_the_mut_it_may_be_about() {
let pair = |info: &[&str]| {
let mut one = dt("a.rs", 1, "m::f", "let v = Vec::new();\nv.push(1);", false);
let mut two = dt(
"b.rs",
2,
"m::g",
"let mut v = Vec::new();\nv.push(1);",
false,
);
one.info = info.iter().map(ToString::to_string).collect();
two.info.clone_from(&one.info);
group(&[one, two], 2, 0).groups.len()
};
assert_eq!(pair(&[]), 1);
assert_eq!(pair(&["no_run"]), 1);
assert_eq!(pair(&["compile_fail"]), 0);
assert_eq!(pair(&["compile_fail", "E0596"]), 0);
}
#[test]
#[cfg(feature = "std")]
fn run_outside_a_workspace_is_a_workspace_error() {
let err = Echoes::default()
.run("/nonexistent-echoes-root")
.unwrap_err();
assert!(matches!(err, Error::Workspace(_)));
}
#[test]
#[cfg(feature = "std")]
fn config_loads_values_immediately() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("config.toml");
std::fs::write(&file, "threshold = 10\n").unwrap();
let targets = vec![
scan_target("alpha", "alpha/src/lib.rs", &[], "one"),
scan_target("beta", "beta/src/lib.rs", &[], "two"),
];
let report =
Echoes::default()
.config(&file)
.unwrap()
.run_targets("", &targets, &|_f, _i| None);
assert_eq!(report.groups, Vec::new());
let report = Echoes::default()
.config(&file)
.unwrap()
.threshold(2)
.run_targets("", &targets, &|_f, _i| None);
assert_eq!(report.groups.len(), 1);
std::fs::write(&file, "min-tokens = 1000\n").unwrap();
let report =
Echoes::default()
.config(&file)
.unwrap()
.run_targets("", &targets, &|_f, _i| None);
assert_eq!(report.groups, Vec::new());
}
#[test]
#[cfg(feature = "std")]
fn config_turns_the_function_check_on_or_lowers_its_floor() {
let dir = tempfile::tempdir().unwrap();
let src = "fn one() -> u8 { 1 + 2 }\nfn two() -> u8 { 1 + 2 }\n";
let scan_with = |toml: &str| {
let file = dir.path().join("config.toml");
std::fs::write(&file, toml).unwrap();
let target = target_from("c", "src/lib.rs", &[], src);
let scan = Echoes::default().config(&file).unwrap();
scan.run_targets("", &[target], &|_f, _i| None).groups.len()
};
assert_eq!(scan_with("fn-min-tokens = 0\n"), 0);
assert_eq!(scan_with("fn-min-tokens = 0\nfunctions = false\n"), 0);
assert_eq!(scan_with("fn-min-tokens = 0\nfunctions = true\n"), 1);
}
#[test]
fn the_function_check_runs_only_when_enabled() {
let src = format!("fn one{BODY}\nfn two{BODY}\n");
let scan = |scan: Echoes| {
let target = target_from("c", "src/lib.rs", &[], &src);
let report = scan.run_targets("", &[target], &|_f, _i| None);
(report.functions, report.groups.len())
};
assert_eq!(scan(Echoes::default()), (0, 0));
assert_eq!(scan(Echoes::default().functions()), (2, 1));
}
#[test]
#[cfg(feature = "std")]
fn config_marks_more_files_generated_or_scans_them_anyway() {
let dir = tempfile::tempdir().unwrap();
let groups = |header: &str, toml: &str, builder: Echoes| {
let file = dir.path().join("config.toml");
std::fs::write(&file, toml).unwrap();
let src = format!("{header}fn one() -> u8 {{ 1 + 2 }}\nfn two() -> u8 {{ 1 + 2 }}\n");
fn_groups(builder.fn_min_tokens(0).config(&file).unwrap(), &src).len()
};
let marked = "// @generated\n";
let bindgen = "/* automatically generated by rust-bindgen */\n";
assert_eq!(groups(marked, "", Echoes::default()), 0);
assert_eq!(
groups(marked, "scan-generated = true\n", Echoes::default()),
1
);
assert_eq!(
groups(marked, "scan-generated = false\n", Echoes::default()),
0
);
assert_eq!(
groups(
bindgen,
"generated-markers = [\"rust-bindgen\"]\n",
Echoes::default()
),
0
);
let builder = Echoes::default().generated_marker("rust-bindgen");
assert_eq!(
groups(bindgen, "generated-markers = [\"protoc\"]\n", builder),
0
);
}
#[test]
#[cfg(feature = "std")]
fn config_bad_toml_fails_at_build() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("bad.toml");
std::fs::write(&file, "threshold = \"high\"\n").unwrap();
let err = Echoes::default().config(&file).unwrap_err();
assert!(matches!(err, Error::Config(_)));
}
fn scan_target(name: &str, path: &str, segments: &[&str], item: &str) -> TargetScan {
let src =
format!("/// Doc.\n///\n/// ```\n/// fn dup() {{}}\n/// ```\npub fn {item}() {{}}\n");
target_from(name, path, segments, &src)
}
fn target_from(name: &str, path: &str, segments: &[&str], src: &str) -> TargetScan {
TargetScan {
name: name.to_string(),
files: vec![SourceFile {
path: path.to_string(),
segments: segments.iter().map(ToString::to_string).collect(),
cfg: Vec::new(),
parsed: match syn::parse_str(src) {
Ok(parsed) => parsed,
Err(err) => panic!("parse fixture: {err}"),
},
text: src.to_string(),
rustdoc: true,
}],
library: true,
}
}
const BODY: &str = "(values: &[u32], limit: u32) -> u32 { let mut total = 0; for value in values { if *value > limit { total += value * 2; } else { total -= 1; } } let doubled = total + limit; doubled }";
fn fn_groups(scan: Echoes, src: &str) -> Vec<Vec<String>> {
let report = scan.functions().run_targets(
"",
&[target_from("c", "src/lib.rs", &[], src)],
&|_f, _i| None,
);
report
.groups
.iter()
.filter(|g| g.kind == Kind::Function)
.map(|g| g.sites.iter().map(|s| s.item.clone()).collect())
.collect()
}
#[test]
#[cfg(feature = "std")]
fn excluded_files_leave_every_check() {
let src = format!("/// Doc.\n///\n/// ```\n/// fn dup() {{}}\n/// ```\npub fn one{BODY}\n");
let scan = |echoes: Echoes| {
echoes
.functions()
.context_blocks()
.context_min_tokens(0)
.run_targets(
"/ws",
&[
target_from("a", "/ws/benches/deep/a.rs", &[], &src),
target_from("b", "/ws/src/lib.rs", &[], &src),
],
&|_f, _i| None,
)
};
let all = scan(Echoes::default());
assert_eq!((all.total, all.functions), (2, 2));
assert!(!all.groups.is_empty() && !all.context_groups.is_empty());
for pattern in [
"benches/",
"benches/**",
"/benches",
"deep/",
"a.rs",
"benches/*/a.rs",
"*.rs",
] {
let kept = scan(Echoes::default().exclude(pattern).unwrap());
let expected = if pattern == "*.rs" { (0, 0) } else { (1, 1) };
assert_eq!((kept.total, kept.functions), expected, "{pattern}");
assert!(kept.groups.is_empty(), "{pattern}");
assert!(kept.context_groups.is_empty(), "{pattern}");
}
for pattern in [
"deep/a.rs",
"/a.rs",
"bench",
"benches/*.rs",
"org/repo:benches/**",
] {
let kept = scan(Echoes::default().exclude(pattern).unwrap());
assert_eq!((kept.total, kept.functions), (2, 2), "{pattern}");
}
}
#[test]
#[cfg(feature = "std")]
fn invalid_exclude_patterns_fail() {
for pattern in ["", "/", "!src/**", "src/[a"] {
assert!(
matches!(
Echoes::default().exclude(pattern),
Err(Error::InvalidExclude { .. })
),
"{pattern:?}"
);
}
}
#[test]
#[cfg(feature = "std")]
fn config_and_builder_excludes_add_up() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("echoes.toml");
std::fs::write(&file, "exclude = [\"benches/**\"]\n").unwrap();
let target = |path: &str| {
target_from(
"t",
path,
&[],
"/// ```\n/// fn dup() {}\n/// ```\npub fn f() {}\n",
)
};
let report = Echoes::default()
.exclude("examples/**")
.unwrap()
.config(&file)
.unwrap()
.run_targets(
"",
&[
target("benches/a.rs"),
target("examples/b.rs"),
target("src/c.rs"),
],
&|_f, _i| None,
);
assert_eq!(report.total, 1);
std::fs::write(&file, "exclude = [\"src/[a\"]\n").unwrap();
assert!(matches!(
Echoes::default().config(&file),
Err(Error::InvalidExclude { .. })
));
}
#[test]
fn a_pub_function_or_method_of_a_library_is_public() {
let public = |src: &str, library: bool| -> Vec<bool> {
let mut target = target_from("c", "src/lib.rs", &[], src);
target.library = library;
let report = Echoes::default()
.functions()
.run_targets("", &[target], &|_f, _i| None);
report.groups[0].sites.iter().map(|s| s.public).collect()
};
let free = format!("fn one{BODY}\npub fn two{BODY}\npub(crate) fn three{BODY}\n");
assert_eq!(public(&free, true), [false, true, false]);
assert_eq!(public(&free, false), [false, false, false]);
let methods = format!("struct A;\nimpl A {{ fn one{BODY}\npub fn two{BODY} }}\n");
assert_eq!(public(&methods, true), [false, true]);
}
#[test]
fn a_cfg_attr_counts_only_when_it_wraps_a_live_attribute() {
for wrapped in [
"inline",
"allow(dead_code), inline(always)",
"doc = \"x\"",
"cfg_attr(windows, cold)",
] {
let src = format!("#[cfg_attr(unix, {wrapped})]\nfn one{BODY}\nfn two{BODY}\n");
assert_eq!(
fn_groups(Echoes::default(), &src),
[["c::one", "c::two"]],
"{wrapped}"
);
}
for wrapped in [
"case(1)",
"inline, test",
"cfg_attr(windows, should_panic)",
"doc = include_str!(\"a.md\")",
] {
let src = format!("#[cfg_attr(unix, {wrapped})]\nfn one{BODY}\nfn two{BODY}\n");
assert_eq!(
fn_groups(Echoes::default(), &src),
Vec::<Vec<String>>::new(),
"{wrapped}"
);
}
}
#[test]
fn functions_differing_in_name_visibility_and_inert_attributes_group() {
let src = format!(
"pub fn one{BODY}\n#[inline]\n#[cfg(unix)]\n/// Doc.\nfn two{BODY}\n#[must_use]\npub(crate) fn three{BODY}\n"
);
assert_eq!(
fn_groups(Echoes::default(), &src),
[["c::one", "c::two", "c::three"]]
);
}
#[test]
fn function_hashes_do_not_depend_on_the_workspace_location() {
let src = format!("fn one{BODY}\nfn two{BODY}\n");
let hash = |root: &str| {
let target = target_from("c", &format!("{root}/src/lib.rs"), &[], &src);
Echoes::default()
.functions()
.run_targets(root, &[target], &|_f, _i| None)
.groups[0]
.hash
.clone()
};
assert_eq!(hash("/base"), hash("/head"));
}
#[test]
fn methods_of_types_sharing_a_last_segment_span_types() {
for (one, two) in [
("Foo<u8>", "Foo<u16>"),
("a::Foo", "b::Foo"),
("dyn Shape", "dyn Area"),
] {
let src = format!("impl {one} {{ fn run{BODY} }}\nimpl {two} {{ fn run{BODY} }}\n");
let report = Echoes::default().functions().run_targets(
"",
&[target_from("c", "src/lib.rs", &[], &src)],
&|_f, _i| None,
);
let types: Vec<Option<&str>> = report.groups[0]
.sites
.iter()
.map(|s| s.self_type.as_deref())
.collect();
assert_eq!(types, [Some(one), Some(two)]);
assert_eq!(
report.groups[0].remedy,
Some(Remedy::GenericOrMacro),
"{one} {two}"
);
}
}
#[test]
fn a_file_marked_generated_in_its_first_five_lines_is_skipped() {
let body = format!(
"/// ```\n/// let x = 1;\n/// let y = x + 1;\n/// ```\nfn one{BODY}\n/// ```\n/// let x = 1;\n/// let y = x + 1;\n/// ```\nfn two{BODY}\n"
);
let scan = |header: &str, echoes: Echoes| {
let target = target_from("c", "src/lib.rs", &[], &format!("{header}{body}"));
let report = echoes
.functions()
.context_blocks()
.context_min_tokens(0)
.run_targets("", &[target], &|_f, _i| None);
(
report.total,
report.functions,
report.groups.len(),
report.context_blocks > 0,
)
};
for header in [
"// This file is @generated by syn-internal-codegen.\n",
"\n\n\n\n// @generated\n",
"// Code generated by software.amazon.smithy.rust.codegen.smithy-rs. DO NOT EDIT.\n",
] {
assert_eq!(
scan(header, Echoes::default()),
(0, 0, 0, false),
"{header}"
);
assert_eq!(
scan(header, Echoes::default().scan_generated()),
(2, 2, 2, true),
"{header}"
);
}
for header in [
"\n\n\n\n\n// @generated\n",
"// Code generated by hand, edit freely.\n",
"/* automatically generated by rust-bindgen 0.72.1 */\n",
] {
assert_eq!(scan(header, Echoes::default()), (2, 2, 2, true), "{header}");
}
let bindgen = "/* automatically generated by rust-bindgen 0.72.1 */\n";
assert_eq!(
scan(
bindgen,
Echoes::default().generated_marker("by rust-bindgen")
),
(0, 0, 0, false)
);
}
#[test]
fn a_methods_name_does_not_bind_the_free_function_its_body_calls() {
let src = format!(
"struct A;\nimpl A {{ fn pick_files{BODY}\nfn pick_folders{BODY} }}\nimpl A {{\nfn one(self, values: &[u32], limit: u32) -> u32 {{ let mut total = 0; for value in values {{ if *value > limit {{ total += pick_files(values, limit); }} else {{ total -= 1; }} }} total }}\nfn two(self, values: &[u32], limit: u32) -> u32 {{ let mut total = 0; for value in values {{ if *value > limit {{ total += pick_folders(values, limit); }} else {{ total -= 1; }} }} total }}\n}}\n"
);
assert_eq!(
fn_groups(Echoes::default(), &src),
[["c::A::pick_files", "c::A::pick_folders"]]
);
let methods = "struct A;\nimpl A {\nfn one(self, values: &[u32], limit: u32) -> u32 { let mut total = 0; for value in values { if *value > limit { total += one(values, limit); } else { total -= 1; } } let doubled = total + limit; doubled }\nfn two(self, values: &[u32], limit: u32) -> u32 { let mut total = 0; for value in values { if *value > limit { total += two(values, limit); } else { total -= 1; } } let doubled = total + limit; doubled }\n}\n";
assert_eq!(
fn_groups(Echoes::default(), methods),
Vec::<Vec<String>>::new()
);
let free = "fn one(values: &[u32], limit: u32) -> u32 { let mut total = 0; for value in values { if *value > limit { total += one(values, limit); } else { total -= 1; } } let doubled = total + limit; doubled }\nfn two(values: &[u32], limit: u32) -> u32 { let mut total = 0; for value in values { if *value > limit { total += two(values, limit); } else { total -= 1; } } let doubled = total + limit; doubled }\n";
assert_eq!(fn_groups(Echoes::default(), free), [["c::one", "c::two"]]);
}
#[test]
fn two_targets_sharing_a_root_file_are_one_crate() {
let src = format!("fn one{BODY}\nfn two{BODY}\n");
let targets = [
target_from("containers", "tests/containers.rs", &[], &src),
target_from("containers", "tests/containers.rs", &[], &src),
];
let report = Echoes::default()
.functions()
.run_targets("", &targets, &|_f, _i| None);
let items: Vec<Vec<&str>> = report
.groups
.iter()
.map(|g| g.sites.iter().map(|s| s.item.as_str()).collect())
.collect();
assert_eq!(items, [["containers::one", "containers::two"]]);
assert_eq!((report.functions, report.unique_functions), (2, 1));
}
#[test]
fn two_targets_sharing_a_name_are_two_crates() {
let src = format!("fn main{BODY}\n");
let targets = [
target_from("show_posts", "mysql/src/bin/show_posts.rs", &[], &src),
target_from("show_posts", "pg/src/bin/show_posts.rs", &[], &src),
];
let report = Echoes::default()
.functions()
.run_targets("", &targets, &|_f, _i| None);
assert_eq!(report.groups, Vec::new());
assert_eq!(report.unique_functions, 2);
}
#[test]
fn functions_compare_within_one_module_only() {
let src = format!("fn one{BODY}\nmod inner {{ fn two{BODY} }}\n");
assert_eq!(
fn_groups(Echoes::default(), &src),
Vec::<Vec<String>>::new()
);
let src = format!("mod inner {{ fn one{BODY}\nfn two{BODY} }}\n");
assert_eq!(
fn_groups(Echoes::default(), &src),
[["c::inner::one", "c::inner::two"]]
);
}
#[test]
fn a_free_function_and_a_method_of_one_body_span_types() {
let src = format!(
"struct A;\nfn run{BODY}\nimpl A {{ fn run{BODY} }}\nimpl A {{ fn again{BODY} }}\n"
);
let report = Echoes::default().functions().run_targets(
"",
&[target_from("c", "src/lib.rs", &[], &src)],
&|_f, _i| None,
);
let group = &report.groups[0];
let types: Vec<Option<&str>> = group.sites.iter().map(|s| s.self_type.as_deref()).collect();
assert_eq!(types, [None, Some("A"), Some("A")]);
assert_eq!(group.remedy, Some(Remedy::GenericOrMacro));
let same = format!("struct A;\nimpl A {{ fn run{BODY} }}\nimpl A {{ fn again{BODY} }}\n");
let report = Echoes::default().functions().run_targets(
"",
&[target_from("c", "src/lib.rs", &[], &same)],
&|_f, _i| None,
);
assert_eq!(report.groups[0].remedy, Some(Remedy::Delete));
}
fn remedy_of(src: &str) -> Option<Remedy> {
let report = Echoes::default().functions().run_targets(
"",
&[target_from("c", "src/lib.rs", &[], src)],
&|_f, _i| None,
);
assert_eq!(report.groups.len(), 1, "{src}");
report.groups[0].remedy
}
#[test]
fn every_function_group_gets_the_remedy_of_its_first_matching_rule() {
let rows = [
(
format!(
"struct A;\ntrait P {{}}\ntrait Q {{}}\nimpl P for A {{ fn a{BODY} }}\nimpl Q for A {{ fn b{BODY} }}\n"
),
Remedy::HelperOrMacro,
),
(
format!("trait T {{ fn a{BODY}\nfn b{BODY} }}\n"),
Remedy::HelperOrMacro,
),
(
format!(
"struct A;\ntrait P {{}}\nimpl A {{ fn a{BODY} }}\nimpl P for A {{ fn b{BODY} }}\n"
),
Remedy::HelperOrMacro,
),
(
format!("#[no_mangle]\npub extern \"C\" fn a{BODY}\npub extern \"C\" fn b{BODY}\n"),
Remedy::HelperOrMacro,
),
(
format!(
"#[unsafe(no_mangle)]\npub extern \"C\" fn a{BODY}\npub extern \"C\" fn b{BODY}\n"
),
Remedy::HelperOrMacro,
),
(
format!(
"pub extern \"C\" fn a{BODY}\n#[unsafe(export_name = \"b\")]\npub extern \"C\" fn b{BODY}\n"
),
Remedy::HelperOrMacro,
),
(
format!(
"#[export_name = \"a\"]\npub extern \"C\" fn a{BODY}\npub extern \"C\" fn b{BODY}\n"
),
Remedy::HelperOrMacro,
),
(
format!("#[cfg(unix)]\nfn a{BODY}\nfn b{BODY}\n"),
Remedy::MergeCfg,
),
(
format!(
"struct A;\nimpl A {{ #[cfg(unix)] fn a{BODY}\n#[cfg(windows)] fn b{BODY} }}\n"
),
Remedy::MergeCfg,
),
(
format!(
"#[cfg(unix)]\nmod m {{ fn a{BODY} }}\n#[cfg(not(unix))]\nmod m {{ fn a{BODY} }}\n"
),
Remedy::MergeCfg,
),
(
format!(
"struct A;\n#[cfg(unix)]\nimpl A {{ fn a{BODY} }}\n#[cfg(not(unix))]\nimpl A {{ fn a{BODY} }}\n"
),
Remedy::MergeCfg,
),
(
format!(
"#[cfg(unix)]\nmod m {{ #[cfg(test)] fn a{BODY}\n#[cfg(test)] fn b{BODY} }}\n"
),
Remedy::Delete,
),
(
format!("#[cfg_attr(unix, inline)]\nfn a{BODY}\nfn b{BODY}\n"),
Remedy::Delete,
),
(
format!(
"#[cfg(unix)]\n#[cfg(feature = \"x\")]\nfn a{BODY}\n#[cfg(feature = \"x\")]\n#[cfg(unix)]\nfn b{BODY}\n"
),
Remedy::Delete,
),
(format!("fn a{BODY}\nfn b{BODY}\n"), Remedy::Delete),
(
format!(
"struct A;\nstruct B;\ntrait P {{}}\nimpl P for A {{ #[cfg(unix)] fn a{BODY} }}\nimpl P for B {{ fn a{BODY} }}\n"
),
Remedy::GenericOrMacro,
),
(
format!(
"struct A;\ntrait P {{}}\ntrait Q {{}}\nimpl P for A {{ #[cfg(unix)] fn a{BODY} }}\nimpl Q for A {{ fn b{BODY} }}\n"
),
Remedy::HelperOrMacro,
),
];
for (src, remedy) in rows {
assert_eq!(remedy_of(&src), Some(remedy), "{src}");
}
}
#[test]
fn doctest_groups_carry_no_remedy() {
let blocks = [
dt("a.rs", 1, "m::f", "let x = 1;", false),
dt("b.rs", 2, "m::g", "let x = 1;", false),
];
assert_eq!(group(&blocks, 2, 0).groups[0].remedy, None);
}
#[test]
fn methods_of_different_self_types_group_and_report_their_types() {
let src = format!(
"struct A;\nstruct B;\nimpl A {{ fn run{BODY} }}\nimpl B {{ fn run{BODY} }}\ntrait T {{ fn go{BODY} }}\n"
);
let report = Echoes::default().functions().run_targets(
"",
&[target_from("c", "src/lib.rs", &[], &src)],
&|_f, _i| None,
);
let group = report
.groups
.iter()
.find(|g| g.kind == Kind::Function)
.unwrap();
let sites: Vec<(&str, Option<&str>)> = group
.sites
.iter()
.map(|s| (s.item.as_str(), s.self_type.as_deref()))
.collect();
assert_eq!(
sites,
[
("c::A::run", Some("A")),
("c::B::run", Some("B")),
("c::T::go", Some("T"))
]
);
}
#[test]
fn methods_naming_their_owner_group_across_self_types() {
let src = "struct A<T>(T);\nstruct B<T>(T);\nimpl<T: Default> A<T> { fn make() -> Self { Self(T::default()) } }\nimpl<T: Default> B<T> { fn make() -> Self { Self(T::default()) } }\n";
assert_eq!(
fn_groups(Echoes::default().fn_min_tokens(0), src),
[["c::A<T>::make", "c::B<T>::make"]]
);
}
#[test]
fn methods_group_across_owner_headers_listing_parameters_differently() {
let src = "struct A<T, U>(T, U);\nstruct B<T, U>(T, U);\nimpl<T: Default, U> A<T, U> { fn make() -> T { T::default() } }\nimpl<U, T: Default> B<T, U> { fn make() -> T { T::default() } }\n";
assert_eq!(
fn_groups(Echoes::default().fn_min_tokens(0), src),
[["c::A<T,U>::make", "c::B<T,U>::make"]]
);
}
#[test]
fn methods_group_across_wrapper_owners() {
let src = "trait Table { fn table() -> u8; }\nimpl<T: Table> Table for &T { fn table() -> u8 { T::table() } }\nimpl<T: Table> Table for Box<T> { fn table() -> u8 { T::table() } }\n";
assert_eq!(
fn_groups(Echoes::default().fn_min_tokens(0), src),
[["c::&T::table", "c::Box<T>::table"]]
);
}
#[test]
fn methods_of_one_owner_using_different_parameters_stay_apart() {
let src = "struct A<T, U>(T, U);\nstruct B<T, U>(T, U);\nimpl<T: Default, U: Default> A<T, U> { fn left() -> T { T::default() } fn right() -> U { U::default() } }\nimpl<T: Default, U: Default> B<T, U> { fn left() -> T { T::default() } }\n";
assert_eq!(
fn_groups(Echoes::default().fn_min_tokens(0), src),
[["c::A<T,U>::left", "c::B<T,U>::left"]]
);
}
#[test]
fn signature_and_test_attributes_keep_functions_apart() {
let wide = BODY.replace("u32", "u64");
for src in [
format!("fn one{BODY}\nfn two{wide}\n"),
format!("#[test]\nfn one{BODY}\nfn two{BODY}\n"),
format!("#[test]\n#[should_panic]\nfn one{BODY}\n#[test]\nfn two{BODY}\n"),
format!("#[test]\n#[ignore]\nfn one{BODY}\n#[test]\nfn two{BODY}\n"),
] {
assert_eq!(
fn_groups(Echoes::default(), &src),
Vec::<Vec<String>>::new(),
"{src}"
);
}
}
#[test]
fn the_function_floor_counts_the_body_not_the_signature() {
let src = "struct S;\ntrait A {}\ntrait B {}\nimpl A for S {\nfn from_sql(value: &mut PgValue<'_>) -> deserialize::Result<Self> { Ok(core::str::from_utf8(value.as_bytes())?) }\n}\nimpl B for S {\nfn from_sql(value: &mut PgValue<'_>) -> deserialize::Result<Self> { Ok(core::str::from_utf8(value.as_bytes())?) }\n}\n";
assert_eq!(fn_groups(Echoes::default(), src), Vec::<Vec<String>>::new());
assert_eq!(fn_groups(Echoes::default().fn_min_tokens(6), src).len(), 1);
assert_eq!(fn_groups(Echoes::default().fn_min_tokens(7), src).len(), 0);
}
#[test]
fn the_function_floor_defaults_to_30_tokens_and_is_settable() {
let src = "fn one() -> u8 { 1 + 2 }\nfn two() -> u8 { 1 + 2 }\n";
assert_eq!(fn_groups(Echoes::default(), src), Vec::<Vec<String>>::new());
assert_eq!(
fn_groups(Echoes::default().fn_min_tokens(0), src),
[["c::one", "c::two"]]
);
let src = format!("fn one{BODY}\nfn two{BODY}\n");
assert_eq!(
fn_groups(Echoes::default().fn_min_tokens(1000), &src),
Vec::<Vec<String>>::new()
);
}
#[test]
fn an_allow_comment_above_a_function_drops_its_site() {
let src = format!("fn one{BODY}\n// echoes: allow\n#[inline]\nfn two{BODY}\n");
assert_eq!(
fn_groups(Echoes::default(), &src),
Vec::<Vec<String>>::new()
);
let src = format!("fn one{BODY}\n// echoes: allow\n\nfn two{BODY}\n");
assert_eq!(fn_groups(Echoes::default(), &src), [["c::one", "c::two"]]);
}
#[test]
fn nested_functions_are_not_sites() {
let src = format!("fn outer() {{ fn one{BODY}\nfn two{BODY} }}\n");
assert_eq!(
fn_groups(Echoes::default(), &src),
Vec::<Vec<String>>::new()
);
}
#[test]
fn a_function_site_spans_its_attributes_and_counts_apart() {
let src = format!("/// One.\n#[inline]\nfn one{BODY}\n\nfn two{BODY}\n");
let report = Echoes::default().functions().run_targets(
"",
&[target_from("c", "src/lib.rs", &[], &src)],
&|_f, _i| None,
);
assert_eq!((report.total, report.unique), (0, 0));
assert_eq!((report.functions, report.unique_functions), (2, 1));
let sites = &report.groups[0].sites;
assert_eq!((sites[0].line, sites[0].end), (1, Some(3)));
assert_eq!((sites[1].line, sites[1].end), (5, Some(5)));
assert_eq!(sites[0].code, format!("/// One.\n#[inline]\nfn one{BODY}"));
}
#[test]
fn functions_in_a_file_rustdoc_skips_still_count() {
let mut target = target_from(
"c",
"src/lib.rs",
&[],
&format!("/// ```\n/// let x = 1;\n/// ```\nfn one{BODY}\nfn two{BODY}\n"),
);
target.files[0].rustdoc = false;
let report = Echoes::default()
.functions()
.run_targets("", &[target], &|_f, _i| None);
assert_eq!(report.total, 0);
assert_eq!(report.groups.len(), 1);
}
#[test]
fn run_targets_groups_duplicate_doctests_in_memory() {
let targets = vec![
scan_target("alpha", "src/lib.rs", &[], "one"),
scan_target("beta", "src/parser.rs", &["parser"], "two"),
];
let report = Echoes::default().run_targets("", &targets, &|_file, _inc| None);
assert_eq!(report.total, 2);
assert_eq!(report.unique, 1);
assert_eq!(report.groups.len(), 1);
let sites = &report.groups[0].sites;
assert_eq!(sites[0].file, "src/lib.rs");
assert_eq!(sites[0].item, "alpha::one");
assert_eq!(sites[1].file, "src/parser.rs");
assert_eq!(sites[1].item, "beta::parser::two");
}
#[test]
fn one_doc_block_shared_by_several_items_is_not_a_duplicate() {
let shared = "use dioxus::prelude::*;\nlet value = use_signal(|| 0);";
let blocks = vec![
dt(
"docs/rules_of_hooks.md",
97,
"hooks::use_context",
shared,
false,
),
dt(
"docs/rules_of_hooks.md",
97,
"hooks::use_callback",
shared,
false,
),
dt(
"docs/rules_of_hooks.md",
97,
"hooks::use_coroutine",
shared,
false,
),
];
let report = group(&blocks, 2, 0);
assert_eq!(report.total, 1);
assert_eq!(report.unique, 1);
assert_eq!(report.groups, Vec::new());
}
#[test]
fn shared_doc_block_keeps_grouping_against_a_real_copy() {
let shared = "let x = 1;\nassert_eq!(x, 1);";
let blocks = vec![
dt("docs/guide.md", 12, "crate::beta", shared, false),
dt("docs/guide.md", 12, "crate::alpha", shared, false),
dt("src/lib.rs", 40, "crate::copy", shared, false),
];
let report = group(&blocks, 2, 0);
assert_eq!(report.total, 2);
assert_eq!(report.groups.len(), 1);
let sites = &report.groups[0].sites;
assert_eq!(sites.len(), 2);
assert_eq!(sites[0].item, "crate::beta");
assert_eq!(sites[1].item, "crate::copy");
}
#[test]
fn run_targets_groups_alpha_equivalent_doctests() {
let target = |name: &str, body: &str| {
let src = format!("/// ```\n/// {body}\n/// ```\npub fn f() {{}}\n");
target_from(name, &format!("{name}/src/lib.rs"), &[], &src)
};
let targets = vec![
target("alpha", "let pino = 1; pino + 1"),
target("beta", "let abete = 1; abete + 1"),
];
let report = Echoes::default().run_targets("", &targets, &|_file, _inc| None);
assert_eq!(report.total, 2);
assert_eq!(report.unique, 1);
assert_eq!(report.groups.len(), 1);
assert_eq!(report.groups[0].sites.len(), 2);
}
#[test]
fn source_file_debug_shows_identity() {
let target = scan_target("alpha", "src/lib.rs", &[], "one");
let dbg = format!("{:?}", target.files[0]);
assert!(dbg.contains("SourceFile"));
assert!(dbg.contains("path: \"src/lib.rs\""));
assert!(dbg.contains("segments: []"));
}
#[test]
fn run_targets_applies_threshold_and_package() {
let targets = vec![
scan_target("alpha", "alpha/src/lib.rs", &[], "one"),
scan_target("beta", "beta/src/lib.rs", &[], "two"),
];
let narrow = Echoes::default()
.threshold(3)
.run_targets("", &targets, &|_f, _i| None);
assert_eq!(narrow.total, 2);
assert_eq!(narrow.groups, Vec::new());
let filtered =
Echoes::default()
.package("beta")
.threshold(1)
.run_targets("", &targets, &|_f, _i| None);
assert_eq!(filtered.total, 1);
assert_eq!(filtered.groups[0].sites[0].file, "beta/src/lib.rs");
}
#[test]
fn allowed_sites_excluded_from_groups() {
let blocks = vec![
dt("a.rs", 1, "m::a", "let x = 1;", false),
dt("b.rs", 2, "m::b", "let x = 1;", true),
dt("c.rs", 3, "m::c", "let x = 1;", false),
];
let report = group(&blocks, 2, 0);
assert_eq!(report.total, 3);
assert_eq!(report.groups.len(), 1);
assert_eq!(report.groups[0].sites.len(), 2);
}
#[test]
fn threshold_filters_groups() {
let blocks = vec![
dt("a.rs", 1, "m::a", "let x = 1;", false),
dt("b.rs", 2, "m::b", "let x = 1;", false),
];
let report = group(&blocks, 3, 0);
assert_eq!(report.groups, Vec::new());
assert_eq!(report.unique, 1);
}
#[test]
fn min_tokens_excludes_from_groups_but_counts_unique() {
let blocks = vec![
dt("a.rs", 1, "m::a", "let x = 1;", false),
dt("b.rs", 2, "m::b", "let x = 1;", false),
];
let report = group(&blocks, 2, 100);
assert_eq!(report.groups, Vec::new());
assert_eq!(report.unique, 1);
}
#[test]
fn sites_sorted_by_file_and_line() {
let blocks = vec![
dt("b.rs", 9, "m::b", "let x = 1;", false),
dt("a.rs", 4, "m::a", "let x = 1;", false),
dt("a.rs", 2, "m::a", "let x = 1;", false),
];
let report = group(&blocks, 2, 0);
let sites = &report.groups[0].sites;
let pos: Vec<(String, u32)> = sites.iter().map(|s| (s.file.clone(), s.line)).collect();
assert_eq!(
pos,
vec![
("a.rs".to_string(), 2),
("a.rs".to_string(), 4),
("b.rs".to_string(), 9)
]
);
}
#[test]
fn a_site_naming_its_item_comes_first() {
let order = |blocks: &[DocTest]| -> Vec<String> {
group(blocks, 2, 0).groups[0]
.sites
.iter()
.map(|s| s.item.clone())
.collect()
};
assert_eq!(
order(&[
dt("a.rs", 1, "m::lex", "parse(\"x\");", false),
dt("b.rs", 2, "m::Parser::parse", "parse(\"x\");", false),
]),
["m::Parser::parse", "m::lex"]
);
assert_eq!(
order(&[
dt("a.rs", 1, "m::read", "read_all(r#match);", false),
dt("b.rs", 2, "m::r#match", "read_all(r#match);", false),
]),
["m::r#match", "m::read"]
);
assert_eq!(
order(&[
dt("b.rs", 1, "m::f", "f(); g();", false),
dt("a.rs", 2, "m::g", "f(); g();", false),
]),
["m::g", "m::f"]
);
assert_eq!(
order(&[
dt("b.rs", 1, "m::f", "h();", false),
dt("a.rs", 2, "m::g", "h();", false),
]),
["m::g", "m::f"]
);
}
#[test]
fn id_is_hash_prefix() {
let blocks = vec![
dt("a.rs", 1, "m::a", "let x = 1;", false),
dt("b.rs", 2, "m::b", "let x = 1;", false),
];
let report = group(&blocks, 2, 0);
let g = &report.groups[0];
assert_eq!(g.id, &g.hash[..8]);
assert_eq!(g.hash.len(), 64);
}
#[test]
fn unparsed_propagates() {
let blocks = vec![
dt("a.rs", 1, "m::a", "@ nope", false),
dt("b.rs", 2, "m::b", "@ nope", false),
];
let report = group(&blocks, 2, 0);
assert!(report.groups[0].unparsed);
}
#[test]
fn attributes_do_not_split_groups() {
let mut a = dt("a.rs", 1, "m::a", "let x = 1;", false);
a.info = vec!["no_run".to_string()];
let b = dt("b.rs", 2, "m::b", "let x = 1;", false);
let report = group(&[a, b], 2, 0);
assert_eq!(report.groups.len(), 1);
assert_eq!(report.groups[0].sites[0].info, vec!["no_run".to_string()]);
}
#[test]
fn no_duplicates_is_clean() {
let blocks = vec![
dt("a.rs", 1, "m::a", "let x = 1;", false),
dt("b.rs", 2, "m::b", "let y = 2;", false),
];
let report = group(&blocks, 2, 0);
assert_eq!(report.groups, Vec::new());
assert_eq!(report.unique, 2);
assert_eq!(report.total, 2);
}
#[test]
#[cfg(feature = "std")]
fn a_long_closure_chain_canonicalizes_on_a_small_caller_stack() {
let code = format!("let f = {}1;", "|x| ".repeat(5000));
let report = std::thread::Builder::new()
.stack_size(2 << 20)
.spawn(move || group(&[dt("a.rs", 1, "m::a", &code, false)], 1, 0))
.unwrap()
.join()
.unwrap();
assert!(!report.groups[0].unparsed);
}
#[test]
#[cfg(feature = "std")]
fn a_refused_worker_thread_falls_back_to_the_calling_thread() {
let blocks = vec![
dt("a.rs", 1, "m::a", "let x = 1;", false),
dt("b.rs", 2, "m::b", "let y = 1;", false),
];
let expected = group(&blocks, 2, 0);
let refused = on_large_stack(usize::MAX / 2, || {
let blocks = blocks;
group_here(&blocks, 2, 0)
});
assert_eq!(refused, expected);
assert_eq!(refused.groups.len(), 1);
}
#[test]
#[cfg(feature = "std")]
fn a_deeply_nested_source_file_scans_on_a_small_caller_stack() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"deep\"\nversion = \"0.1.0\"\nedition = \"2021\"\n[workspace]\n",
)
.unwrap();
let body = format!("{}1{}", "{".repeat(5000), "}".repeat(5000));
let lib = format!("/// ```\n/// let x = 1;\n/// ```\npub fn f() -> u8 {{ {body} }}\n");
std::fs::write(dir.path().join("src").join("lib.rs"), lib).unwrap();
let root = dir.path().to_path_buf();
let report = std::thread::Builder::new()
.stack_size(2 << 20)
.spawn(move || Echoes::default().run(root))
.unwrap()
.join()
.unwrap()
.unwrap();
assert_eq!(report.total, 1);
}
#[test]
#[cfg(feature = "std")]
fn context_dependency_depth_sets_the_search_depth() {
let scan = Echoes::default().context_dependency_depth(3);
assert_eq!(scan.context_dependencies.depth, Some(3));
let scan = Echoes::default()
.context_dependencies(DependencySearch {
check_latest: Some(false),
..DependencySearch::default()
})
.context_dependency_depth(2);
assert_eq!(scan.context_dependencies.depth, Some(2));
assert_eq!(scan.context_dependencies.check_latest, Some(false));
}
#[test]
#[cfg(feature = "std")]
fn context_target_requested_applies_scope_and_package() {
let target = discover::Target {
name: "mycrate".into(),
package: "mycrate".into(),
src: std::path::PathBuf::from("src/lib.rs"),
doc: true,
library: true,
};
let requested = |scan: Echoes| scan.context_target_requested(&target, "root");
assert!(requested(Echoes::default()));
assert!(requested(Echoes::default().package("mycrate")));
assert!(!requested(Echoes::default().package("other")));
assert!(requested(
Echoes::default().context_select(ContextScope::Workspace)
));
assert!(requested(
Echoes::default()
.context_select(ContextScope::Crate("other".into()))
.context_search(ContextScope::File("src/lib.rs".into()))
));
assert!(requested(
Echoes::default()
.package("other")
.context_select(ContextScope::Crate("another".into()))
));
assert!(requested(
Echoes::default()
.context_select(ContextScope::Module("mycrate::deep".into()))
.context_search(ContextScope::Module("mycrate::deeper".into()))
));
assert!(!requested(
Echoes::default()
.context_select(ContextScope::Module("other::deep".into()))
.context_search(ContextScope::Module("another::deep".into()))
));
}
#[test]
#[cfg(feature = "std")]
fn run_targets_reports_dependency_discovery_as_incomplete() {
let target = target_from("mycrate", "src\\lib.rs", &[], "fn one() {}\n");
let report =
Echoes::default()
.context_dependency_depth(1)
.run_targets("", &[target], &|_f, _i| None);
assert!(report.reuse.is_some());
let reuse = report.reuse.as_ref().unwrap();
assert!(!reuse.coverage.complete);
assert_eq!(
reuse.coverage.failures,
["Dependency discovery requires Echoes::run"]
);
assert_eq!(reuse.groups, Vec::new());
}
}