use std::collections::HashSet;
use std::fmt;
use std::path::PathBuf;
use std::sync::atomic::AtomicBool;
use std::sync::{Mutex, OnceLock};
use indicatif::MultiProgress;
use miette::{Diagnostic, ReportHandler};
use owo_colors::Style;
use thiserror::Error;
use crate::{fmt_dim, fmt_field, fmt_path, fmt_pkg, fmt_version, fmt_with_style};
static GLOBAL_DIAGNOSTICS: OnceLock<Diagnostics> = OnceLock::new();
pub static ENABLE_DEBUG: AtomicBool = AtomicBool::new(false);
#[macro_export]
macro_rules! diagnostic {
($prefix:expr; $($arg:tt)*) => {
$crate::diagnostic::Diagnostics::eprintln(&format!("{:>14} {}", $prefix, format!($($arg)*)))
}
}
#[macro_export]
macro_rules! errorln {
($($arg:tt)*) => { $crate::diagnostic!($crate::fmt_with_style!("Error", owo_colors::Style::new().red().bold()); $($arg)*); }
}
#[macro_export]
macro_rules! infoln {
($($arg:tt)*) => { $crate::diagnostic!($crate::fmt_with_style!("Info", owo_colors::Style::new().white().bold()); $($arg)*); }
}
#[macro_export]
macro_rules! stageln {
($stage_name:expr, $($arg:tt)*) => { $crate::diagnostic!($crate::fmt_with_style!($stage_name, owo_colors::Style::new().green().bold()); $($arg)*); }
}
#[derive(Debug)]
pub struct Diagnostics {
suppressed: HashSet<String>,
all_suppressed: bool,
emitted: Mutex<HashSet<Warnings>>,
multiprogress: Mutex<Option<MultiProgress>>,
}
impl Diagnostics {
pub fn init(suppressed: HashSet<String>) {
miette::set_hook(Box::new(|_| Box::new(DiagnosticRenderer))).unwrap();
let diag = Diagnostics {
all_suppressed: suppressed.contains("all") || suppressed.contains("Wall"),
suppressed,
emitted: Mutex::new(HashSet::new()),
multiprogress: Mutex::new(None),
};
GLOBAL_DIAGNOSTICS
.set(diag)
.expect("Diagnostics already initialized!");
}
pub fn set_multiprogress(multiprogress: Option<MultiProgress>) {
let diag = Diagnostics::get();
let mut guard = diag.multiprogress.lock().unwrap();
*guard = multiprogress;
}
pub fn progress_active() -> bool {
Diagnostics::get().multiprogress.lock().unwrap().is_some()
}
fn get() -> &'static Diagnostics {
GLOBAL_DIAGNOSTICS
.get()
.expect("Diagnostics not initialized!")
}
pub fn is_suppressed(code: &str) -> bool {
let diag = Diagnostics::get();
diag.all_suppressed || diag.suppressed.contains(code)
}
pub fn eprintln(msg: &str) {
let diag = Diagnostics::get();
let mp_guard = diag.multiprogress.lock().unwrap();
if let Some(mp) = &*mp_guard {
mp.suspend(|| {
eprintln!("{msg}");
});
} else {
eprintln!("{msg}");
}
}
}
impl Warnings {
pub fn emit(self) {
let diag = Diagnostics::get();
if let Some(code) = self.code()
&& (diag.all_suppressed || diag.suppressed.contains(&code.to_string()))
{
return;
}
let mut emitted = diag.emitted.lock().unwrap();
if emitted.contains(&self) {
return;
}
emitted.insert(self.clone());
drop(emitted);
let report = miette::Report::new(self.clone());
Diagnostics::eprintln(&format!("{report:?}"));
}
}
pub struct DiagnosticRenderer;
impl ReportHandler for DiagnosticRenderer {
fn debug(&self, diagnostic: &dyn Diagnostic, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let (severity, style) = match diagnostic.severity().unwrap_or_default() {
miette::Severity::Error => ("error", Style::new().red().bold()),
miette::Severity::Warning => ("warning", Style::new().yellow().bold()),
miette::Severity::Advice => ("advice", Style::new().cyan().bold()),
};
write!(f, "{}", fmt_with_style!(severity, style))?;
if let Some(code) = diagnostic.code() {
write!(f, "{}", fmt_with_style!(format!("[{}]", code), style))?;
}
write!(f, ": {}", diagnostic)?;
let mut annotations: Vec<String> = Vec::new();
let mut cause = std::error::Error::source(diagnostic);
while let Some(current_cause) = cause {
annotations.push(format!(
"{} {}",
fmt_with_style!("caused by:", Style::new().bold()),
fmt_dim!(
current_cause
.to_string()
.replace("\x1b[0m", "\x1b[0m\x1b[2m")
)
));
cause = current_cause.source();
}
if let Some(help) = diagnostic.help() {
let help_str = help.to_string();
for line in help_str.lines() {
annotations.push(format!(
"{} {}",
fmt_with_style!("help:", Style::new().bold()),
fmt_dim!(line.replace("\x1b[0m", "\x1b[0m\x1b[2m"))
));
}
}
let branch = " ├─›";
let corner = " ╰─›";
for (i, note) in annotations.iter().enumerate() {
let is_last = i == annotations.len() - 1;
let prefix = if is_last { corner } else { branch };
write!(f, "\n{} {}", fmt_dim!(prefix), note)?;
}
Ok(())
}
}
#[derive(Error, Diagnostic, Hash, Eq, PartialEq, Debug, Clone)]
#[diagnostic(severity(Warning))]
pub enum Warnings {
#[error(
"Skipping link to package {} at {} since there is something there",
fmt_pkg!(.0),
fmt_path!(.1.display())
)]
#[diagnostic(
code(W01),
help("Check the existing file or directory that is preventing the link.")
)]
SkippingPackageLink(String, PathBuf),
#[error("Using config at {} for overrides.", fmt_path!(path.display()))]
#[diagnostic(code(W02))]
UsingConfigForOverride { path: PathBuf },
#[error("Ignoring unknown field {} in package {}.", fmt_field!(field), fmt_pkg!(pkg))]
#[diagnostic(
code(W03),
help("Check for typos in {} or remove it from the {} manifest.", fmt_field!(field), fmt_pkg!(pkg))
)]
IgnoreUnknownField { field: String, pkg: String },
#[error("Source group in package {} contains no source files.", fmt_pkg!(.0))]
#[diagnostic(
code(W04),
help("Add source files to the source group or remove it from the manifest.")
)]
NoFilesInSourceGroup(String),
#[error("No files matched the glob pattern {}.", fmt_path!(path))]
#[diagnostic(code(W05))]
NoFilesForGlobPattern { path: String },
#[error("Dependency {} in checkout_dir {} is not a git repository. Setting as path dependency.", fmt_pkg!(.0), fmt_path!(.1.display()))]
#[diagnostic(
code(W06),
help(
"Use `bender clone` to work on git dependencies.\nRun `bender update --ignore-checkout-dir` to overwrite this at your own risk."
)
)]
NotAGitDependency(String, PathBuf),
#[error("Dependency {} in checkout_dir {} is not in a clean state. Setting as path dependency.", fmt_pkg!(.0), fmt_path!(.1.display()))]
#[diagnostic(
code(W06),
help(
"Use `bender clone` to work on git dependencies.\nRun `bender update --ignore-checkout-dir` to overwrite this at your own risk."
)
)]
DirtyGitDependency(String, PathBuf),
#[error("Path dependency {} inside git dependency {} detected. This is currently not fully supported. Your mileage may vary.", fmt_pkg!(pkg), fmt_pkg!(top_pkg))]
#[diagnostic(code(W09))]
PathDepInGitDep { pkg: String, top_pkg: String },
#[error("There may be issues in the path for {}.", fmt_pkg!(.0))]
#[diagnostic(
code(W10),
help("Please check that {} is correct and accessible.", fmt_path!(.1.display()))
)]
MaybePathIssues(String, PathBuf),
#[error("Dependency package name {} does not match the package name {} in its manifest. This can cause unwanted behavior.", fmt_pkg!(.0), fmt_pkg!(.1))]
#[diagnostic(
code(W11),
help("Check that the dependency name in your calling manifest matches the name in the {} manifest.", fmt_pkg!(.0))
)]
DepPkgNameNotMatching(String, String),
#[error("Manifest for package {} not found at {}.", fmt_pkg!(pkg), fmt_path!(src))]
#[diagnostic(code(W12))]
ManifestNotFound { pkg: String, src: String },
#[error("Name issue with package {}. `export_include_dirs` cannot be handled.", fmt_pkg!(.0))]
#[diagnostic(
code(W13),
help(
"Could be related to name mismatch between calling manifest and package manifest, check `bender update`."
)
)]
ExportDirNameIssue(String),
#[error("If `--local` is used, no fetching will be performed.")]
#[diagnostic(code(W14))]
LocalNoFetch,
#[error("No patch directory found for package {} when trying to apply patches from {} to {}. Skipping patch generation.", fmt_pkg!(vendor_pkg), fmt_path!(from_prefix.display()), fmt_path!(to_prefix.display()))]
#[diagnostic(code(W15))]
NoPatchDir {
vendor_pkg: String,
from_prefix: PathBuf,
to_prefix: PathBuf,
},
#[error("Dependency string for the included dependencies might be wrong.")]
#[diagnostic(code(W16))]
DependStringMaybeWrong,
#[error("{} not found in upstream, continuing.", fmt_path!(path))]
#[diagnostic(code(W16))]
NotInUpstream { path: String },
#[error("Package {} is shown to include dependency, but manifest does not have this information.", fmt_pkg!(pkg))]
#[diagnostic(code(W17))]
IncludeDepManifestMismatch { pkg: String },
#[error("An override is specified for dependency {} to {}.", fmt_pkg!(pkg), fmt_pkg!(pkg_override))]
#[diagnostic(code(W18))]
DepOverride { pkg: String, pkg_override: String },
#[error("Workspace checkout directory set and has uncommitted changes, not updating {} at {}.", fmt_pkg!(.0), fmt_path!(.1.display()))]
#[diagnostic(
code(W19),
help("Run `bender checkout --force` to overwrite the dependency at your own risk.")
)]
CheckoutDirDirty(String, PathBuf),
#[error("Workspace checkout directory set and remote url doesn't match, not updating {} at {}.", fmt_pkg!(.0), fmt_path!(.1.display()))]
#[diagnostic(
code(W19),
help("Run `bender checkout --force` to overwrite the dependency at your own risk.")
)]
CheckoutDirUrlMismatch(String, PathBuf),
#[error("Ignoring error for {} at {}: {}", fmt_pkg!(.0), fmt_path!(.1), .2)]
#[diagnostic(code(W20))]
IgnoringError(String, String, String),
#[error("No revision found in lock file for git dependency {}.", fmt_pkg!(pkg))]
#[diagnostic(code(W21))]
NoRevisionInLockFile { pkg: String },
#[error("Dependency {} has source path {} which does not exist.", fmt_pkg!(.0), fmt_path!(.1.display()))]
#[diagnostic(code(W22), help("Please check that the path exists and is correct."))]
DepSourcePathMissing(String, PathBuf),
#[error("Locked revision {} for dependency {} not found in available revisions, allowing update.", fmt_version!(rev), fmt_pkg!(pkg))]
#[diagnostic(code(W23))]
LockedRevisionNotFound { pkg: String, rev: String },
#[error("Include directory {} doesn't exist.", fmt_path!(.0.display()))]
#[diagnostic(
code(W24),
help("Please check that the include directory exists and is correct.")
)]
IncludeDirMissing(PathBuf),
#[error("Skipping dirty dependency {}", fmt_pkg!(pkg))]
#[diagnostic(code(W25), help("Use `--no-skip` to still snapshot {}.", fmt_pkg!(pkg)))]
SkippingDirtyDep { pkg: String },
#[error("Dependency {} seems to use git-lfs, but git-lfs failed with `{}`.", fmt_pkg!(.0), .1)]
#[diagnostic(
code(W26),
help("You may need to install git-lfs to ensure all files are fetched correctly.")
)]
LfsMissing(String, String),
#[error("Git LFS is disabled but dependency {} seems to use git-lfs.", fmt_pkg!(.0))]
#[diagnostic(
code(W27),
help("Enable git-lfs support in the configuration to fetch all files correctly.")
)]
LfsDisabled(String),
#[error("{} with unknown type:\n{}", if .0.len() == 1 { "File" } else { "Files" }, .0.iter().map(|p| format!(" - {}", fmt_path!(p.display()))).collect::<Vec<_>>().join("\n"))]
#[diagnostic(
code(W28),
help(
"Known file extensions are: .sv, .v, .vp (Verilog) and .vhd, .vhdl (VHDL).\nTo indicate a file type for unknown file extensions, use `sv:`, `v:`, or `vhd:` when listing the file."
)
)]
UnknownFileType(Vec<PathBuf>),
#[error("Revision {} for dependency {} is not on any upstream branch or tag.", fmt_version!(.0), fmt_pkg!(.1))]
#[diagnostic(
code(W29),
help(
"The commit may have been removed from the remote by a force-push. Consider updating to a tracked version or revision, or add a tag or branch to ensure the commit does not get removed."
)
)]
RevisionNotOnUpstream(String, String),
#[error("File/Directory not added, ignoring: {cause}")]
#[diagnostic(code(W30))]
IgnoredPath { cause: String },
#[error("File {} doesn't exist.", fmt_path!(path.display()))]
#[diagnostic(code(W31))]
FileMissing { path: PathBuf },
#[error("Path {} for dependency {} does not exist.", fmt_path!(path.display()), fmt_pkg!(pkg))]
#[diagnostic(code(W32))]
DepPathMissing { pkg: String, path: PathBuf },
#[error("Override files in {} does not support additional fields like include_dirs, defines, etc.", fmt_pkg!(.0))]
#[diagnostic(code(W33))]
OverrideFilesWithExtras(String),
#[error("File {} is not a Verilog file and will be ignored in the pickle output.", fmt_path!(.0.display()))]
#[diagnostic(code(W34))]
PickleNonVerilogFile(PathBuf),
#[error("Source group in package {} uses `override_files`, which is not supported in the simplified source output and will be ignored.", fmt_pkg!(.0))]
#[diagnostic(code(W35))]
OverrideFilesIgnored(String),
}
#[derive(Error, Diagnostic, Debug, Clone)]
#[diagnostic(severity(Error))]
pub enum Errors {
#[error("Include directory {} doesn't exist.", fmt_path!(.0.display()))]
#[diagnostic(
code(E24),
help("Please check that the include directory exists and is correct.")
)]
IncludeDirMissing(PathBuf),
#[error("File/Directory not added, ignoring: {cause}")]
#[diagnostic(code(E30))]
IgnoredPath { cause: String },
#[error("File {} doesn't exist.", fmt_path!(path.display()))]
#[diagnostic(code(E31))]
FileMissing { path: PathBuf },
#[error("Path {} for dependency {} does not exist.", fmt_path!(path.display()), fmt_pkg!(pkg))]
#[diagnostic(code(E32))]
DepPathMissing { pkg: String, path: PathBuf },
}
impl Errors {
fn downgrade(self) -> Warnings {
match self {
Errors::IncludeDirMissing(path) => Warnings::IncludeDirMissing(path),
Errors::IgnoredPath { cause } => Warnings::IgnoredPath { cause },
Errors::FileMissing { path } => Warnings::FileMissing { path },
Errors::DepPathMissing { pkg, path } => Warnings::DepPathMissing { pkg, path },
}
}
pub fn downgrade_if_suppressed(self) -> miette::Result<()> {
let error_code = self
.code()
.expect("All error diagnostics must define a code")
.to_string();
if Diagnostics::is_suppressed(&error_code) {
self.downgrade().emit();
Ok(())
} else {
Err(self.into())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, Once};
static TEST_INIT: Once = Once::new();
static TEST_LOCK: Mutex<()> = Mutex::new(());
fn setup_diagnostics() {
TEST_INIT.call_once(|| {
Diagnostics::init(HashSet::from(["W02".to_string(), "E30".to_string()]));
});
}
fn with_test_lock(f: impl FnOnce()) {
let _guard = TEST_LOCK.lock().unwrap();
setup_diagnostics();
Diagnostics::get().emitted.lock().unwrap().clear();
f();
}
#[test]
fn test_is_suppressed() {
with_test_lock(|| {
assert!(Diagnostics::is_suppressed("W02"));
assert!(!Diagnostics::is_suppressed("W01"));
});
}
#[test]
fn test_suppression_works() {
with_test_lock(|| {
let diag = Diagnostics::get();
let warn = Warnings::UsingConfigForOverride {
path: PathBuf::from("/example/path"),
};
warn.clone().emit();
let emitted = diag.emitted.lock().unwrap();
assert!(!emitted.contains(&warn));
});
}
#[test]
fn test_all_suppressed() {
with_test_lock(|| {
let diag = Diagnostics {
suppressed: HashSet::new(),
all_suppressed: true,
emitted: Mutex::new(HashSet::new()),
multiprogress: Mutex::new(None),
};
let warn = Warnings::LocalNoFetch;
let code = warn.code().unwrap().to_string();
assert!(diag.all_suppressed || diag.suppressed.contains(&code));
});
}
#[test]
fn test_deduplication_logic() {
with_test_lock(|| {
let diag = Diagnostics::get();
let warn1 = Warnings::NoRevisionInLockFile {
pkg: "example_pkg".into(),
};
let warn2 = Warnings::NoRevisionInLockFile {
pkg: "other_pkg".into(),
};
warn1.clone().emit();
{
let emitted = diag.emitted.lock().unwrap();
assert!(emitted.contains(&warn1));
assert_eq!(emitted.len(), 1);
}
warn2.clone().emit();
{
let emitted = diag.emitted.lock().unwrap();
assert!(emitted.contains(&warn2));
assert_eq!(emitted.len(), 2);
}
warn1.clone().emit();
{
let emitted = diag.emitted.lock().unwrap();
assert_eq!(emitted.len(), 2);
}
});
}
#[test]
fn test_emit_or_error_suppressed() {
with_test_lock(|| {
let diag = Diagnostics::get();
let error = Errors::IgnoredPath {
cause: "bad env var".to_string(),
};
assert!(error.clone().downgrade_if_suppressed().is_ok());
let emitted = diag.emitted.lock().unwrap();
assert!(emitted.contains(&Warnings::IgnoredPath {
cause: "bad env var".to_string(),
}));
});
}
#[test]
fn test_stderr_contains_caused_by_chain() {
setup_diagnostics();
let report = format!(
"{:?}",
miette::miette!("root cause").wrap_err("outer context")
);
assert!(report.contains("outer context"));
assert!(report.contains("caused by: root cause"));
}
#[test]
fn test_emit_or_error_not_suppressed() {
with_test_lock(|| {
let error = Errors::FileMissing {
path: PathBuf::from("/definitely/missing/file.sv"),
};
let err = error.downgrade_if_suppressed().expect_err("expected error");
let report = format!("{err:?}");
assert!(report.contains("E31"));
assert!(report.contains("doesn't exist"));
});
}
}