#[derive(Debug, Clone, PartialEq, Eq, Hash, thiserror::Error)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub enum Warning {
#[error("input exceeded {limit_bytes} bytes and was truncated")]
InputTruncated {
limit_bytes: usize,
},
#[error("{count} control character(s) were replaced with U+FFFD")]
ControlCharactersSanitized {
count: u32,
},
#[error("a table was not closed with `|}}` and was auto-closed at end of input")]
UnclosedTable,
#[error("extension tag <{name}> was not closed; its content runs to end of input")]
UnclosedExtensionTag {
name: String,
},
#[error("disallowed HTML tag <{name}> was treated as literal text")]
DisallowedHtmlTag {
name: String,
},
#[error("`[[{target}]]` is not a valid link target and stayed literal")]
InvalidLinkTarget {
target: String,
},
#[error("Template loop detected: [[:{title}]]")]
TemplateLoop {
title: String,
},
#[error("Page exceeded the expansion depth (limit {limit})")]
ExpansionDepthExceeded {
limit: u16,
},
#[error("Page exceeded the node-count limit ({limit})")]
NodeCountExceeded {
limit: u64,
},
#[error("Post-expand include size exceeded the limit ({limit} bytes)")]
PostExpandSizeExceeded {
limit: usize,
},
#[error(
"Template [[:{template}]] is calling itself with a duplicate value for the \"{name}\" argument"
)]
DuplicateArgument {
template: String,
name: String,
},
#[error("Too many expensive parser-function calls (limit {limit})")]
ExpensiveFunctionLimit {
limit: u32,
},
#[error("`{{{{#invoke:}}}}` of [[:{module}]] was not expanded (Lua is unsupported)")]
UnsupportedModuleInvocation {
module: String,
},
#[error("unsupported parser function `{name}` was left unexpanded")]
UnsupportedParserFunction {
name: String,
},
#[error("unsupported `{{{{#time:}}}}` format code was left literal")]
UnsupportedTimeFormat,
#[error("existence of [[:{title}]] is unknown; `{{{{#ifexist:}}}}` took the false branch")]
ExistenceUnknown {
title: String,
},
}
impl Warning {
#[must_use]
pub fn code(&self) -> Option<&'static str> {
match self {
Warning::TemplateLoop { .. } => Some("parser-template-loop-warning"),
Warning::ExpansionDepthExceeded { .. } => Some("expansion-depth-exceeded-warning"),
Warning::NodeCountExceeded { .. } => Some("node-count-exceeded-warning"),
Warning::PostExpandSizeExceeded { .. } => {
Some("post-expand-template-inclusion-warning")
}
Warning::DuplicateArgument { .. } => Some("duplicate-args-warning"),
Warning::ExpensiveFunctionLimit { .. } => Some("expensive-parserfunction-warning"),
Warning::InputTruncated { .. }
| Warning::ControlCharactersSanitized { .. }
| Warning::UnclosedTable
| Warning::UnclosedExtensionTag { .. }
| Warning::DisallowedHtmlTag { .. }
| Warning::InvalidLinkTarget { .. }
| Warning::UnsupportedModuleInvocation { .. }
| Warning::UnsupportedParserFunction { .. }
| Warning::UnsupportedTimeFormat
| Warning::ExistenceUnknown { .. } => None,
}
}
}
#[derive(Debug, Default)]
pub(crate) struct WarningCollector {
items: Vec<Warning>,
seen: std::collections::HashSet<Warning>,
}
const MAX_WARNINGS: usize = 200;
impl WarningCollector {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn emit(&mut self, warning: Warning) {
if self.items.len() >= MAX_WARNINGS || !self.seen.insert(warning.clone()) {
return;
}
self.items.push(warning);
}
#[allow(dead_code, reason = "consumed by the expansion-stage limit checks")]
pub(crate) fn as_slice(&self) -> &[Warning] {
&self.items
}
pub(crate) fn into_vec(self) -> Vec<Warning> {
self.items
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn diagnostic_warnings_render_and_report_no_parity_code() {
let warnings = [
Warning::InputTruncated { limit_bytes: 4096 },
Warning::ControlCharactersSanitized { count: 3 },
Warning::UnclosedTable,
Warning::UnclosedExtensionTag {
name: "ref".to_owned(),
},
Warning::DisallowedHtmlTag {
name: "script".to_owned(),
},
Warning::InvalidLinkTarget {
target: "a<b".to_owned(),
},
Warning::UnsupportedModuleInvocation {
module: "Module:X".to_owned(),
},
Warning::UnsupportedParserFunction {
name: "#foo".to_owned(),
},
Warning::UnsupportedTimeFormat,
Warning::ExistenceUnknown {
title: "Foo".to_owned(),
},
];
for warning in &warnings {
assert!(!warning.to_string().is_empty());
assert_eq!(warning.code(), None, "{warning:?}");
}
assert!(
Warning::UnclosedTable.to_string().contains("|}"),
"escaped brace renders literally"
);
}
#[test]
fn parity_warnings_render_and_report_their_message_key() {
let cases = [
(
Warning::TemplateLoop {
title: "Template:A".to_owned(),
},
"parser-template-loop-warning",
"Template loop detected: [[:Template:A]]",
),
(
Warning::ExpansionDepthExceeded { limit: 100 },
"expansion-depth-exceeded-warning",
"Page exceeded the expansion depth (limit 100)",
),
(
Warning::NodeCountExceeded { limit: 1_000_000 },
"node-count-exceeded-warning",
"Page exceeded the node-count limit (1000000)",
),
(
Warning::PostExpandSizeExceeded { limit: 2_097_152 },
"post-expand-template-inclusion-warning",
"Post-expand include size exceeded the limit (2097152 bytes)",
),
(
Warning::DuplicateArgument {
template: "Template:Cite".to_owned(),
name: "date".to_owned(),
},
"duplicate-args-warning",
"\"date\" argument",
),
(
Warning::ExpensiveFunctionLimit { limit: 500 },
"expensive-parserfunction-warning",
"expensive parser-function calls (limit 500)",
),
];
for (warning, key, needle) in cases {
assert_eq!(warning.code(), Some(key), "{warning:?}");
assert!(
warning.to_string().contains(needle),
"{warning:?} → {}",
warning
);
}
}
#[test]
fn collector_dedupes_and_keeps_first_emission_order() {
let mut collector = WarningCollector::new();
collector.emit(Warning::UnclosedTable);
collector.emit(Warning::InputTruncated { limit_bytes: 10 });
collector.emit(Warning::UnclosedTable);
collector.emit(Warning::InputTruncated { limit_bytes: 20 });
let slice = collector.as_slice();
assert_eq!(
slice.len(),
3,
"exact duplicates collapse, distinct payloads stay"
);
assert_eq!(slice[0], Warning::UnclosedTable);
assert_eq!(slice[1], Warning::InputTruncated { limit_bytes: 10 });
assert_eq!(slice[2], Warning::InputTruncated { limit_bytes: 20 });
assert_eq!(collector.into_vec().len(), 3);
}
#[test]
fn collector_stops_at_the_cap() {
let mut collector = WarningCollector::new();
for i in 0..(MAX_WARNINGS + 50) {
collector.emit(Warning::ControlCharactersSanitized {
count: u32::try_from(i).unwrap(),
});
}
assert_eq!(collector.as_slice().len(), MAX_WARNINGS);
let debug = format!("{collector:?}");
assert!(debug.contains("WarningCollector"));
}
#[cfg(feature = "serde")]
#[test]
fn warnings_serialize() {
let json = serde_json::to_string(&Warning::UnclosedTable).unwrap();
assert!(json.contains("UnclosedTable"), "{json}");
}
}