xdoc-rs 0.1.1

Declarative XML engine for Rust
Documentation
use std::fmt;

use super::{ErrorKind, XmlError, XmlResult};

pub const XML_NAMESPACE_URI: &str = "http://www.w3.org/XML/1998/namespace";
pub const XMLNS_NAMESPACE_URI: &str = "http://www.w3.org/2000/xmlns/";

/// Namespace URI associated with a qualified XML name.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct NamespaceUri(String);

impl NamespaceUri {
    pub fn new(uri: impl Into<String>) -> XmlResult<Self> {
        let uri = uri.into();
        if uri.is_empty() {
            return Err(XmlError::new(
                ErrorKind::InvalidNamespace,
                "namespace URI cannot be empty",
            ));
        }

        Ok(Self(uri))
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl fmt::Display for NamespaceUri {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

impl TryFrom<&str> for NamespaceUri {
    type Error = XmlError;

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        Self::new(value)
    }
}

impl TryFrom<String> for NamespaceUri {
    type Error = XmlError;

    fn try_from(value: String) -> Result<Self, Self::Error> {
        Self::new(value)
    }
}

/// Prefix used to bind an XML name to a namespace URI.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct NamespacePrefix(String);

impl NamespacePrefix {
    pub fn new(prefix: impl Into<String>) -> XmlResult<Self> {
        let prefix = prefix.into();
        validate_xml_name(&prefix)?;
        if prefix == "xmlns" {
            return Err(XmlError::new(
                ErrorKind::InvalidNamespace,
                "namespace prefix `xmlns` is reserved",
            ));
        }

        Ok(Self(prefix))
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl fmt::Display for NamespacePrefix {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

impl TryFrom<&str> for NamespacePrefix {
    type Error = XmlError;

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        Self::new(value)
    }
}

impl TryFrom<String> for NamespacePrefix {
    type Error = XmlError;

    fn try_from(value: String) -> Result<Self, Self::Error> {
        Self::new(value)
    }
}

/// Namespace-aware XML name.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct QName {
    prefix: Option<NamespacePrefix>,
    local: String,
    namespace_uri: Option<NamespaceUri>,
}

impl QName {
    /// Creates an unqualified XML name.
    pub fn new(local: impl Into<String>) -> XmlResult<Self> {
        let local = local.into();
        validate_xml_name(&local)?;

        Ok(Self {
            prefix: None,
            local,
            namespace_uri: None,
        })
    }

    /// Creates a name with explicit namespace prefix and URI.
    pub fn qualified(
        prefix: impl Into<String>,
        local: impl Into<String>,
        namespace_uri: impl Into<String>,
    ) -> XmlResult<Self> {
        let prefix = prefix.into();
        let local = local.into();
        let namespace_uri = namespace_uri.into();
        validate_namespace_binding(Some(&prefix), &namespace_uri)?;
        let prefix = NamespacePrefix::new(prefix)?;
        validate_xml_name(&local)?;
        let namespace_uri = NamespaceUri::new(namespace_uri)?;

        Ok(Self {
            prefix: Some(prefix),
            local,
            namespace_uri: Some(namespace_uri),
        })
    }

    /// Creates a name in a default namespace without assigning a prefix.
    pub fn namespaced(
        local: impl Into<String>,
        namespace_uri: impl Into<String>,
    ) -> XmlResult<Self> {
        let local = local.into();
        let namespace_uri = namespace_uri.into();
        validate_namespace_binding(None, &namespace_uri)?;
        validate_xml_name(&local)?;

        Ok(Self {
            prefix: None,
            local,
            namespace_uri: Some(NamespaceUri::new(namespace_uri)?),
        })
    }

    pub fn prefix(&self) -> Option<&NamespacePrefix> {
        self.prefix.as_ref()
    }

    pub fn local(&self) -> &str {
        &self.local
    }

    pub fn namespace_uri(&self) -> Option<&NamespaceUri> {
        self.namespace_uri.as_ref()
    }

    pub fn lexical_name(&self) -> String {
        match &self.prefix {
            Some(prefix) => format!("{}:{}", prefix.as_str(), self.local),
            None => self.local.clone(),
        }
    }
}

impl fmt::Display for QName {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.lexical_name())
    }
}

/// Validates the XML name subset used by this MVP.
///
/// The validator intentionally keeps the first implementation conservative:
/// ASCII letters and `_` may start a name; ASCII letters, digits, `_`, `-`, and
/// `.` may continue it. Namespaces are represented structurally, so `:` is not
/// accepted inside a local name or prefix.
pub fn validate_xml_name(name: &str) -> XmlResult<()> {
    if name.is_empty() {
        return Err(XmlError::invalid_name(name, "name cannot be empty"));
    }

    let mut chars = name.chars();
    let first = chars.next().expect("name is not empty");
    if !is_name_start_char(first) {
        return Err(XmlError::invalid_name(
            name,
            "name must start with an ASCII letter or underscore",
        ));
    }

    if let Some(invalid) = chars.find(|ch| !is_name_char(*ch)) {
        return Err(XmlError::invalid_name(
            name,
            format!("character `{invalid}` is not allowed"),
        ));
    }

    Ok(())
}

pub fn validate_namespace_binding(prefix: Option<&str>, uri: &str) -> XmlResult<()> {
    match prefix {
        Some("xml") if uri == XML_NAMESPACE_URI => Ok(()),
        Some("xml") => Err(XmlError::new(
            ErrorKind::InvalidNamespace,
            "namespace prefix `xml` must be bound to the XML namespace URI",
        )),
        Some("xmlns") => Err(XmlError::new(
            ErrorKind::InvalidNamespace,
            "namespace prefix `xmlns` cannot be declared or used as a qualified name prefix",
        )),
        Some(_) | None if uri == XML_NAMESPACE_URI => Err(XmlError::new(
            ErrorKind::InvalidNamespace,
            "the XML namespace URI can only be bound to prefix `xml`",
        )),
        Some(_) | None if uri == XMLNS_NAMESPACE_URI => Err(XmlError::new(
            ErrorKind::InvalidNamespace,
            "the XMLNS namespace URI cannot be declared explicitly",
        )),
        _ => Ok(()),
    }
}

fn is_name_start_char(ch: char) -> bool {
    ch == '_' || ch.is_ascii_alphabetic()
}

fn is_name_char(ch: char) -> bool {
    is_name_start_char(ch) || ch.is_ascii_digit() || matches!(ch, '-' | '.')
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn qname_new_creates_unqualified_name() {
        let name = QName::new("Invoice").expect("valid name");

        assert_eq!(name.local(), "Invoice");
        assert_eq!(name.prefix(), None);
        assert_eq!(name.namespace_uri(), None);
        assert_eq!(name.lexical_name(), "Invoice");
    }

    #[test]
    fn qname_qualified_creates_prefixed_name() {
        let name = QName::qualified("cbc", "ID", "urn:example:cbc").expect("valid name");

        assert_eq!(name.prefix().map(NamespacePrefix::as_str), Some("cbc"));
        assert_eq!(name.local(), "ID");
        assert_eq!(
            name.namespace_uri().map(NamespaceUri::as_str),
            Some("urn:example:cbc")
        );
        assert_eq!(name.lexical_name(), "cbc:ID");
    }

    #[test]
    fn qname_rejects_empty_local_name() {
        let error = QName::new("").expect_err("empty local name must fail");

        assert_eq!(error.kind(), &ErrorKind::InvalidName);
    }

    #[test]
    fn qname_rejects_empty_prefix() {
        let error = QName::qualified("", "ID", "urn:example").expect_err("empty prefix must fail");

        assert_eq!(error.kind(), &ErrorKind::InvalidName);
    }

    #[test]
    fn qname_rejects_colon_inside_structural_name_parts() {
        let error = QName::new("cbc:ID").expect_err("colon must be structural");

        assert_eq!(error.kind(), &ErrorKind::InvalidName);
    }

    #[test]
    fn qname_accepts_reserved_xml_prefix_with_reserved_uri() {
        let name = QName::qualified("xml", "lang", XML_NAMESPACE_URI).expect("xml name");

        assert_eq!(name.lexical_name(), "xml:lang");
        assert_eq!(
            name.namespace_uri().map(NamespaceUri::as_str),
            Some(XML_NAMESPACE_URI)
        );
    }

    #[test]
    fn qname_rejects_reserved_namespace_misuse() {
        assert_eq!(
            QName::qualified("xml", "lang", "urn:wrong")
                .expect_err("xml prefix must use reserved URI")
                .kind(),
            &ErrorKind::InvalidNamespace
        );
        assert_eq!(
            QName::qualified("doc", "ID", XML_NAMESPACE_URI)
                .expect_err("xml URI must only use xml prefix")
                .kind(),
            &ErrorKind::InvalidNamespace
        );
        assert_eq!(
            QName::qualified("xmlns", "ID", XMLNS_NAMESPACE_URI)
                .expect_err("xmlns prefix is reserved")
                .kind(),
            &ErrorKind::InvalidNamespace
        );
        assert_eq!(
            QName::namespaced("Root", XML_NAMESPACE_URI)
                .expect_err("xml URI cannot be default namespace")
                .kind(),
            &ErrorKind::InvalidNamespace
        );
    }

    #[test]
    fn core_validates_basic_xml_names() {
        assert!(validate_xml_name("_valid-Name.1").is_ok());
        assert!(validate_xml_name("1invalid").is_err());
        assert!(validate_xml_name("invalid name").is_err());
    }
}