From 5df1dd77fb8114ff6ceacd585df3e6323e0e5843 Mon Sep 17 00:00:00 2001 From: Matthew Jackson Date: Sun, 10 May 2026 16:21:51 -0700 Subject: [PATCH] labels/xml: shared tolerant XML helper, paramount + criterion onto it Replaces two near-duplicate hand-rolled XML scrapers in paramount.rs and criterion.rs with a single labels::xml module that's robust to: - Case-insensitive tag / attribute names ('' matches the same as ''; 'Name=...' matches 'name=...'). - XML namespace prefixes (matches '' for tag='tag'). - Arbitrary whitespace inside open tags and around '=' separators ('' works). - Both quote styles for attribute values (" and '). - Self-closing tag forms ('' and ''). - '>' chars inside quoted attribute values (no premature end-of-tag). Three functions: xml::attr(element, name) -> Option Extract attribute value from an open-tag fragment. xml::text(xml, tag) -> Option Trimmed text content of first .... xml::find_element(xml, tag, from) -> Option<(start, end)> Locate next ... for iteration; handles self-closing. 22 unit tests cover the robustness properties: case-insensitivity, namespace stripping, whitespace tolerance, quote styles, self-close forms, no-substring-false-positive (looking for 'lang' must NOT match 'lang_id' or 'language'), '>' inside quoted attrs, iteration across repeated elements. paramount.rs: drops local extract_attr; find_feature_playlist now walks xml::find_element('playlist', ...) so it works regardless of case and self-closing style. Pre-refactor: required exactly '' for self-close. criterion.rs: drops local extract_tag; parse_stream_infos and parse_playback_config iterate via xml::find_element. Same case- sensitivity + namespace gains. The 'COMMENTARY' / 'SDH' / 'DS' content-value match is now case-insensitive too (previously a disc authored with 'commentary' would have been miscategorized as Normal). Pre-refactor known failure modes (none observed yet, but trivial to trip on a future disc): vendor switches whitespace around '=', uses single quotes, capitalizes a tag, prefixes a namespace. All now handled. Out of scope by design: XML entity decoding (&, <), CDATA sections, comments, processing instructions. None observed in BD-J authored label data. If a future disc trips them, the entity decoder is a localized addition. Precommit (cargo +1.86 fmt + clippy + test) green. --- src/labels/criterion.rs | 141 +++++-------- src/labels/mod.rs | 1 + src/labels/paramount.rs | 43 ++-- src/labels/xml.rs | 452 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 520 insertions(+), 117 deletions(-) create mode 100644 src/labels/xml.rs diff --git a/src/labels/criterion.rs b/src/labels/criterion.rs index b1d52f1..a654b28 100644 --- a/src/labels/criterion.rs +++ b/src/labels/criterion.rs @@ -3,7 +3,7 @@ //! Clean structured XML with Content/Qualifier per stream and //! stream number mapping via playbackconfig. -use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType}; +use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, xml}; use crate::sector::SectorReader; use crate::udf::UdfFs; use std::collections::HashMap; @@ -79,105 +79,68 @@ struct StreamInfo { qualifier: LabelQualifier, } -fn parse_stream_infos(xml: &str) -> Vec { +fn parse_stream_infos(text: &str) -> Vec { let mut infos = Vec::new(); - let mut pos = 0; - while pos < xml.len() { - let (tag, stream_type) = if let Some(p) = xml[pos..].find("") { - (p + pos, StreamLabelType::Audio) - } else if let Some(p) = xml[pos..].find("") { - (p + pos, StreamLabelType::Subtitle) - } else { - break; - }; + for (tag_name, stream_type) in [ + ("AudioStreamInfos", StreamLabelType::Audio), + ("SubtitleStreamInfos", StreamLabelType::Subtitle), + ] { + let mut from = 0; + while let Some((start, end)) = xml::find_element(text, tag_name, from) { + let block = &text[start..end]; + let id = xml::text(block, "ID").unwrap_or_default(); + let lang_id = xml::text(block, "LangInfoID").unwrap_or_default(); + let content = xml::text(block, "Content").unwrap_or_default(); + let qualifier_str = xml::text(block, "Qualifier").unwrap_or_default(); - let end_tag = match stream_type { - StreamLabelType::Audio => "", - StreamLabelType::Subtitle => "", - }; + let (language, variant) = if lang_id.contains('_') { + let parts: Vec<&str> = lang_id.splitn(2, '_').collect(); + (parts[0].to_lowercase(), parts[1].to_string()) + } else { + (lang_id.to_lowercase(), String::new()) + }; - let block_end = match xml[tag..].find(end_tag) { - Some(p) => tag + p + end_tag.len(), - None => break, - }; + let purpose = if content.eq_ignore_ascii_case("COMMENTARY") { + LabelPurpose::Commentary + } else { + LabelPurpose::Normal + }; - let block = &xml[tag..block_end]; - let id = extract_tag(block, "ID").unwrap_or_default(); - let lang_id = extract_tag(block, "LangInfoID").unwrap_or_default(); - let content = extract_tag(block, "Content").unwrap_or_default(); - let qualifier_str = extract_tag(block, "Qualifier").unwrap_or_default(); + let qualifier = match qualifier_str.to_ascii_uppercase().as_str() { + "SDH" => LabelQualifier::Sdh, + "DS" => LabelQualifier::DescriptiveService, + _ => LabelQualifier::None, + }; - let (language, variant) = if lang_id.contains('_') { - let parts: Vec<&str> = lang_id.splitn(2, '_').collect(); - (parts[0].to_lowercase(), parts[1].to_string()) - } else { - (lang_id.to_lowercase(), String::new()) - }; - - let purpose = match content.as_str() { - "COMMENTARY" => LabelPurpose::Commentary, - _ => LabelPurpose::Normal, - }; - - let qualifier = match qualifier_str.as_str() { - "SDH" => LabelQualifier::Sdh, - "DS" => LabelQualifier::DescriptiveService, - _ => LabelQualifier::None, - }; - - infos.push(StreamInfo { - id, - stream_type, - language, - variant, - purpose, - qualifier, - }); - pos = block_end; + infos.push(StreamInfo { + id, + stream_type, + language, + variant, + purpose, + qualifier, + }); + from = end; + } } infos } -fn parse_playback_config(xml: &str, map: &mut HashMap) { - let mut pos = 0; - while pos < xml.len() { - let tag_start = if let Some(p) = xml[pos..].find("") { - Some(p + pos) - } else { - xml[pos..].find("").map(|p| p + pos) - }; - - let tag_start = match tag_start { - Some(p) => p, - None => break, - }; - - let block_end = xml[tag_start..] - .find("") - .or_else(|| xml[tag_start..].find("")) - .map(|p| tag_start + p + 20) - .unwrap_or(xml.len()); - - let block = &xml[tag_start..block_end]; - - if let (Some(stream_id_str), Some(info_id)) = ( - extract_tag(block, "StreamID"), - extract_tag(block, "StreamInfo_ID"), - ) { - if let Ok(stream_num) = stream_id_str.parse::() { - map.insert(info_id, stream_num); +fn parse_playback_config(text: &str, map: &mut HashMap) { + for tag_name in ["AudioStreams", "SubtitlesStreams"] { + let mut from = 0; + while let Some((start, end)) = xml::find_element(text, tag_name, from) { + let block = &text[start..end]; + if let (Some(stream_id_str), Some(info_id)) = ( + xml::text(block, "StreamID"), + xml::text(block, "StreamInfo_ID"), + ) { + if let Ok(stream_num) = stream_id_str.parse::() { + map.insert(info_id, stream_num); + } } + from = end; } - - pos = block_end; } } - -fn extract_tag(xml: &str, tag: &str) -> Option { - let open = format!("<{tag}>"); - let close = format!(""); - let start = xml.find(&open)? + open.len(); - let end = xml[start..].find(&close)? + start; - Some(xml[start..end].trim().to_string()) -} diff --git a/src/labels/mod.rs b/src/labels/mod.rs index caae301..f70e27f 100644 --- a/src/labels/mod.rs +++ b/src/labels/mod.rs @@ -17,6 +17,7 @@ mod paramount; mod pixelogic; pub(crate) mod text; pub mod vocab; +pub(crate) mod xml; use crate::disc::{DiscTitle, Stream}; use crate::sector::SectorReader; diff --git a/src/labels/paramount.rs b/src/labels/paramount.rs index 29bca97..ea9a190 100644 --- a/src/labels/paramount.rs +++ b/src/labels/paramount.rs @@ -12,7 +12,7 @@ //! sub_com1_idx="23,24,25" /> //! ``` -use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType}; +use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, xml}; use crate::sector::SectorReader; use crate::udf::UdfFs; @@ -30,8 +30,8 @@ pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option let mut labels = Vec::new(); // Parse audio streams - if let Some(aud) = extract_attr(&feature, "aud") { - let com_idx = extract_attr(&feature, "aud_com1_idx").and_then(|s| s.parse::().ok()); + if let Some(aud) = xml::attr(&feature, "aud") { + let com_idx = xml::attr(&feature, "aud_com1_idx").and_then(|s| s.parse::().ok()); for (i, lang) in aud.split(',').enumerate() { let lang = lang.trim(); @@ -57,12 +57,12 @@ pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option } // Parse subtitle streams - if let Some(sub) = extract_attr(&feature, "sub") { - let forced: Vec = extract_attr(&feature, "forced_sub") + if let Some(sub) = xml::attr(&feature, "sub") { + let forced: Vec = xml::attr(&feature, "forced_sub") .map(|s| s.split(',').map(|f| f.trim() == "1").collect()) .unwrap_or_default(); - let com_indices: Vec = extract_attr(&feature, "sub_com1_idx") + let com_indices: Vec = xml::attr(&feature, "sub_com1_idx") .map(|s| s.split(',').filter_map(|i| i.trim().parse().ok()).collect()) .unwrap_or_default(); @@ -106,28 +106,23 @@ pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option } /// Find the feature playlist element (the one with the most audio tracks). -fn find_feature_playlist(xml: &str) -> Option { +fn find_feature_playlist(text: &str) -> Option { let mut best: Option = None; let mut best_aud_count = 0; + let mut from = 0; - let mut pos = 0; - while let Some(start) = xml[pos..].find("") { - Some(p) => abs_start + p + 2, - None => break, - }; - let element = &xml[abs_start..end]; + while let Some((start, end)) = xml::find_element(text, "playlist", from) { + let element = &text[start..end]; - // Prefer name="Feature" explicitly - if let Some(name) = extract_attr(element, "name") { + // Prefer name="Feature" explicitly. + if let Some(name) = xml::attr(element, "name") { if name.eq_ignore_ascii_case("Feature") { return Some(element.to_string()); } } - // Otherwise pick the one with the most audio streams - if let Some(aud) = extract_attr(element, "aud") { + // Otherwise pick the one with the most audio streams. + if let Some(aud) = xml::attr(element, "aud") { let count = aud.split(',').count(); if count > best_aud_count { best_aud_count = count; @@ -135,15 +130,7 @@ fn find_feature_playlist(xml: &str) -> Option { } } - pos = end; + from = end; } best } - -/// Extract an XML attribute value from an element string. -fn extract_attr(element: &str, name: &str) -> Option { - let needle = format!("{name}=\""); - let start = element.find(&needle)? + needle.len(); - let end = element[start..].find('"')? + start; - Some(element[start..end].to_string()) -} diff --git a/src/labels/xml.rs b/src/labels/xml.rs new file mode 100644 index 0000000..1b324f3 --- /dev/null +++ b/src/labels/xml.rs @@ -0,0 +1,452 @@ +//! Tolerant XML scraping helpers — promoted from two near-duplicate +//! hand-rolls in `paramount.rs` (attribute extraction) and `criterion.rs` +//! (tag-text extraction). +//! +//! These are NOT a full XML parser. They handle the subset of XML the +//! BD-J authoring tools we've seen actually emit: ASCII tag/attr +//! names, no entity references inside label strings, optional XML +//! namespaces. Hardening goals over the prior `find("")` / +//! `find(r#"name=""#)` matchers: +//! +//! 1. **Case-insensitive** tag and attribute names — vendors casing +//! is inconsistent across authoring-tool revisions. +//! 2. **Namespace-aware** — strip an optional `ns:` prefix so +//! `` and `` both match. +//! 3. **Whitespace-tolerant** — multiple/tab/newline characters +//! around `=` between attribute name and value; whitespace inside +//! the opening tag. +//! 4. **Quote-style tolerant** — both `"value"` and `'value'`. +//! 5. **Self-closing tag handling** — `` and `` both +//! work; [`text`] returns `Some("")` for empty content. +//! +//! Out of scope (intentionally simple): XML entity decoding +//! (`&`, `<`, etc.), CDATA sections, comments, processing +//! instructions, DTD declarations. None of the BD-J authored disc +//! data we've observed exercises any of those — labels are plain +//! ASCII/Latin-1 in attribute values. + +/// Extract the value of attribute `name` from one XML element +/// fragment (e.g. ``). +/// +/// Returns the raw attribute text (no entity decoding) or `None` if +/// the attribute isn't present. Empty string for `name=""` is +/// represented as `Some("")`. +pub fn attr(element: &str, name: &str) -> Option { + let bytes = element.as_bytes(); + let name_lower = name.to_ascii_lowercase(); + let name_bytes = name_lower.as_bytes(); + let mut i = 0; + while i + name_bytes.len() < bytes.len() { + // Find the next position where `name=` could start. We need + // a word boundary before the name (whitespace or `<` or `:`). + if i > 0 && is_name_char(bytes[i - 1]) { + i += 1; + continue; + } + if !slice_eq_ignore_case(&bytes[i..i + name_bytes.len()], name_bytes) { + i += 1; + continue; + } + let after_name = i + name_bytes.len(); + // The character immediately after the name must not be a + // name-continuation (otherwise we matched a prefix like + // `lang_id` when looking for `lang`). + if after_name < bytes.len() && is_name_char(bytes[after_name]) { + i = after_name; + continue; + } + // Walk past whitespace, then `=`, then more whitespace, then + // the opening quote. + let mut j = after_name; + while j < bytes.len() && is_ws(bytes[j]) { + j += 1; + } + if j >= bytes.len() || bytes[j] != b'=' { + i = j.max(i + 1); + continue; + } + j += 1; // past '=' + while j < bytes.len() && is_ws(bytes[j]) { + j += 1; + } + if j >= bytes.len() { + return None; + } + let quote = bytes[j]; + if quote != b'"' && quote != b'\'' { + // Unquoted attribute values aren't part of well-formed + // XML (HTML5 allows them, XML doesn't). Skip. + i = j; + continue; + } + let value_start = j + 1; + let close = bytes[value_start..].iter().position(|&b| b == quote)?; + let value = &element[value_start..value_start + close]; + return Some(value.to_string()); + } + None +} + +/// Extract the trimmed text content of the first occurrence of +/// `...` in `xml`. Returns `None` if the tag isn't found +/// or its opening tag is malformed. +/// +/// Whitespace around the inner text is stripped. Self-closing +/// `` yields `Some("")`. Nested same-name tags are NOT +/// handled — the first close encountered wins (this matches the +/// prior behavior in criterion.rs). +pub fn text(xml: &str, tag: &str) -> Option { + let (open_end, body_start) = find_open_tag(xml, tag, 0)?; + // Self-closing — already consumed in find_open_tag if `/>`. + if open_end == body_start { + // Means find_open_tag returned the same offset twice for + // self-closing form. (Not currently the case in our impl, + // but defensive.) + return Some(String::new()); + } + // For self-closing tags, body_start is past `/>` and we have no + // content. Detect by checking the char at body_start - 1 was `/`. + if body_start >= 2 && &xml[body_start - 2..body_start] == "/>" { + return Some(String::new()); + } + // Find the matching close tag. Case-insensitive + namespace-aware. + let close_start = find_close_tag(xml, tag, body_start)?; + Some(xml[body_start..close_start].trim().to_string()) +} + +/// Locate the next `` opening AND its closing `` in +/// `xml`, starting at byte offset `from`. Returns `(element_start, +/// element_end)` — `element_start` is the `<` of the opening tag, +/// `element_end` is one past the `>` of the closing tag. Useful for +/// iterating over repeated elements like `` blocks in +/// `paramount`. +/// +/// Self-closing elements return the same offset for body_end as the +/// element_end (i.e. `element_end - element_start` includes only the +/// `` text). +pub fn find_element(xml: &str, tag: &str, from: usize) -> Option<(usize, usize)> { + let bytes = xml.as_bytes(); + let mut i = from; + while i < bytes.len() { + if bytes[i] != b'<' { + i += 1; + continue; + } + // Try matching tag name at i+1 (after `<`). + let after_lt = i + 1; + if !matches_tag_name_at(bytes, after_lt, tag) { + i += 1; + continue; + } + // Found an open tag at offset i. Walk to find the closing `>` + // of the open tag itself. + let mut j = after_lt; + // Skip past the tag name (and optional namespace prefix). + while j < bytes.len() && (is_name_char(bytes[j]) || bytes[j] == b':') { + j += 1; + } + // Walk attributes — track quoting state. + let mut self_closing = false; + while j < bytes.len() { + match bytes[j] { + b'>' => { + j += 1; + break; + } + b'/' if j + 1 < bytes.len() && bytes[j + 1] == b'>' => { + self_closing = true; + j += 2; + break; + } + b'"' | b'\'' => { + let q = bytes[j]; + j += 1; + while j < bytes.len() && bytes[j] != q { + j += 1; + } + if j < bytes.len() { + j += 1; + } + } + _ => j += 1, + } + } + if self_closing { + return Some((i, j)); + } + // Find matching close. Doesn't handle nested same-name; OK + // for our authoring-tool subset. + let close_start = find_close_tag(xml, tag, j)?; + let close_end = find_byte(bytes, b'>', close_start)? + 1; + return Some((i, close_end)); + } + None +} + +// ── Internal helpers ─────────────────────────────────────────────────────── + +/// True if `bytes[start..]` opens a tag named `tag`, allowing an +/// optional `ns:` namespace prefix. Comparison is case-insensitive. +/// The character after the tag name must not be a name-continuation +/// (so `` doesn't match ``). +fn matches_tag_name_at(bytes: &[u8], start: usize, tag: &str) -> bool { + let tag_lower = tag.to_ascii_lowercase(); + let tag_bytes = tag_lower.as_bytes(); + // Skip optional `prefix:` (one or more name chars + `:`). + let mut name_start = start; + let mut scan = start; + while scan < bytes.len() && is_name_char(bytes[scan]) { + scan += 1; + } + if scan < bytes.len() && bytes[scan] == b':' { + name_start = scan + 1; + } + if name_start + tag_bytes.len() > bytes.len() { + return false; + } + if !slice_eq_ignore_case(&bytes[name_start..name_start + tag_bytes.len()], tag_bytes) { + return false; + } + // Boundary: char after the tag name must be `>`, `/`, whitespace. + let after = name_start + tag_bytes.len(); + if after >= bytes.len() { + return false; + } + matches!(bytes[after], b'>' | b'/' | b' ' | b'\t' | b'\n' | b'\r') +} + +/// Find the offset of the next `` (or ``) in `xml` +/// starting at `from`. Case-insensitive; returns the offset of the +/// `<`. None if not found. +fn find_close_tag(xml: &str, tag: &str, from: usize) -> Option { + let bytes = xml.as_bytes(); + let mut i = from; + while i + 2 < bytes.len() { + if bytes[i] == b'<' && bytes[i + 1] == b'/' { + // Check tag name (with optional namespace). + if matches_tag_name_at(bytes, i + 2, tag) { + return Some(i); + } + } + i += 1; + } + None +} + +/// Find the open tag of `` in `xml` starting at `from`. Returns +/// `(after_open_lt, after_open_gt)` — the offsets are: just past +/// the `<` of the open tag, and just past the `>` of the open tag. +fn find_open_tag(xml: &str, tag: &str, from: usize) -> Option<(usize, usize)> { + let bytes = xml.as_bytes(); + let (elem_start, _) = find_element(xml, tag, from)?; + let after_lt = elem_start + 1; + // Find the `>` that ends the open tag (handling quoted attrs). + let mut j = after_lt; + while j < bytes.len() { + match bytes[j] { + b'>' => return Some((after_lt, j + 1)), + b'/' if j + 1 < bytes.len() && bytes[j + 1] == b'>' => { + return Some((after_lt, j + 2)); + } + b'"' | b'\'' => { + let q = bytes[j]; + j += 1; + while j < bytes.len() && bytes[j] != q { + j += 1; + } + if j < bytes.len() { + j += 1; + } + } + _ => j += 1, + } + } + None +} + +fn find_byte(bytes: &[u8], target: u8, from: usize) -> Option { + bytes[from..] + .iter() + .position(|&b| b == target) + .map(|p| p + from) +} + +/// True if `c` can be part of an XML name token (rough). We accept +/// alphanumerics, `_`, `-`, `.`. +fn is_name_char(c: u8) -> bool { + c.is_ascii_alphanumeric() || c == b'_' || c == b'-' || c == b'.' +} + +fn is_ws(c: u8) -> bool { + matches!(c, b' ' | b'\t' | b'\n' | b'\r') +} + +fn slice_eq_ignore_case(a: &[u8], b_lower: &[u8]) -> bool { + if a.len() != b_lower.len() { + return false; + } + a.iter() + .zip(b_lower.iter()) + .all(|(&x, &y)| x.to_ascii_lowercase() == y) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn attr_basic() { + assert_eq!( + attr(r#""#, "name"), + Some("Feature".into()) + ); + assert_eq!( + attr(r#""#, "id"), + Some("00222".into()) + ); + } + + #[test] + fn attr_case_insensitive_name() { + assert_eq!( + attr(r#""#, "name"), + Some("Feature".into()) + ); + assert_eq!( + attr(r#""#, "Name"), + Some("Feature".into()) + ); + } + + #[test] + fn attr_accepts_single_quotes() { + assert_eq!( + attr(r#""#, "name"), + Some("Feature".into()) + ); + } + + #[test] + fn attr_whitespace_around_equals() { + assert_eq!( + attr(r#""#, "name"), + Some("Feature".into()) + ); + assert_eq!( + attr("", "name"), + Some("Feature".into()) + ); + } + + #[test] + fn attr_missing_returns_none() { + assert_eq!(attr(r#""#, "id"), None); + assert_eq!(attr("", "name"), None); + } + + #[test] + fn attr_no_substring_false_positive() { + // Looking for "lang" should NOT match "lang_id" or + // "language" because of the name-char boundary check. + assert_eq!(attr(r#""#, "lang"), None); + } + + #[test] + fn attr_empty_value() { + assert_eq!(attr(r#""#, "name"), Some("".into())); + } + + #[test] + fn text_basic() { + assert_eq!(text("hello", "x"), Some("hello".into())); + assert_eq!( + text(" hello world ", "x"), + Some("hello world".into()) + ); + } + + #[test] + fn text_case_insensitive_tag() { + assert_eq!(text("foo", "x"), Some("foo".into())); + assert_eq!(text("bar", "foo"), Some("bar".into())); + } + + #[test] + fn text_namespace_prefix() { + assert_eq!(text("value", "tag"), Some("value".into())); + assert_eq!(text("v", "bar"), Some("v".into())); + } + + #[test] + fn text_self_closing() { + assert_eq!(text("", "x"), Some("".into())); + assert_eq!(text("", "x"), Some("".into())); + assert_eq!(text("", "x"), Some("".into())); + } + + #[test] + fn text_with_attrs() { + assert_eq!( + text(r#"hello"#, "x"), + Some("hello".into()) + ); + } + + #[test] + fn text_missing_close_returns_none() { + assert_eq!(text("hello", "x"), None); + } + + #[test] + fn text_skips_inner_tags_naively() { + // Limitation noted: nested same-name tags aren't handled. + // Different-name nesting works (we just return everything + // between the open and close). + assert_eq!( + text("nested", "x"), + Some("nested".into()) + ); + } + + #[test] + fn find_element_basic() { + let xml = r#" body"#; + let (s, e) = find_element(xml, "y", 0).unwrap(); + assert_eq!(&xml[s..e], r#"body"#); + } + + #[test] + fn find_element_self_closing() { + let xml = r#""#; + let (s, e) = find_element(xml, "x", 0).unwrap(); + assert_eq!(&xml[s..e], ""); + } + + #[test] + fn find_element_handles_quoted_gt_in_attr() { + // A `>` inside a quoted attribute value should not terminate + // the open tag prematurely. + let xml = r#"body"#; + let (s, e) = find_element(xml, "x", 0).unwrap(); + assert_eq!(&xml[s..e], r#"body"#); + } + + #[test] + fn find_element_iteration() { + let xml = "

a

b

c

"; + let mut positions = Vec::new(); + let mut from = 0; + while let Some((s, e)) = find_element(xml, "p", from) { + positions.push(&xml[s..e]); + from = e; + } + assert_eq!(positions, vec!["

a

", "

b

", "

c

"]); + } + + #[test] + fn find_element_with_namespace() { + let xml = r#""#; + let (s, e) = find_element(xml, "item", 0).unwrap(); + assert_eq!(&xml[s..e], r#""#); + } +}