#![forbid(unsafe_code)]
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Warning {
pub category: WarningCategory,
pub page: Option<usize>,
pub message: String,
pub spec_section: Option<&'static str>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum WarningCategory {
SpecViolation,
ToUnicodeMissing,
XrefRecovery,
OperatorCapExceeded,
Type3Font,
EofPremature,
Encryption,
Font,
Layout,
GlyphDropped,
NoTextLayer,
ImageSuppressed,
}
impl WarningCategory {
pub fn as_str(&self) -> &'static str {
match self {
Self::SpecViolation => "spec_violation",
Self::ToUnicodeMissing => "to_unicode_missing",
Self::XrefRecovery => "xref_recovery",
Self::OperatorCapExceeded => "operator_cap_exceeded",
Self::Type3Font => "type3_font",
Self::EofPremature => "eof_premature",
Self::Encryption => "encryption",
Self::Font => "font",
Self::Layout => "layout",
Self::GlyphDropped => "glyph_dropped",
Self::NoTextLayer => "no_text_layer",
Self::ImageSuppressed => "image_suppressed",
}
}
}
#[derive(Debug, Default)]
pub struct WarningSink {
warnings: Mutex<Vec<Warning>>,
}
const MAX_SINK_ENTRIES: usize = 1000;
thread_local! {
static WARNING_SINK: std::cell::RefCell<Vec<Warning>> =
const { std::cell::RefCell::new(Vec::new()) };
static DROPPED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
pub fn push_global_warning(warning: Warning) {
WARNING_SINK.with(|sink| {
let mut v = sink.borrow_mut();
if v.len() >= MAX_SINK_ENTRIES {
DROPPED.with(|d| d.set(d.get() + 1));
return;
}
if let Some(last) = v.iter_mut().rev().take(16).find(|w| {
w.category == warning.category && w.page == warning.page && w.message == warning.message
}) {
let _ = last;
return;
}
v.push(warning);
});
}
pub fn drain_global_warnings() -> Vec<Warning> {
let mut out = WARNING_SINK.with(|sink| std::mem::take(&mut *sink.borrow_mut()));
let dropped = DROPPED.with(|d| d.replace(0));
if dropped > 0 {
out.push(Warning {
category: WarningCategory::SpecViolation,
page: None,
message: format!(
"{dropped} further diagnostics were dropped after the {MAX_SINK_ENTRIES}-entry cap"
),
spec_section: None,
});
}
out
}
pub fn snapshot_global_warnings() -> Vec<Warning> {
WARNING_SINK.with(|sink| sink.borrow().clone())
}
pub(crate) fn restore_global_warnings(mut warnings: Vec<Warning>) {
if warnings.is_empty() {
return;
}
WARNING_SINK.with(|sink| {
let mut v = sink.borrow_mut();
warnings.append(&mut v);
*v = warnings;
});
}
impl WarningSink {
pub fn new() -> Self {
Self::default()
}
pub fn push(&self, warning: Warning) {
if let Ok(mut v) = self.warnings.lock() {
v.push(warning);
}
}
pub fn snapshot(&self) -> Vec<Warning> {
self.warnings.lock().map(|v| v.clone()).unwrap_or_default()
}
pub fn len(&self) -> usize {
self.warnings.lock().map(|v| v.len()).unwrap_or(0)
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn clear(&self) {
if let Ok(mut v) = self.warnings.lock() {
v.clear();
}
}
pub fn extend(&self, warnings: impl IntoIterator<Item = Warning>) {
if let Ok(mut v) = self.warnings.lock() {
v.extend(warnings);
}
}
pub fn take(&self) -> Vec<Warning> {
if let Ok(mut v) = self.warnings.lock() {
std::mem::take(&mut *v)
} else {
Vec::new()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sink_starts_empty() {
let sink = WarningSink::new();
assert!(sink.is_empty());
assert_eq!(sink.len(), 0);
assert_eq!(sink.snapshot().len(), 0);
}
#[test]
fn push_and_snapshot() {
let sink = WarningSink::new();
sink.push(Warning {
category: WarningCategory::ToUnicodeMissing,
page: Some(0),
message: "Type0 font 'X' has no ToUnicode entry!".into(),
spec_section: Some("9.10.2"),
});
assert_eq!(sink.len(), 1);
let snap = sink.snapshot();
assert_eq!(snap[0].category, WarningCategory::ToUnicodeMissing);
assert_eq!(snap[0].page, Some(0));
assert!(snap[0].message.contains("ToUnicode"));
}
#[test]
fn category_as_str_stable() {
assert_eq!(WarningCategory::SpecViolation.as_str(), "spec_violation");
assert_eq!(WarningCategory::ToUnicodeMissing.as_str(), "to_unicode_missing");
assert_eq!(WarningCategory::OperatorCapExceeded.as_str(), "operator_cap_exceeded");
}
#[test]
fn clear_resets() {
let sink = WarningSink::new();
sink.push(Warning {
category: WarningCategory::SpecViolation,
page: None,
message: "x".into(),
spec_section: None,
});
assert_eq!(sink.len(), 1);
sink.clear();
assert!(sink.is_empty());
}
#[test]
fn warning_serializes_to_json() {
let w = Warning {
category: WarningCategory::SpecViolation,
page: Some(0),
message: "No newline after stream keyword".into(),
spec_section: Some("7.3.8.1"),
};
let json = serde_json::to_string(&w).unwrap();
assert!(json.contains("\"category\":\"spec_violation\""));
assert!(json.contains("\"page\":0"));
assert!(json.contains("\"spec_section\":\"7.3.8.1\""));
}
#[test]
fn sink_thread_safe() {
use std::sync::Arc;
use std::thread;
let sink = Arc::new(WarningSink::new());
let mut handles = Vec::new();
for i in 0..10 {
let s = sink.clone();
handles.push(thread::spawn(move || {
s.push(Warning {
category: WarningCategory::Font,
page: Some(i),
message: format!("font warning {}", i),
spec_section: None,
});
}));
}
for h in handles {
h.join().unwrap();
}
assert_eq!(sink.len(), 10);
}
}
#[cfg(test)]
mod sink_scope_tests {
use super::*;
fn w(msg: &str) -> Warning {
Warning {
category: WarningCategory::SpecViolation,
page: None,
message: msg.to_string(),
spec_section: None,
}
}
#[test]
fn one_thread_does_not_drain_anothers_warnings() {
let _ = drain_global_warnings();
push_global_warning(w("belongs to the main thread"));
let other = std::thread::spawn(|| {
push_global_warning(w("belongs to the spawned thread"));
drain_global_warnings()
})
.join()
.expect("thread");
assert_eq!(other.len(), 1, "the other thread saw {other:?}");
assert_eq!(other[0].message, "belongs to the spawned thread");
let mine = drain_global_warnings();
assert_eq!(mine.len(), 1, "this thread saw {mine:?}");
assert_eq!(mine[0].message, "belongs to the main thread");
}
#[test]
fn test_identical_warning_is_not_recorded_repeatedly() {
let _ = drain_global_warnings();
for _ in 0..50 {
push_global_warning(w("one malformed font, warned per glyph"));
}
assert_eq!(drain_global_warnings().len(), 1);
}
#[test]
fn test_sink_is_bounded_and_reports_what_it_dropped() {
let _ = drain_global_warnings();
for i in 0..MAX_SINK_ENTRIES + 25 {
push_global_warning(w(&format!("distinct {i}")));
}
let out = drain_global_warnings();
assert_eq!(out.len(), MAX_SINK_ENTRIES + 1, "capped, plus one sentinel");
assert!(
out.last()
.expect("sentinel")
.message
.contains("were dropped"),
"the drop must be reported, not silent: {:?}",
out.last()
);
}
}