wikioxide 0.1.0

MediaWiki wikitext parser producing typed domain models (templates, links, categories, warnings) with template expansion — no HTML, no network
Documentation
//! Typed parser warnings.
//!
//! The parser never fails on wikitext — anything irregular becomes a [`Warning`].
//! Warnings that correspond to MediaWiki `parsewarnings` entries report their message
//! key via [`Warning::code`]; purely diagnostic warnings return `None` there. The
//! parity-key set (template loops, expansion limits, duplicate arguments, …) arrives
//! together with the expansion engine.

/// One parser warning: machine-readable data with a human-readable `Display`.
#[derive(Debug, Clone, PartialEq, Eq, Hash, thiserror::Error)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub enum Warning {
    /// The input exceeded [`Limits::max_source_bytes`](crate::Limits::max_source_bytes)
    /// and was truncated before parsing.
    #[error("input exceeded {limit_bytes} bytes and was truncated")]
    InputTruncated {
        /// The configured size cap.
        limit_bytes: usize,
    },
    /// Control characters (or the reserved `0x7F` byte) were replaced with U+FFFD.
    #[error("{count} control character(s) were replaced with U+FFFD")]
    ControlCharactersSanitized {
        /// How many characters were replaced.
        count: u32,
    },
    /// A `{|` table was never closed with `|}` and was auto-closed at end of input.
    #[error("a table was not closed with `|}}` and was auto-closed at end of input")]
    UnclosedTable,
    /// An extension tag had no closing tag; its body ran to end of input.
    #[error("extension tag <{name}> was not closed; its content runs to end of input")]
    UnclosedExtensionTag {
        /// Lower-cased tag name.
        name: String,
    },
    /// A disallowed HTML tag (e.g. `<script>`) was rendered as literal text.
    #[error("disallowed HTML tag <{name}> was treated as literal text")]
    DisallowedHtmlTag {
        /// Lower-cased tag name.
        name: String,
    },
    /// A `[[…]]` target was not a valid title; the brackets stayed literal text.
    #[error("`[[{target}]]` is not a valid link target and stayed literal")]
    InvalidLinkTarget {
        /// The invalid target text as written.
        target: String,
    },
    /// A template transcluded itself directly or indirectly; expansion stopped there.
    #[error("Template loop detected: [[:{title}]]")]
    TemplateLoop {
        /// The looping template's title (display form).
        title: String,
    },
    /// Template nesting hit the expansion-depth limit; deeper calls were left unexpanded.
    #[error("Page exceeded the expansion depth (limit {limit})")]
    ExpansionDepthExceeded {
        /// The configured maximum expansion depth.
        limit: u16,
    },
    /// The preprocessor visited more nodes than the limit allowed; the rest of the
    /// page was left unexpanded.
    #[error("Page exceeded the node-count limit ({limit})")]
    NodeCountExceeded {
        /// The configured node-count budget.
        limit: u64,
    },
    /// Transcluded content exceeded the post-expand include-size limit; some templates
    /// were not included.
    #[error("Post-expand include size exceeded the limit ({limit} bytes)")]
    PostExpandSizeExceeded {
        /// The configured post-expand include-size limit, in bytes.
        limit: usize,
    },
    /// A template was called with the same named argument more than once; the last
    /// value was used.
    #[error(
        "Template [[:{template}]] is calling itself with a duplicate value for the \"{name}\" argument"
    )]
    DuplicateArgument {
        /// The calling template's title (display form).
        template: String,
        /// The duplicated argument name.
        name: String,
    },
    /// Too many expensive parser-function calls (e.g. `{{#ifexist:}}`); further calls
    /// took their default branch.
    #[error("Too many expensive parser-function calls (limit {limit})")]
    ExpensiveFunctionLimit {
        /// The configured expensive parser-function budget.
        limit: u32,
    },
    /// A `{{#invoke:}}` was left unexpanded because Scribunto/Lua is not implemented.
    #[error("`{{{{#invoke:}}}}` of [[:{module}]] was not expanded (Lua is unsupported)")]
    UnsupportedModuleInvocation {
        /// The invoked module's title (display form).
        module: String,
    },
    /// A parser function outside the supported set was left literal.
    #[error("unsupported parser function `{name}` was left unexpanded")]
    UnsupportedParserFunction {
        /// The parser-function name as written (e.g. `#foo`).
        name: String,
    },
    /// A `{{#time:}}` format string used a code this implementation does not support.
    #[error("unsupported `{{{{#time:}}}}` format code was left literal")]
    UnsupportedTimeFormat,
    /// `{{#ifexist:}}` had no existence provider to consult and took the false branch.
    #[error("existence of [[:{title}]] is unknown; `{{{{#ifexist:}}}}` took the false branch")]
    ExistenceUnknown {
        /// The queried title (display form).
        title: String,
    },
}

impl Warning {
    /// The MediaWiki `parsewarnings` message key this warning mirrors, when it has an
    /// API-parity equivalent (e.g. `duplicate-args-warning`). Diagnostic warnings —
    /// and warnings specific to this crate — return `None`.
    #[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,
        }
    }
}

/// Collects warnings in first-emission order, deduplicated, with a hard cap.
#[derive(Debug, Default)]
pub(crate) struct WarningCollector {
    items: Vec<Warning>,
    seen: std::collections::HashSet<Warning>,
}

/// More warnings than any sane document produces; a runaway guard, not a tunable.
const MAX_WARNINGS: usize = 200;

impl WarningCollector {
    pub(crate) fn new() -> Self {
        Self::default()
    }

    /// Records a warning unless it is a duplicate or the collector is full.
    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}");
    }
}