config_disassembler/xml/parsers/
strip_whitespace.rs1use serde_json::{Map, Value};
4
5const 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
14pub 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 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
103pub 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 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 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 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 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 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 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 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 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 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 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 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 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}