Skip to main content

xdoc/core/
names.rs

1use std::fmt;
2
3use super::{ErrorKind, XmlError, XmlResult};
4
5pub const XML_NAMESPACE_URI: &str = "http://www.w3.org/XML/1998/namespace";
6pub const XMLNS_NAMESPACE_URI: &str = "http://www.w3.org/2000/xmlns/";
7
8/// Namespace URI associated with a qualified XML name.
9#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
10pub struct NamespaceUri(String);
11
12impl NamespaceUri {
13    pub fn new(uri: impl Into<String>) -> XmlResult<Self> {
14        let uri = uri.into();
15        if uri.is_empty() {
16            return Err(XmlError::new(
17                ErrorKind::InvalidNamespace,
18                "namespace URI cannot be empty",
19            ));
20        }
21
22        Ok(Self(uri))
23    }
24
25    pub fn as_str(&self) -> &str {
26        &self.0
27    }
28}
29
30impl fmt::Display for NamespaceUri {
31    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32        f.write_str(&self.0)
33    }
34}
35
36impl TryFrom<&str> for NamespaceUri {
37    type Error = XmlError;
38
39    fn try_from(value: &str) -> Result<Self, Self::Error> {
40        Self::new(value)
41    }
42}
43
44impl TryFrom<String> for NamespaceUri {
45    type Error = XmlError;
46
47    fn try_from(value: String) -> Result<Self, Self::Error> {
48        Self::new(value)
49    }
50}
51
52/// Prefix used to bind an XML name to a namespace URI.
53#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
54pub struct NamespacePrefix(String);
55
56impl NamespacePrefix {
57    pub fn new(prefix: impl Into<String>) -> XmlResult<Self> {
58        let prefix = prefix.into();
59        validate_xml_name(&prefix)?;
60        if prefix == "xmlns" {
61            return Err(XmlError::new(
62                ErrorKind::InvalidNamespace,
63                "namespace prefix `xmlns` is reserved",
64            ));
65        }
66
67        Ok(Self(prefix))
68    }
69
70    pub fn as_str(&self) -> &str {
71        &self.0
72    }
73}
74
75impl fmt::Display for NamespacePrefix {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        f.write_str(&self.0)
78    }
79}
80
81impl TryFrom<&str> for NamespacePrefix {
82    type Error = XmlError;
83
84    fn try_from(value: &str) -> Result<Self, Self::Error> {
85        Self::new(value)
86    }
87}
88
89impl TryFrom<String> for NamespacePrefix {
90    type Error = XmlError;
91
92    fn try_from(value: String) -> Result<Self, Self::Error> {
93        Self::new(value)
94    }
95}
96
97/// Namespace-aware XML name.
98#[derive(Debug, Clone, PartialEq, Eq, Hash)]
99pub struct QName {
100    prefix: Option<NamespacePrefix>,
101    local: String,
102    namespace_uri: Option<NamespaceUri>,
103}
104
105impl QName {
106    /// Creates an unqualified XML name.
107    pub fn new(local: impl Into<String>) -> XmlResult<Self> {
108        let local = local.into();
109        validate_xml_name(&local)?;
110
111        Ok(Self {
112            prefix: None,
113            local,
114            namespace_uri: None,
115        })
116    }
117
118    /// Creates a name with explicit namespace prefix and URI.
119    pub fn qualified(
120        prefix: impl Into<String>,
121        local: impl Into<String>,
122        namespace_uri: impl Into<String>,
123    ) -> XmlResult<Self> {
124        let prefix = prefix.into();
125        let local = local.into();
126        let namespace_uri = namespace_uri.into();
127        validate_namespace_binding(Some(&prefix), &namespace_uri)?;
128        let prefix = NamespacePrefix::new(prefix)?;
129        validate_xml_name(&local)?;
130        let namespace_uri = NamespaceUri::new(namespace_uri)?;
131
132        Ok(Self {
133            prefix: Some(prefix),
134            local,
135            namespace_uri: Some(namespace_uri),
136        })
137    }
138
139    /// Creates a name in a default namespace without assigning a prefix.
140    pub fn namespaced(
141        local: impl Into<String>,
142        namespace_uri: impl Into<String>,
143    ) -> XmlResult<Self> {
144        let local = local.into();
145        let namespace_uri = namespace_uri.into();
146        validate_namespace_binding(None, &namespace_uri)?;
147        validate_xml_name(&local)?;
148
149        Ok(Self {
150            prefix: None,
151            local,
152            namespace_uri: Some(NamespaceUri::new(namespace_uri)?),
153        })
154    }
155
156    pub fn prefix(&self) -> Option<&NamespacePrefix> {
157        self.prefix.as_ref()
158    }
159
160    pub fn local(&self) -> &str {
161        &self.local
162    }
163
164    pub fn namespace_uri(&self) -> Option<&NamespaceUri> {
165        self.namespace_uri.as_ref()
166    }
167
168    pub fn lexical_name(&self) -> String {
169        match &self.prefix {
170            Some(prefix) => format!("{}:{}", prefix.as_str(), self.local),
171            None => self.local.clone(),
172        }
173    }
174}
175
176impl fmt::Display for QName {
177    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178        f.write_str(&self.lexical_name())
179    }
180}
181
182/// Validates the XML name subset used by this MVP.
183///
184/// The validator intentionally keeps the first implementation conservative:
185/// ASCII letters and `_` may start a name; ASCII letters, digits, `_`, `-`, and
186/// `.` may continue it. Namespaces are represented structurally, so `:` is not
187/// accepted inside a local name or prefix.
188pub fn validate_xml_name(name: &str) -> XmlResult<()> {
189    if name.is_empty() {
190        return Err(XmlError::invalid_name(name, "name cannot be empty"));
191    }
192
193    let mut chars = name.chars();
194    let first = chars.next().expect("name is not empty");
195    if !is_name_start_char(first) {
196        return Err(XmlError::invalid_name(
197            name,
198            "name must start with an ASCII letter or underscore",
199        ));
200    }
201
202    if let Some(invalid) = chars.find(|ch| !is_name_char(*ch)) {
203        return Err(XmlError::invalid_name(
204            name,
205            format!("character `{invalid}` is not allowed"),
206        ));
207    }
208
209    Ok(())
210}
211
212pub fn validate_namespace_binding(prefix: Option<&str>, uri: &str) -> XmlResult<()> {
213    match prefix {
214        Some("xml") if uri == XML_NAMESPACE_URI => Ok(()),
215        Some("xml") => Err(XmlError::new(
216            ErrorKind::InvalidNamespace,
217            "namespace prefix `xml` must be bound to the XML namespace URI",
218        )),
219        Some("xmlns") => Err(XmlError::new(
220            ErrorKind::InvalidNamespace,
221            "namespace prefix `xmlns` cannot be declared or used as a qualified name prefix",
222        )),
223        Some(_) | None if uri == XML_NAMESPACE_URI => Err(XmlError::new(
224            ErrorKind::InvalidNamespace,
225            "the XML namespace URI can only be bound to prefix `xml`",
226        )),
227        Some(_) | None if uri == XMLNS_NAMESPACE_URI => Err(XmlError::new(
228            ErrorKind::InvalidNamespace,
229            "the XMLNS namespace URI cannot be declared explicitly",
230        )),
231        _ => Ok(()),
232    }
233}
234
235fn is_name_start_char(ch: char) -> bool {
236    ch == '_' || ch.is_ascii_alphabetic()
237}
238
239fn is_name_char(ch: char) -> bool {
240    is_name_start_char(ch) || ch.is_ascii_digit() || matches!(ch, '-' | '.')
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn qname_new_creates_unqualified_name() {
249        let name = QName::new("Invoice").expect("valid name");
250
251        assert_eq!(name.local(), "Invoice");
252        assert_eq!(name.prefix(), None);
253        assert_eq!(name.namespace_uri(), None);
254        assert_eq!(name.lexical_name(), "Invoice");
255    }
256
257    #[test]
258    fn qname_qualified_creates_prefixed_name() {
259        let name = QName::qualified("cbc", "ID", "urn:example:cbc").expect("valid name");
260
261        assert_eq!(name.prefix().map(NamespacePrefix::as_str), Some("cbc"));
262        assert_eq!(name.local(), "ID");
263        assert_eq!(
264            name.namespace_uri().map(NamespaceUri::as_str),
265            Some("urn:example:cbc")
266        );
267        assert_eq!(name.lexical_name(), "cbc:ID");
268    }
269
270    #[test]
271    fn qname_rejects_empty_local_name() {
272        let error = QName::new("").expect_err("empty local name must fail");
273
274        assert_eq!(error.kind(), &ErrorKind::InvalidName);
275    }
276
277    #[test]
278    fn qname_rejects_empty_prefix() {
279        let error = QName::qualified("", "ID", "urn:example").expect_err("empty prefix must fail");
280
281        assert_eq!(error.kind(), &ErrorKind::InvalidName);
282    }
283
284    #[test]
285    fn qname_rejects_colon_inside_structural_name_parts() {
286        let error = QName::new("cbc:ID").expect_err("colon must be structural");
287
288        assert_eq!(error.kind(), &ErrorKind::InvalidName);
289    }
290
291    #[test]
292    fn qname_accepts_reserved_xml_prefix_with_reserved_uri() {
293        let name = QName::qualified("xml", "lang", XML_NAMESPACE_URI).expect("xml name");
294
295        assert_eq!(name.lexical_name(), "xml:lang");
296        assert_eq!(
297            name.namespace_uri().map(NamespaceUri::as_str),
298            Some(XML_NAMESPACE_URI)
299        );
300    }
301
302    #[test]
303    fn qname_rejects_reserved_namespace_misuse() {
304        assert_eq!(
305            QName::qualified("xml", "lang", "urn:wrong")
306                .expect_err("xml prefix must use reserved URI")
307                .kind(),
308            &ErrorKind::InvalidNamespace
309        );
310        assert_eq!(
311            QName::qualified("doc", "ID", XML_NAMESPACE_URI)
312                .expect_err("xml URI must only use xml prefix")
313                .kind(),
314            &ErrorKind::InvalidNamespace
315        );
316        assert_eq!(
317            QName::qualified("xmlns", "ID", XMLNS_NAMESPACE_URI)
318                .expect_err("xmlns prefix is reserved")
319                .kind(),
320            &ErrorKind::InvalidNamespace
321        );
322        assert_eq!(
323            QName::namespaced("Root", XML_NAMESPACE_URI)
324                .expect_err("xml URI cannot be default namespace")
325                .kind(),
326            &ErrorKind::InvalidNamespace
327        );
328    }
329
330    #[test]
331    fn core_validates_basic_xml_names() {
332        assert!(validate_xml_name("_valid-Name.1").is_ok());
333        assert!(validate_xml_name("1invalid").is_err());
334        assert!(validate_xml_name("invalid name").is_err());
335    }
336}