use sup_xml_core::xpath::{DocIndexLike, NodeId, XPathNodeKind};
use crate::ast::{QName, StylesheetAst, WhitespaceRule};
pub fn is_xslt_whitespace_only(s: &str) -> bool {
s.bytes().all(|b| matches!(b, b' ' | b'\t' | b'\r' | b'\n'))
}
pub fn should_strip<I: DocIndexLike>(
style: &StylesheetAst,
node: NodeId,
idx: &I,
) -> bool {
if !matches!(idx.kind(node), XPathNodeKind::Text | XPathNodeKind::CData) {
return false;
}
let content = idx.string_value(node);
if !is_xslt_whitespace_only(&content) {
return false;
}
let Some(parent) = idx.parent(node) else { return false; };
if !matches!(idx.kind(parent), XPathNodeKind::Element) {
return false;
}
let parent_local = idx.local_name(parent);
let parent_uri = idx.namespace_uri(parent);
let mut best: Option<(i32, i32, bool)> = None; for rule in &style.whitespace_rules {
let (q, prec, strip) = match rule {
WhitespaceRule::Strip(q, p) => (q, *p, true),
WhitespaceRule::Preserve(q, p) => (q, *p, false),
};
let Some(spec) = match_specificity(q, parent_local, parent_uri) else { continue };
let key = (prec, spec);
if best.is_none_or(|(bp, bs, _)| key >= (bp, bs)) {
best = Some((prec, spec, strip));
}
}
best.is_some_and(|(_, _, strip)| strip)
}
fn match_specificity(rule: &QName, name: &str, uri: &str) -> Option<i32> {
if rule.local == "*" && rule.uri.is_empty() && rule.prefix.is_none() {
return Some(0);
}
if rule.local == "*" {
return if rule.uri == uri { Some(1) } else { None };
}
if rule.local == name && rule.uri == uri { return Some(2); }
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Stylesheet;
use sup_xml_core::{parse_str, ParseOptions, XPathContext};
use sup_xml_core::xpath::eval::Value;
fn parse_doc(xml: &str) -> sup_xml_tree::dom::Document {
parse_str(xml, &ParseOptions::default()).unwrap()
}
fn first_text_in(idx: &sup_xml_core::xpath::DocIndex, parent: NodeId) -> NodeId {
for &c in idx.children(parent) {
if matches!(idx.kind(c), XPathNodeKind::Text | XPathNodeKind::CData) {
return c;
}
}
panic!("no text child");
}
fn ws_text(_doc: &sup_xml_tree::dom::Document, ctx: &XPathContext, query: &str) -> NodeId {
let ns = match ctx.eval(query).unwrap() { Value::NodeSet(ns) => ns, _ => panic!() };
first_text_in(&ctx.index, ns[0])
}
fn make_style(body: &str) -> Stylesheet {
let full = format!(
r#"<xsl:stylesheet version="1.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform">{body}</xsl:stylesheet>"#);
Stylesheet::compile_str(&full).unwrap()
}
#[test]
fn unmarked_element_preserves_whitespace() {
let xslt = make_style("");
let doc = parse_doc("<r> </r>");
let ctx = XPathContext::new(&doc);
let t = ws_text(&doc, &ctx, "/r");
assert!(!should_strip(&xslt.ast, t, &ctx.index));
}
#[test]
fn strip_space_strips_marked_element() {
let xslt = make_style(r#"<xsl:strip-space elements="r"/>"#);
let doc = parse_doc("<r> </r>");
let ctx = XPathContext::new(&doc);
let t = ws_text(&doc, &ctx, "/r");
assert!(should_strip(&xslt.ast, t, &ctx.index));
}
#[test]
fn strip_space_with_wildcard_strips_all() {
let xslt = make_style(r#"<xsl:strip-space elements="*"/>"#);
let doc = parse_doc("<r><a> </a></r>");
let ctx = XPathContext::new(&doc);
let t = ws_text(&doc, &ctx, "/r/a");
assert!(should_strip(&xslt.ast, t, &ctx.index));
}
#[test]
fn non_whitespace_text_always_preserved() {
let xslt = make_style(r#"<xsl:strip-space elements="*"/>"#);
let doc = parse_doc("<r>hello</r>");
let ctx = XPathContext::new(&doc);
let t = ws_text(&doc, &ctx, "/r");
assert!(!should_strip(&xslt.ast, t, &ctx.index));
}
#[test]
fn preserve_space_beats_wildcard_strip() {
let xslt = make_style(r#"
<xsl:strip-space elements="*"/>
<xsl:preserve-space elements="keep"/>
"#);
let doc = parse_doc("<r><keep> </keep></r>");
let ctx = XPathContext::new(&doc);
let t = ws_text(&doc, &ctx, "/r/keep");
assert!(!should_strip(&xslt.ast, t, &ctx.index));
}
}