Skip to main content

config_disassembler/xml/parsers/
strip_whitespace.rs

1//! Strip meaningless whitespace-only #text nodes from parsed XML structure.
2
3use serde_json::{Map, Value};
4
5/// Keys whose mere presence means an element had real text/comment/CDATA content
6/// between its tags -- disqualifying it from `mark_compact_elements`'s "wrapper with
7/// zero surrounding whitespace" check regardless of the key's value.
8const CONTENT_KEYS: [&str; 4] = ["#text", "#comment", "#text-tail", "#cdata"];
9
10fn is_meta_key(key: &str) -> bool {
11    key.starts_with('#') || key.starts_with('@') || key == "?xml"
12}
13
14/// Mark elements written as a single-line "compact" wrapper around exactly one nested
15/// child element, with zero whitespace between the wrapper's start tag, the child, and
16/// the wrapper's end tag -- e.g. Salesforce Flow's
17/// `<connector><targetReference>X</targetReference></connector>` idiom. Marks by
18/// inserting `"#compact": true` on the wrapper element itself; `build_xml_string`
19/// consumes and strips it to render the wrapper and its single child on one line.
20///
21/// Must run on the freshly parsed tree *before* [`strip_whitespace_text_nodes`], which
22/// removes whitespace-only `#text`/`#cdata`/`#text-tail` this depends on: once removed,
23/// "never had whitespace" (compact) and "had whitespace, now stripped" (block) are
24/// indistinguishable from the object alone.
25///
26/// Does not recurse into `Value::String`/`Number`/etc; call on each of the document
27/// root's own child values (not the root wrapper itself, which always has exactly one
28/// key -- the root element -- and would otherwise always be marked compact).
29pub fn mark_compact_elements(node: &mut Value) {
30    match node {
31        Value::Array(arr) => {
32            for item in arr.iter_mut() {
33                mark_compact_elements(item);
34            }
35        }
36        Value::Object(obj) => {
37            for value in obj.values_mut() {
38                mark_compact_elements(value);
39            }
40
41            let has_content_key = obj.keys().any(|k| CONTENT_KEYS.contains(&k.as_str()));
42            let mut element_key_count = 0;
43            let mut sole_child_is_single_element = false;
44            for (key, value) in obj.iter() {
45                if !is_meta_key(key) {
46                    element_key_count += 1;
47                    sole_child_is_single_element = !matches!(value, Value::Array(_));
48                }
49            }
50
51            if !has_content_key && element_key_count == 1 && sole_child_is_single_element {
52                obj.insert("#compact".to_string(), Value::Bool(true));
53            }
54        }
55        _ => {}
56    }
57}
58
59fn is_empty_text_node(key: &str, value: &Value) -> bool {
60    (key == "#text" || key == "#cdata" || key == "#text-tail")
61        && value.as_str().map(|s| s.trim().is_empty()).unwrap_or(false)
62}
63
64fn clean_array(arr: &[Value]) -> Vec<Value> {
65    arr.iter()
66        .filter_map(|entry| {
67            let cleaned = strip_whitespace_text_nodes(entry);
68            match &cleaned {
69                Value::Object(m) if m.is_empty() => None,
70                _ => Some(cleaned),
71            }
72        })
73        .collect()
74}
75
76fn clean_object(obj: &Map<String, Value>) -> Map<String, Value> {
77    let mut result = Map::new();
78    let has_cdata = obj.contains_key("#cdata");
79    let has_comment = obj.contains_key("#comment");
80    for (key, value) in obj {
81        // Preserve whitespace-only #text when element has #cdata (needed for round-trip)
82        // Preserve whitespace-only #text and #text-tail when element has #comment
83        if is_empty_text_node(key, value)
84            && !(key == "#text" && has_cdata)
85            && !(key == "#text" && has_comment)
86            && !(key == "#text-tail" && has_comment)
87        {
88            continue;
89        }
90        let cleaned = strip_whitespace_text_nodes(value);
91        if !cleaned.is_null()
92            || key == "#text"
93            || key == "#cdata"
94            || key == "#comment"
95            || key == "#text-tail"
96        {
97            result.insert(key.clone(), cleaned);
98        }
99    }
100    result
101}
102
103/// Remove meaningless whitespace-only #text nodes from the XML structure.
104pub fn strip_whitespace_text_nodes(node: &Value) -> Value {
105    match node {
106        Value::Array(arr) => Value::Array(clean_array(arr)),
107        Value::Object(obj) => Value::Object(clean_object(obj)),
108        other => other.clone(),
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use serde_json::json;
116
117    #[test]
118    fn strips_empty_text_nodes_from_array() {
119        let input = json!([{ "#text": "   " }, { "#text": "keep me" }]);
120        let result = strip_whitespace_text_nodes(&input);
121        let arr = result.as_array().unwrap();
122        assert_eq!(arr.len(), 1);
123        assert_eq!(
124            arr[0].get("#text").and_then(|v| v.as_str()),
125            Some("keep me")
126        );
127    }
128
129    #[test]
130    fn preserves_non_empty_text() {
131        let input = json!({ "#text": "  content  " });
132        let result = strip_whitespace_text_nodes(&input);
133        assert_eq!(
134            result.get("#text").and_then(|v| v.as_str()),
135            Some("  content  ")
136        );
137    }
138
139    #[test]
140    fn leaves_primitive_unchanged() {
141        let input = json!("hello");
142        let result = strip_whitespace_text_nodes(&input);
143        assert_eq!(result, json!("hello"));
144    }
145
146    #[test]
147    fn preserves_empty_text_when_element_has_cdata() {
148        let input = json!({ "#cdata": "content", "#text": "   " });
149        let result = strip_whitespace_text_nodes(&input);
150        let obj = result.as_object().unwrap();
151        assert_eq!(obj.get("#cdata").and_then(|v| v.as_str()), Some("content"));
152        assert_eq!(obj.get("#text").and_then(|v| v.as_str()), Some("   "));
153    }
154
155    #[test]
156    fn preserves_null_special_keys() {
157        let input = json!({ "#text": null });
158        let result = strip_whitespace_text_nodes(&input);
159        assert!(result.get("#text").map(|v| v.is_null()) == Some(true));
160    }
161
162    #[test]
163    fn strips_whitespace_only_cdata_node() {
164        // Whitespace-only #cdata (without a sibling preservation trigger) should be dropped,
165        // mirroring the existing #text behavior. Guards is_empty_text_node's #cdata branch.
166        let input = json!([{ "#cdata": "   " }, { "#text": "keep me" }]);
167        let result = strip_whitespace_text_nodes(&input);
168        let arr = result.as_array().unwrap();
169        assert_eq!(arr.len(), 1);
170        assert_eq!(
171            arr[0].get("#text").and_then(|v| v.as_str()),
172            Some("keep me")
173        );
174    }
175
176    #[test]
177    fn strips_whitespace_only_text_tail_node() {
178        // Whitespace-only #text-tail without a #comment sibling should be stripped.
179        // Guards both is_empty_text_node's #text-tail branch and the line-32 guard in clean_object.
180        let input = json!([{ "#text-tail": "   " }, { "#text": "keep me" }]);
181        let result = strip_whitespace_text_nodes(&input);
182        let arr = result.as_array().unwrap();
183        assert_eq!(arr.len(), 1);
184        assert_eq!(
185            arr[0].get("#text").and_then(|v| v.as_str()),
186            Some("keep me")
187        );
188    }
189
190    #[test]
191    fn preserves_whitespace_text_tail_when_element_has_comment() {
192        // Mirror of preserves_empty_text_when_element_has_cdata for #text-tail + #comment.
193        // Documents the line-32 guard that keeps whitespace #text-tail alive next to comments.
194        let input = json!({ "#comment": "note", "#text-tail": "   " });
195        let result = strip_whitespace_text_nodes(&input);
196        let obj = result.as_object().unwrap();
197        assert_eq!(obj.get("#comment").and_then(|v| v.as_str()), Some("note"));
198        assert_eq!(obj.get("#text-tail").and_then(|v| v.as_str()), Some("   "));
199    }
200
201    #[test]
202    fn preserves_null_cdata_comment_and_text_tail_keys() {
203        // Special keys #cdata, #comment, #text-tail are kept even when value is null (insert branch)
204        let input = json!({
205            "#cdata": null,
206            "#comment": null,
207            "#text-tail": null,
208            "a": "b"
209        });
210        let result = strip_whitespace_text_nodes(&input);
211        let obj = result.as_object().unwrap();
212        assert!(obj.get("#cdata").map(|v| v.is_null()) == Some(true));
213        assert!(obj.get("#comment").map(|v| v.is_null()) == Some(true));
214        assert!(obj.get("#text-tail").map(|v| v.is_null()) == Some(true));
215        assert_eq!(obj.get("a").and_then(|v| v.as_str()), Some("b"));
216    }
217
218    #[test]
219    fn mark_compact_elements_marks_sole_element_child_with_no_whitespace() {
220        // `<connector><targetReference>X</targetReference></connector>` -- parsed with
221        // no #text on `connector` at all (no whitespace ever occurred between tags).
222        let mut input = json!({
223            "connector": { "targetReference": { "#text": "X" } }
224        });
225        mark_compact_elements(&mut input);
226        let connector = input.get("connector").and_then(|v| v.as_object()).unwrap();
227        assert_eq!(connector.get("#compact"), Some(&Value::Bool(true)));
228    }
229
230    #[test]
231    fn mark_compact_elements_recurses_into_array_items() {
232        // `Value::Array` must recurse into each item, not just skip past the array --
233        // otherwise a compact wrapper nested inside a repeated sibling element (very
234        // common: each shard/array item is its own subtree) would never get marked.
235        let mut input = json!({
236            "items": [
237                { "wrapper": { "child": { "#text": "1" } } },
238                { "unrelated": { "#text": "2" } }
239            ]
240        });
241        mark_compact_elements(&mut input);
242        let items = input.get("items").and_then(|v| v.as_array()).unwrap();
243        let wrapper = items[0].get("wrapper").and_then(|v| v.as_object()).unwrap();
244        assert_eq!(
245            wrapper.get("#compact"),
246            Some(&Value::Bool(true)),
247            "wrapper nested inside an array item must still be marked compact"
248        );
249    }
250
251    #[test]
252    fn mark_compact_elements_treats_hash_prefixed_marker_as_meta_not_element() {
253        // Isolates is_meta_key's `key.starts_with('#')` clause: a `#`-prefixed key that
254        // is NOT one of CONTENT_KEYS (so has_content_key stays false) and is NOT the
255        // literal "#compact" marker itself (which would make the assertion vacuously
256        // true regardless of is_meta_key's behavior) must still be excluded from the
257        // element count, or a wrapper carrying it alongside its one real child would be
258        // miscounted as having two children and never marked.
259        let mut input = json!({
260            "wrapper": { "#some-other-marker": "ignored", "child": { "#text": "1" } }
261        });
262        mark_compact_elements(&mut input);
263        let wrapper = input.get("wrapper").and_then(|v| v.as_object()).unwrap();
264        assert_eq!(wrapper.get("#compact"), Some(&Value::Bool(true)));
265    }
266
267    #[test]
268    fn mark_compact_elements_treats_xml_declaration_key_as_meta_not_element() {
269        // Isolates is_meta_key's `key == "?xml"` clause: even though mark_compact_elements
270        // is only ever invoked one level below the document root in practice (never on an
271        // object that itself carries "?xml"), is_meta_key's own contract must still exclude
272        // it from the element count wherever it appears.
273        let mut input = json!({
274            "wrapper": { "?xml": { "@version": "1.0" }, "child": { "#text": "1" } }
275        });
276        mark_compact_elements(&mut input);
277        let wrapper = input.get("wrapper").and_then(|v| v.as_object()).unwrap();
278        assert_eq!(wrapper.get("#compact"), Some(&Value::Bool(true)));
279    }
280
281    #[test]
282    fn mark_compact_elements_does_not_mark_block_formatted_wrapper() {
283        // Same shape, but whitespace was present (block-formatted in source) --
284        // `connector` carries a whitespace-only #text from the surrounding newlines.
285        let mut input = json!({
286            "connector": {
287                "#text": "\n        ",
288                "targetReference": { "#text": "X" }
289            }
290        });
291        mark_compact_elements(&mut input);
292        let connector = input.get("connector").and_then(|v| v.as_object()).unwrap();
293        assert!(connector.get("#compact").is_none());
294    }
295
296    #[test]
297    fn mark_compact_elements_recurses_into_nested_children() {
298        // The nested `value` wrapper should be marked even though `decisions` itself
299        // (with its many real fields) never qualifies as a compact wrapper.
300        let mut input = json!({
301            "decisions": {
302                "name": { "#text": "Decision_0001" },
303                "value": { "stringValue": { "#text": "Match" } }
304            }
305        });
306        mark_compact_elements(&mut input);
307        let decisions = input.get("decisions").and_then(|v| v.as_object()).unwrap();
308        assert!(
309            decisions.get("#compact").is_none(),
310            "element with multiple children must never be marked compact"
311        );
312        let value = decisions.get("value").and_then(|v| v.as_object()).unwrap();
313        assert_eq!(value.get("#compact"), Some(&Value::Bool(true)));
314    }
315
316    #[test]
317    fn mark_compact_elements_ignores_array_valued_sole_key() {
318        // A repeated sibling tag collapses to an Array even when it is the only key
319        // present; that must never be treated as a single-element wrapper.
320        let mut input = json!({
321            "parent": { "item": [{ "#text": "1" }, { "#text": "2" }] }
322        });
323        mark_compact_elements(&mut input);
324        let parent = input.get("parent").and_then(|v| v.as_object()).unwrap();
325        assert!(parent.get("#compact").is_none());
326    }
327
328    #[test]
329    fn mark_compact_elements_ignores_element_with_attributes_and_text() {
330        // An element with both an attribute and real text content is a leaf, not a
331        // wrapper -- must never be marked regardless of key count.
332        let mut input = json!({
333            "field": { "@type": "string", "#text": "value" }
334        });
335        mark_compact_elements(&mut input);
336        let field = input.get("field").and_then(|v| v.as_object()).unwrap();
337        assert!(field.get("#compact").is_none());
338    }
339
340    #[test]
341    fn mark_compact_elements_leaves_primitives_and_empty_containers_unchanged() {
342        let mut s = json!("hello");
343        mark_compact_elements(&mut s);
344        assert_eq!(s, json!("hello"));
345
346        let mut empty_obj = json!({});
347        mark_compact_elements(&mut empty_obj);
348        assert_eq!(empty_obj, json!({}));
349
350        let mut empty_arr = json!([]);
351        mark_compact_elements(&mut empty_arr);
352        assert_eq!(empty_arr, json!([]));
353    }
354}