Skip to main content

xsd_schema/xpath/functions/
node.rs

1//! XPath 2.0 node functions.
2//!
3//! This module implements node functions from the XPath 2.0 specification:
4//! - fn:name
5//! - fn:local-name
6//! - fn:namespace-uri
7//! - fn:node-name
8//! - fn:nilled
9//! - fn:base-uri
10//! - fn:document-uri
11//! - fn:lang
12//! - fn:root
13//! - fn:id
14
15use crate::ids::NameId;
16use crate::namespace::qname::QualifiedName;
17use crate::namespace::table::NameTable;
18use crate::types::value::{XmlAtomicValue, XmlValue, XmlValueKind};
19use crate::types::XmlTypeCode;
20use crate::xpath::context::DynamicContext;
21use crate::xpath::error::XPathError;
22use crate::xpath::iterator::XmlItem;
23use crate::xpath::{DomNavigator, DomNodeType};
24
25use super::{atomize_to_string_opt, materialize, XPathValue};
26
27// ============================================================================
28// fn:name($arg as node()?) as xs:string
29// ============================================================================
30
31/// Implements fn:name - returns the qualified name of a node.
32///
33/// If no argument, uses context item.
34/// Returns empty string for nodes without names.
35pub fn name<N: DomNavigator>(
36    context: &mut DynamicContext<'_, N>,
37    args: Vec<XPathValue<N>>,
38) -> Result<XPathValue<N>, XPathError> {
39    if args.len() > 1 {
40        return Err(XPathError::wrong_number_of_arguments("name", 1, args.len()));
41    }
42
43    let node = get_node_arg(context, args)?;
44
45    match node {
46        None => Ok(XPathValue::string("")),
47        Some(nav) => Ok(XPathValue::string(nav.name().to_string())),
48    }
49}
50
51// ============================================================================
52// fn:local-name($arg as node()?) as xs:string
53// ============================================================================
54
55/// Implements fn:local-name - returns the local name of a node.
56///
57/// If no argument, uses context item.
58/// Returns empty string for nodes without names.
59pub fn local_name<N: DomNavigator>(
60    context: &mut DynamicContext<'_, N>,
61    args: Vec<XPathValue<N>>,
62) -> Result<XPathValue<N>, XPathError> {
63    if args.len() > 1 {
64        return Err(XPathError::wrong_number_of_arguments(
65            "local-name",
66            1,
67            args.len(),
68        ));
69    }
70
71    let node = get_node_arg(context, args)?;
72
73    match node {
74        None => Ok(XPathValue::string("")),
75        Some(nav) => Ok(XPathValue::string(nav.local_name().to_string())),
76    }
77}
78
79// ============================================================================
80// fn:namespace-uri($arg as node()?) as xs:anyURI
81// ============================================================================
82
83/// Implements fn:namespace-uri - returns the namespace URI of a node.
84///
85/// If no argument, uses context item.
86/// Returns empty anyURI for nodes without namespace.
87pub fn namespace_uri<N: DomNavigator>(
88    context: &mut DynamicContext<'_, N>,
89    args: Vec<XPathValue<N>>,
90) -> Result<XPathValue<N>, XPathError> {
91    if args.len() > 1 {
92        return Err(XPathError::wrong_number_of_arguments(
93            "namespace-uri",
94            1,
95            args.len(),
96        ));
97    }
98
99    let node = get_node_arg(context, args)?;
100
101    match node {
102        None => Ok(XPathValue::from_atomic(any_uri(""))),
103        Some(nav) => Ok(XPathValue::from_atomic(any_uri(nav.namespace_uri()))),
104    }
105}
106
107// ============================================================================
108// fn:node-name($arg as node()?) as xs:QName?
109// ============================================================================
110
111/// Implements fn:node-name - returns the QName of a node.
112///
113/// Element/Attribute: returns QName with prefix, local, namespace
114/// ProcessingInstruction/Namespace: returns QName with target/prefix
115/// Others: returns empty sequence
116pub fn node_name<N: DomNavigator>(
117    context: &mut DynamicContext<'_, N>,
118    args: Vec<XPathValue<N>>,
119) -> Result<XPathValue<N>, XPathError> {
120    if args.len() > 1 {
121        return Err(XPathError::wrong_number_of_arguments(
122            "node-name",
123            1,
124            args.len(),
125        ));
126    }
127
128    let node = get_node_arg(context, args)?;
129
130    match node {
131        None => Ok(XPathValue::Empty),
132        Some(nav) => {
133            let names = context.static_context.names;
134            match nav.node_type() {
135                DomNodeType::Element | DomNodeType::Attribute => {
136                    let local_name = get_or_empty_id(names, nav.local_name());
137                    let namespace_uri = get_opt_id(names, nav.namespace_uri());
138                    let prefix = get_opt_id(names, nav.prefix());
139                    let qname = QualifiedName::new(namespace_uri, local_name, prefix);
140                    Ok(XPathValue::from_atomic(XmlValue::new(
141                        XmlTypeCode::QName,
142                        XmlValueKind::Atomic(XmlAtomicValue::QName(qname)),
143                    )))
144                }
145                DomNodeType::ProcessingInstruction => {
146                    // PI has target as name, no namespace
147                    let local_name = get_or_empty_id(names, nav.name());
148                    let qname = QualifiedName::new(None, local_name, None);
149                    Ok(XPathValue::from_atomic(XmlValue::new(
150                        XmlTypeCode::QName,
151                        XmlValueKind::Atomic(XmlAtomicValue::QName(qname)),
152                    )))
153                }
154                DomNodeType::Namespace => {
155                    // Namespace node: prefix is the name
156                    let local_name = get_or_empty_id(names, nav.local_name());
157                    let qname = QualifiedName::new(None, local_name, None);
158                    Ok(XPathValue::from_atomic(XmlValue::new(
159                        XmlTypeCode::QName,
160                        XmlValueKind::Atomic(XmlAtomicValue::QName(qname)),
161                    )))
162                }
163                // Text, Comment, Document nodes have no name
164                _ => Ok(XPathValue::Empty),
165            }
166        }
167    }
168}
169
170// ============================================================================
171// fn:nilled($arg as node()) as xs:boolean?
172// ============================================================================
173
174/// Implements fn:nilled - returns whether an element is nilled (xsi:nil).
175///
176/// Returns Empty for non-element nodes.
177/// Returns boolean for element nodes (false if no xsi:nil or schema info).
178pub fn nilled<N: DomNavigator>(
179    context: &mut DynamicContext<'_, N>,
180    args: Vec<XPathValue<N>>,
181) -> Result<XPathValue<N>, XPathError> {
182    if args.len() > 1 {
183        return Err(XPathError::wrong_number_of_arguments(
184            "nilled",
185            1,
186            args.len(),
187        ));
188    }
189
190    let node = get_node_arg(context, args)?;
191    let node = match node {
192        None => return Ok(XPathValue::Empty),
193        Some(n) => n,
194    };
195
196    match node.node_type() {
197        DomNodeType::Element => {
198            // Check for xsi:nil attribute
199            let mut nav = node.clone();
200            if nav.move_to_first_attribute() {
201                loop {
202                    if nav.local_name() == "nil"
203                        && nav.namespace_uri() == "http://www.w3.org/2001/XMLSchema-instance"
204                    {
205                        let value = nav.value();
206                        let is_nilled = value == "true" || value == "1";
207                        return Ok(XPathValue::boolean(is_nilled));
208                    }
209                    if !nav.move_to_next_attribute() {
210                        break;
211                    }
212                }
213            }
214            // No xsi:nil attribute found
215            Ok(XPathValue::boolean(false))
216        }
217        _ => Ok(XPathValue::Empty),
218    }
219}
220
221// ============================================================================
222// fn:base-uri($arg as node()?) as xs:anyURI?
223// ============================================================================
224
225/// Implements fn:base-uri - returns the base URI of a node.
226///
227/// Walks ancestor chain for xml:base attributes and resolves against static base URI.
228/// Returns Empty if base URI is not available.
229pub fn base_uri<N: DomNavigator>(
230    context: &mut DynamicContext<'_, N>,
231    args: Vec<XPathValue<N>>,
232) -> Result<XPathValue<N>, XPathError> {
233    if args.len() > 1 {
234        return Err(XPathError::wrong_number_of_arguments(
235            "base-uri",
236            1,
237            args.len(),
238        ));
239    }
240
241    let node = get_node_arg(context, args)?;
242
243    match node {
244        None => Ok(XPathValue::Empty),
245        Some(nav) => {
246            let uri = compute_base_uri(&nav, context.base_uri.as_deref());
247            match uri {
248                Some(u) if !u.is_empty() => Ok(XPathValue::from_atomic(any_uri(u))),
249                _ => Ok(XPathValue::Empty),
250            }
251        }
252    }
253}
254
255// ============================================================================
256// fn:document-uri($arg as node()) as xs:anyURI?
257// ============================================================================
258
259/// Implements fn:document-uri - returns the document URI of a document node.
260///
261/// Only returns a value for Root nodes with a non-empty base URI.
262pub fn document_uri<N: DomNavigator>(
263    context: &mut DynamicContext<'_, N>,
264    args: Vec<XPathValue<N>>,
265) -> Result<XPathValue<N>, XPathError> {
266    if args.len() > 1 {
267        return Err(XPathError::wrong_number_of_arguments(
268            "document-uri",
269            1,
270            args.len(),
271        ));
272    }
273
274    let node = get_node_arg(context, args)?;
275    let node = match node {
276        None => return Ok(XPathValue::Empty),
277        Some(n) => n,
278    };
279
280    match node.node_type() {
281        DomNodeType::Root => {
282            let uri = node.base_uri();
283            if uri.is_empty() {
284                Ok(XPathValue::Empty)
285            } else {
286                Ok(XPathValue::from_atomic(any_uri(uri)))
287            }
288        }
289        _ => Ok(XPathValue::Empty),
290    }
291}
292
293// ============================================================================
294// fn:lang($testlang as xs:string, $node as node()?) as xs:boolean
295// ============================================================================
296
297/// Implements fn:lang - tests whether a node's language matches.
298///
299/// Walks up ancestors to find xml:lang attribute.
300/// Matching is case-insensitive with subtag support ("en" matches "en-US").
301pub fn lang<N: DomNavigator>(
302    context: &mut DynamicContext<'_, N>,
303    mut args: Vec<XPathValue<N>>,
304) -> Result<XPathValue<N>, XPathError> {
305    if args.is_empty() || args.len() > 2 {
306        return Err(XPathError::wrong_number_of_arguments("lang", 1, args.len()));
307    }
308
309    let test_lang = atomize_to_string_opt(args.remove(0))?.unwrap_or_default();
310
311    let node = if args.is_empty() {
312        // Use context item
313        match &context.context_item {
314            Some(XmlItem::Node(n)) => n.clone(),
315            Some(XmlItem::Atomic(_)) => {
316                return Err(XPathError::XPTY0004 {
317                    expected: "node()".to_string(),
318                    found: "atomic value".to_string(),
319                });
320            }
321            None => {
322                return Err(XPathError::XPDY0002 {
323                    message: "Context item is absent".to_string(),
324                });
325            }
326        }
327    } else {
328        let node_arg = args.remove(0);
329        let items = materialize(node_arg);
330        if items.is_empty() {
331            return Ok(XPathValue::boolean(false));
332        }
333        match &items[0] {
334            XmlItem::Node(n) => n.clone(),
335            XmlItem::Atomic(_) => {
336                return Err(XPathError::XPTY0004 {
337                    expected: "node()".to_string(),
338                    found: "atomic value".to_string(),
339                });
340            }
341        }
342    };
343
344    // Find xml:lang in ancestors
345    let node_lang = find_xml_lang(&node);
346
347    let result = match node_lang {
348        Some(lang) => lang_matches(&lang, &test_lang),
349        None => false,
350    };
351
352    Ok(XPathValue::boolean(result))
353}
354
355// ============================================================================
356// fn:root($arg as node()?) as node()?
357// ============================================================================
358
359/// Implements fn:root - returns the root of the tree containing the node.
360///
361/// If no argument, uses context item.
362pub fn root<N: DomNavigator>(
363    context: &mut DynamicContext<'_, N>,
364    args: Vec<XPathValue<N>>,
365) -> Result<XPathValue<N>, XPathError> {
366    if args.len() > 1 {
367        return Err(XPathError::wrong_number_of_arguments("root", 1, args.len()));
368    }
369
370    let node = get_node_arg(context, args)?;
371
372    match node {
373        None => Ok(XPathValue::Empty),
374        Some(mut nav) => {
375            nav.move_to_root();
376            Ok(XPathValue::from_node(nav))
377        }
378    }
379}
380
381// ============================================================================
382// fn:id($arg as xs:string*, $node as node()) as element()*
383// ============================================================================
384
385/// Implements fn:id - selects elements by their ID attribute value.
386///
387/// If 1 arg: uses context item as reference node.
388/// If 2 args: second arg is the reference node.
389///
390/// The reference node determines which document tree to search.
391/// Each string argument is tokenized by whitespace and each token
392/// is looked up via `find_element_by_id`. Results are deduplicated
393/// and returned in document order.
394///
395/// Without DTD/schema ID declarations, the default `find_element_by_id`
396/// returns `None`, so this returns an empty sequence.
397pub fn id<N: DomNavigator>(
398    context: &mut DynamicContext<'_, N>,
399    args: Vec<XPathValue<N>>,
400) -> Result<XPathValue<N>, XPathError> {
401    if args.is_empty() || args.len() > 2 {
402        return Err(XPathError::wrong_number_of_arguments("id", 1, args.len()));
403    }
404
405    let mut args = args;
406
407    // Get the reference node (arg 2 or context item)
408    let ref_node = if args.len() == 2 {
409        let node_arg = args.remove(1);
410        let items = materialize(node_arg);
411        if items.len() != 1 {
412            return Err(XPathError::XPTY0004 {
413                expected: "node()".to_string(),
414                found: if items.is_empty() {
415                    "empty-sequence()".to_string()
416                } else {
417                    format!("sequence of {} items", items.len())
418                },
419            });
420        }
421        match items.into_iter().next().unwrap() {
422            XmlItem::Node(n) => n,
423            XmlItem::Atomic(_) => {
424                return Err(XPathError::XPTY0004 {
425                    expected: "node()".to_string(),
426                    found: "atomic value".to_string(),
427                });
428            }
429        }
430    } else {
431        match &context.context_item {
432            Some(XmlItem::Node(n)) => n.clone(),
433            Some(XmlItem::Atomic(_)) => {
434                return Err(XPathError::XPTY0004 {
435                    expected: "node()".to_string(),
436                    found: "atomic value".to_string(),
437                });
438            }
439            None => {
440                return Err(XPathError::XPDY0002 {
441                    message: "Context item is absent".to_string(),
442                });
443            }
444        }
445    };
446
447    // Navigate reference node to document root
448    let mut root_nav = ref_node;
449    root_nav.move_to_root();
450
451    // Collect all ID tokens from the first argument
452    let id_arg = args.into_iter().next().unwrap();
453    let tokens = collect_id_tokens(id_arg);
454
455    // Look up each token, dedup by position
456    let mut result_nodes: Vec<N> = Vec::new();
457    for token in &tokens {
458        if let Some(found) = root_nav.find_element_by_id(token)? {
459            // Deduplicate: only add if not already present
460            let already_present = result_nodes.iter().any(|n| n.is_same_position(&found));
461            if !already_present {
462                result_nodes.push(found);
463            }
464        }
465    }
466
467    // Sort in document order
468    result_nodes.sort_by(|a, b| {
469        use crate::xpath::node_ops::compare_document_order;
470        compare_document_order(a, b)
471    });
472
473    // Convert to XPathValue
474    let items: Vec<XmlItem<N>> = result_nodes.into_iter().map(XmlItem::Node).collect();
475    Ok(XPathValue::from_sequence(items))
476}
477
478/// Collect whitespace-tokenized ID strings from an XPathValue argument.
479///
480/// Per the spec, each string value in the argument is split on whitespace
481/// and each resulting token is an IDREF to look up.
482fn collect_id_tokens<N: DomNavigator>(value: XPathValue<N>) -> Vec<String> {
483    let mut tokens = Vec::new();
484    match value {
485        XPathValue::Empty => {}
486        XPathValue::Item(item) => {
487            let s = item_string_value(item);
488            for token in s.split_whitespace() {
489                tokens.push(token.to_string());
490            }
491        }
492        XPathValue::Sequence(items) => {
493            for item in items {
494                let s = item_string_value(item);
495                for token in s.split_whitespace() {
496                    tokens.push(token.to_string());
497                }
498            }
499        }
500    }
501    tokens
502}
503
504/// Get the string value of an XmlItem for ID tokenization.
505fn item_string_value<N: DomNavigator>(item: XmlItem<N>) -> String {
506    match item {
507        XmlItem::Node(nav) => nav.value(),
508        XmlItem::Atomic(v) => crate::xpath::atomize::string_value(&v),
509    }
510}
511
512// ============================================================================
513// Helper Functions
514// ============================================================================
515
516/// Create an anyURI value.
517fn any_uri(s: impl Into<String>) -> XmlValue {
518    XmlValue::new(
519        XmlTypeCode::AnyUri,
520        XmlValueKind::Atomic(XmlAtomicValue::AnyUri(s.into())),
521    )
522}
523
524/// Get a NameId from a string, using NameId(0) (empty string) if string is empty.
525///
526/// This is used for required names (like local-name) where we need a NameId.
527/// The string is interned using `add()` which always succeeds.
528fn get_or_empty_id(names: &NameTable, s: &str) -> NameId {
529    if s.is_empty() {
530        NameId(0)
531    } else {
532        names.add(s)
533    }
534}
535
536/// Get an optional NameId from a string.
537///
538/// Returns None if the string is empty, Some(NameId) otherwise.
539/// The string is interned using `add()` which always succeeds.
540fn get_opt_id(names: &NameTable, s: &str) -> Option<NameId> {
541    if s.is_empty() {
542        None
543    } else {
544        Some(names.add(s))
545    }
546}
547
548/// Get a node argument, using context item if no argument provided.
549/// Returns None for empty sequence.
550fn get_node_arg<N: DomNavigator>(
551    context: &DynamicContext<'_, N>,
552    args: Vec<XPathValue<N>>,
553) -> Result<Option<N>, XPathError> {
554    if args.is_empty() {
555        // Use context item
556        match &context.context_item {
557            Some(XmlItem::Node(n)) => Ok(Some(n.clone())),
558            Some(XmlItem::Atomic(_)) => {
559                // Non-node context item returns empty for these functions
560                Ok(None)
561            }
562            None => Err(XPathError::XPDY0002 {
563                message: "Context item is absent".to_string(),
564            }),
565        }
566    } else {
567        let items = materialize(args.into_iter().next().unwrap());
568        if items.is_empty() {
569            return Ok(None);
570        }
571        match &items[0] {
572            XmlItem::Node(n) => Ok(Some(n.clone())),
573            XmlItem::Atomic(_) => {
574                // Non-node returns empty for these functions
575                Ok(None)
576            }
577        }
578    }
579}
580
581/// Compute the base URI of a node by walking ancestor chain.
582///
583/// Per XPath 2.0:
584/// 1. Walk ancestor chain collecting xml:base attributes
585/// 2. Get document base URI at root
586/// 3. Resolve chain against static base URI
587fn compute_base_uri<N: DomNavigator>(node: &N, static_base_uri: Option<&str>) -> Option<String> {
588    let mut xml_bases: Vec<String> = Vec::new();
589    let mut nav = node.clone();
590
591    // For text, comment, PI nodes, start from parent
592    match nav.node_type() {
593        DomNodeType::Text
594        | DomNodeType::Whitespace
595        | DomNodeType::SignificantWhitespace
596        | DomNodeType::Comment
597        | DomNodeType::ProcessingInstruction => {
598            if !nav.move_to_parent() {
599                return None;
600            }
601        }
602        _ => {}
603    }
604
605    // Walk up ancestor chain, collecting xml:base attributes
606    loop {
607        if nav.node_type() == DomNodeType::Element {
608            if let Some(xml_base) = get_xml_base_attr(&nav) {
609                xml_bases.push(xml_base);
610            }
611        }
612
613        if nav.node_type() == DomNodeType::Root {
614            // At root - get document base URI
615            let doc_base = nav.base_uri();
616            if !doc_base.is_empty() {
617                xml_bases.push(doc_base.to_string());
618            }
619            break;
620        }
621
622        if !nav.move_to_parent() {
623            // No further ancestor: in CTA fragment evaluation the
624            // synthetic Root is severed from the tree (§3.12.4 — E
625            // is the root of the CTA XDM instance), so we never
626            // observe a `Root` node above the test element. The
627            // navigator still surfaces the document-level base URI
628            // via `nav.base_uri()` once no `xml:base` is found, so
629            // pick it up here as the bottom of the chain.
630            let doc_base = nav.base_uri();
631            if !doc_base.is_empty() {
632                xml_bases.push(doc_base.to_string());
633            }
634            break;
635        }
636    }
637
638    // Start with static base URI (if any)
639    let mut base = static_base_uri.map(|s| s.to_string());
640
641    // Resolve xml:base chain from root to node (reverse order)
642    for uri in xml_bases.into_iter().rev() {
643        base = Some(resolve_uri(&uri, base.as_deref()));
644    }
645
646    base
647}
648
649/// Get xml:base attribute value from an element node.
650fn get_xml_base_attr<N: DomNavigator>(nav: &N) -> Option<String> {
651    let mut attr_nav = nav.clone();
652    if attr_nav.move_to_first_attribute() {
653        loop {
654            if attr_nav.local_name() == "base"
655                && attr_nav.namespace_uri() == "http://www.w3.org/XML/1998/namespace"
656            {
657                return Some(attr_nav.value());
658            }
659            if !attr_nav.move_to_next_attribute() {
660                break;
661            }
662        }
663    }
664    None
665}
666
667/// Resolve a URI reference against a base URI.
668///
669/// Simple implementation that handles:
670/// - Absolute URIs (returned as-is)
671/// - Relative paths resolved against base
672fn resolve_uri(uri: &str, base: Option<&str>) -> String {
673    // If URI is absolute (has scheme), return as-is
674    if uri.contains("://") || uri.starts_with("file:") {
675        return uri.to_string();
676    }
677
678    match base {
679        None => uri.to_string(),
680        Some(base_uri) => {
681            if uri.is_empty() {
682                return base_uri.to_string();
683            }
684
685            // Simple resolution: append relative to base directory
686            if uri.starts_with('/') {
687                // Absolute path - find scheme://host and append
688                if let Some(scheme_end) = base_uri.find("://") {
689                    if let Some(path_start) = base_uri[scheme_end + 3..].find('/') {
690                        let host_end = scheme_end + 3 + path_start;
691                        return format!("{}{}", &base_uri[..host_end], uri);
692                    }
693                }
694                uri.to_string()
695            } else {
696                // Relative path - append to base directory
697                if let Some(last_slash) = base_uri.rfind('/') {
698                    format!("{}/{}", &base_uri[..last_slash], uri)
699                } else {
700                    uri.to_string()
701                }
702            }
703        }
704    }
705}
706
707/// Find xml:lang attribute by walking up ancestors.
708fn find_xml_lang<N: DomNavigator>(node: &N) -> Option<String> {
709    let mut nav = node.clone();
710
711    loop {
712        // Check attributes on this element
713        if nav.node_type() == DomNodeType::Element {
714            let mut attr_nav = nav.clone();
715            if attr_nav.move_to_first_attribute() {
716                loop {
717                    if attr_nav.local_name() == "lang"
718                        && attr_nav.namespace_uri() == "http://www.w3.org/XML/1998/namespace"
719                    {
720                        return Some(attr_nav.value());
721                    }
722                    if !attr_nav.move_to_next_attribute() {
723                        break;
724                    }
725                }
726            }
727        }
728
729        // Move to parent
730        if !nav.move_to_parent() {
731            break;
732        }
733    }
734
735    None
736}
737
738/// Check if a language tag matches the test language.
739///
740/// Case-insensitive comparison with subtag support.
741/// "en" matches "en", "en-US", "en-GB", etc.
742fn lang_matches(lang: &str, test_lang: &str) -> bool {
743    let lang_lower = lang.to_lowercase();
744    let test_lower = test_lang.to_lowercase();
745
746    if lang_lower == test_lower {
747        return true;
748    }
749
750    // Check if lang starts with test_lang followed by '-'
751    if lang_lower.starts_with(&test_lower) {
752        let remainder = &lang_lower[test_lower.len()..];
753        if remainder.starts_with('-') {
754            return true;
755        }
756    }
757
758    false
759}
760
761#[cfg(test)]
762mod tests {
763    use super::*;
764
765    #[test]
766    fn test_lang_matches_exact() {
767        assert!(lang_matches("en", "en"));
768        assert!(lang_matches("EN", "en"));
769        assert!(lang_matches("en", "EN"));
770    }
771
772    #[test]
773    fn test_lang_matches_subtag() {
774        assert!(lang_matches("en-US", "en"));
775        assert!(lang_matches("en-GB", "en"));
776        assert!(lang_matches("zh-Hans-CN", "zh"));
777    }
778
779    #[test]
780    fn test_lang_matches_no_match() {
781        assert!(!lang_matches("de", "en"));
782        assert!(!lang_matches("english", "en"));
783        assert!(!lang_matches("en", "en-US"));
784    }
785
786    #[test]
787    fn test_any_uri_creation() {
788        let uri = any_uri("http://example.com");
789        assert_eq!(uri.type_code, XmlTypeCode::AnyUri);
790    }
791
792    #[test]
793    fn test_lang_matches_empty_testlang() {
794        // Empty test_lang should not match anything
795        assert!(!lang_matches("en", ""));
796        assert!(!lang_matches("en-US", ""));
797    }
798
799    #[test]
800    fn test_resolve_uri_absolute() {
801        // Absolute URI returned as-is
802        assert_eq!(
803            resolve_uri("http://example.com/path", Some("http://other.com/")),
804            "http://example.com/path"
805        );
806    }
807
808    #[test]
809    fn test_resolve_uri_relative() {
810        // Relative path appended to base directory
811        assert_eq!(
812            resolve_uri("file.xml", Some("http://example.com/dir/base.xml")),
813            "http://example.com/dir/file.xml"
814        );
815    }
816
817    #[test]
818    fn test_resolve_uri_absolute_path() {
819        // Absolute path resolved against host
820        assert_eq!(
821            resolve_uri(
822                "/absolute/path.xml",
823                Some("http://example.com/dir/base.xml")
824            ),
825            "http://example.com/absolute/path.xml"
826        );
827    }
828
829    #[test]
830    fn test_resolve_uri_no_base() {
831        // No base returns URI as-is
832        assert_eq!(resolve_uri("relative.xml", None), "relative.xml");
833    }
834
835    #[test]
836    fn test_resolve_uri_empty() {
837        // Empty URI returns base
838        assert_eq!(
839            resolve_uri("", Some("http://example.com/base.xml")),
840            "http://example.com/base.xml"
841        );
842    }
843
844    // ========================================================================
845    // fn:id tests
846    // ========================================================================
847
848    #[test]
849    fn test_collect_id_tokens_single_string() {
850        use crate::xpath::RoXmlNavigator;
851        let value: super::super::XPathValue<RoXmlNavigator<'static>> =
852            super::super::XPathValue::string("abc");
853        let tokens = collect_id_tokens(value);
854        assert_eq!(tokens, vec!["abc"]);
855    }
856
857    #[test]
858    fn test_collect_id_tokens_multi_whitespace() {
859        use crate::xpath::RoXmlNavigator;
860        let value: super::super::XPathValue<RoXmlNavigator<'static>> =
861            super::super::XPathValue::string("  foo   bar  baz  ");
862        let tokens = collect_id_tokens(value);
863        assert_eq!(tokens, vec!["foo", "bar", "baz"]);
864    }
865
866    #[test]
867    fn test_collect_id_tokens_empty() {
868        use crate::xpath::RoXmlNavigator;
869        let value: super::super::XPathValue<RoXmlNavigator<'static>> =
870            super::super::XPathValue::Empty;
871        let tokens = collect_id_tokens(value);
872        assert!(tokens.is_empty());
873    }
874
875    #[test]
876    fn test_collect_id_tokens_sequence() {
877        use crate::types::value::XmlValue;
878        use crate::xpath::iterator::XmlItem;
879        use crate::xpath::RoXmlNavigator;
880
881        let items = vec![
882            XmlItem::Atomic(XmlValue::string("a1 a2")),
883            XmlItem::Atomic(XmlValue::string("b1")),
884        ];
885        let value: super::super::XPathValue<RoXmlNavigator<'static>> =
886            super::super::XPathValue::from_sequence(items);
887        let tokens = collect_id_tokens(value);
888        assert_eq!(tokens, vec!["a1", "a2", "b1"]);
889    }
890
891    #[test]
892    fn test_fn_id_empty_without_dtd() {
893        // RoXmlNavigator inherits default find_element_by_id (returns None),
894        // so fn:id should return empty sequence, not an error.
895        use crate::namespace::table::NameTable;
896        use crate::xpath::context::{DynamicContext, XPathContext};
897        use crate::xpath::RoXmlNavigator;
898
899        let doc = roxmltree::Document::parse("<root><a id='x'/></root>").unwrap();
900        let nav = RoXmlNavigator::new(&doc);
901
902        let names = NameTable::new();
903        let static_ctx = XPathContext::new(&names);
904        let mut dyn_ctx = DynamicContext::new(&static_ctx, 0);
905        dyn_ctx.context_item = Some(XmlItem::Node(nav));
906
907        let args = vec![super::super::XPathValue::string("x")];
908        let result = id(&mut dyn_ctx, args).unwrap();
909
910        // Should be empty (no DTD/schema means no ID declarations)
911        assert!(
912            matches!(result, super::super::XPathValue::Empty),
913            "Expected empty sequence from fn:id without DTD"
914        );
915    }
916}