neutralts/utils.rs
1use crate::constants::*;
2use serde_json::Value;
3use std::borrow::Cow;
4
5/// Merges two JSON schemas represented as `serde_json::Value`.
6///
7/// This function performs a recursive merge between two JSON objects.
8/// If an object has common keys, the values are merged recursively.
9/// If the value is not an object, it is directly overwritten.
10///
11/// # Arguments
12///
13/// * `a` - A mutable reference to the first JSON object (`serde_json::Value::Object`).
14/// * `b` - A reference to the second JSON object (`serde_json::Value::Object`) that will be merged with the first.
15///
16/// # Example
17///
18/// ```text
19/// use serde_json::{json, Value};
20///
21/// let mut schema1 = json!({
22/// "name": "John",
23/// "age": 30,
24/// });
25///
26/// let schema2 = json!({
27/// "age": 31,
28/// "city": "New York"
29/// });
30///
31/// merge_schema(&mut schema1, &schema2);
32/// assert_eq!(schema1, json!({
33/// "name": "John",
34/// "age": 31,
35/// "city": "New York"
36/// }));
37/// ```
38pub fn merge_schema(a: &mut Value, b: &Value) {
39 match (a, b) {
40 (Value::Object(a_map), Value::Object(b_map)) => {
41 for (k, v) in b_map {
42 if let Some(va) = a_map.get_mut(k) {
43 merge_schema(va, v);
44 } else {
45 a_map.insert(k.clone(), v.clone());
46 }
47 }
48 }
49 (a, b) => *a = b.clone(),
50 }
51}
52
53/// Same as merge_schema but takes ownership of `b` to avoid clones.
54/// Use this when you don't need `b` after the merge.
55pub fn merge_schema_owned(a: &mut Value, b: Value) {
56 match (a, b) {
57 (Value::Object(a_map), Value::Object(b_map)) => {
58 for (k, v) in b_map {
59 match a_map.entry(k) {
60 serde_json::map::Entry::Occupied(mut entry) => {
61 merge_schema_owned(entry.get_mut(), v);
62 }
63 serde_json::map::Entry::Vacant(entry) => {
64 entry.insert(v);
65 }
66 }
67 }
68 }
69 (a, b) => *a = b,
70 }
71}
72
73/// Merge schema and update some keys
74///
75/// This is a thin wrapper around `merge_schema` that additionally:
76/// 1. Copies the value of the header key `requested-with-ajax` (all lower-case) into the
77/// variants `Requested-With-Ajax` (Pascal-Case) and `REQUESTED-WITH-AJAX` (upper-case),
78/// or vice-versa, depending on which variant is present in the incoming schema.
79/// 2. Overwrites the top-level `version` field with the compile-time constant `VERSION`.
80///
81/// The three header variants are created so that downstream code can read the header
82/// regardless of the casing rules enforced by the environment (HTTP servers, proxies, etc.).
83///
84/// # Arguments
85/// * `a` – the target `Value` (must be an `Object`) that will receive the merge result.
86/// * `b` – the source `Value` (must be an `Object`) whose contents are merged into `a`.
87///
88pub fn update_schema(a: &mut Value, b: &Value) {
89 merge_schema(a, b);
90
91 // Different environments may ignore or add capitalization in headers
92 if let Some(headers) = a
93 .get_mut("data")
94 .and_then(|d| d.get_mut("CONTEXT"))
95 .and_then(|c| c.get_mut("HEADERS"))
96 .and_then(|h| h.as_object_mut())
97 {
98 if let Some(val) = headers.get("requested-with-ajax").cloned() {
99 headers.insert("Requested-With-Ajax".to_string(), val.clone());
100 headers.insert("REQUESTED-WITH-AJAX".to_string(), val);
101 } else if let Some(val) = headers.get("Requested-With-Ajax").cloned() {
102 headers.insert("requested-with-ajax".to_string(), val.clone());
103 headers.insert("REQUESTED-WITH-AJAX".to_string(), val);
104 } else if let Some(val) = headers.get("REQUESTED-WITH-AJAX").cloned() {
105 headers.insert("requested-with-ajax".to_string(), val.clone());
106 headers.insert("Requested-With-Ajax".to_string(), val);
107 }
108 }
109
110 // Update version
111 if let Some(obj) = a.as_object_mut() {
112 obj.insert("version".to_string(), VERSION.into());
113 } else {
114 a["version"] = VERSION.into();
115 }
116}
117
118/// Same as update_schema but takes ownership of `b` to avoid clones.
119pub fn update_schema_owned(a: &mut Value, b: Value) {
120 merge_schema_owned(a, b);
121
122 // Different environments may ignore or add capitalization in headers
123 if let Some(headers) = a
124 .get_mut("data")
125 .and_then(|d| d.get_mut("CONTEXT"))
126 .and_then(|c| c.get_mut("HEADERS"))
127 .and_then(|h| h.as_object_mut())
128 {
129 if let Some(val) = headers.get("requested-with-ajax").cloned() {
130 headers.insert("Requested-With-Ajax".to_string(), val.clone());
131 headers.insert("REQUESTED-WITH-AJAX".to_string(), val);
132 } else if let Some(val) = headers.get("Requested-With-Ajax").cloned() {
133 headers.insert("requested-with-ajax".to_string(), val.clone());
134 headers.insert("REQUESTED-WITH-AJAX".to_string(), val);
135 } else if let Some(val) = headers.get("REQUESTED-WITH-AJAX").cloned() {
136 headers.insert("requested-with-ajax".to_string(), val.clone());
137 headers.insert("Requested-With-Ajax".to_string(), val);
138 }
139 }
140
141 // Update version
142 if let Some(obj) = a.as_object_mut() {
143 obj.insert("version".to_string(), VERSION.into());
144 } else {
145 a["version"] = VERSION.into();
146 }
147}
148
149/// Extract same level blocks positions.
150///
151/// ```text
152///
153/// .-----> .-----> {:code:
154/// | | {:code: ... :}
155/// | | {:code: ... :}
156/// | | {:code: ... :}
157/// Level block --> | ·-----> :}
158/// | -----> {:code: ... :}
159/// | .-----> {:code:
160/// | | {:code: ... :}
161/// ·-----> ·-----> :}
162///
163/// # Arguments
164///
165/// * `raw_source` - A string slice containing the template source text.
166///
167/// # Returns
168///
169/// * `Ok(Vec<(usize, usize)>)`: A vector of tuples representing the start and end positions of each extracted block.
170/// * `Err(usize)`: An error position if there are unmatched closing tags or other issues
171/// ```
172pub fn extract_blocks(raw_source: &str) -> Result<Vec<(usize, usize)>, usize> {
173 let mut blocks = Vec::new();
174 let mut curr_pos: usize = 0;
175 let len_src = raw_source.len();
176 let bytes = raw_source.as_bytes();
177
178 while let Some(pos) = raw_source[curr_pos..].find(BIF_OPEN) {
179 let open_pos = curr_pos + pos;
180 let start_body = open_pos + BIF_OPEN.len();
181 curr_pos = start_body;
182
183 if curr_pos < len_src && bytes[curr_pos] == BIF_COMMENT_B {
184 let mut nested_comment = 0;
185 let mut search_pos = curr_pos;
186 while let Some(delim_pos_rel) = raw_source[search_pos..].find(':') {
187 let delim_pos = search_pos + delim_pos_rel;
188 if delim_pos > 0 && delim_pos + 1 < len_src {
189 let prev = bytes[delim_pos - 1];
190 let next = bytes[delim_pos + 1];
191
192 if prev == BIF_OPEN0 && next == BIF_COMMENT_B {
193 nested_comment += 1;
194 search_pos = delim_pos + 1;
195 continue;
196 }
197 if nested_comment > 0 && prev == BIF_COMMENT_B && next == BIF_CLOSE1 {
198 nested_comment -= 1;
199 search_pos = delim_pos + 1;
200 continue;
201 }
202 if prev == BIF_COMMENT_B && next == BIF_CLOSE1 {
203 curr_pos = delim_pos + BIF_CLOSE.len();
204 blocks.push((open_pos, curr_pos));
205 break;
206 }
207 }
208 search_pos = delim_pos + 1;
209 }
210 } else {
211 let mut nested = 0;
212 let mut search_pos = curr_pos;
213 while let Some(delim_pos_rel) = raw_source[search_pos..].find(':') {
214 let delim_pos = search_pos + delim_pos_rel;
215 if delim_pos > 0 && delim_pos + 1 < len_src {
216 let prev = bytes[delim_pos - 1];
217 let next = bytes[delim_pos + 1];
218
219 if prev == BIF_OPEN0 {
220 nested += 1;
221 search_pos = delim_pos + 1;
222 continue;
223 }
224 if nested > 0 && next == BIF_CLOSE1 {
225 nested -= 1;
226 search_pos = delim_pos + 1;
227 continue;
228 }
229 if next == BIF_CLOSE1 {
230 curr_pos = delim_pos + BIF_CLOSE.len();
231 blocks.push((open_pos, curr_pos));
232 break;
233 }
234 }
235 search_pos = delim_pos + 1;
236 }
237 }
238 }
239
240 let mut prev_end = 0;
241 for (start, end) in &blocks {
242 if let Some(pos) = raw_source[prev_end..*start].find(BIF_CLOSE) {
243 return Err(prev_end + pos);
244 }
245 prev_end = *end;
246 }
247
248 if let Some(pos) = raw_source[prev_end..].find(BIF_CLOSE) {
249 return Err(prev_end + pos);
250 }
251
252 Ok(blocks)
253}
254
255/// Removes a prefix and suffix from a string slice.
256///
257/// # Arguments
258///
259/// * `str`: The input string slice.
260/// * `prefix`: The prefix to remove.
261/// * `suffix`: The suffix to remove.
262///
263/// # Returns
264///
265/// * A new string slice with the prefix and suffix removed, or the original string if not found.
266pub fn strip_prefix_suffix<'a>(str: &'a str, prefix: &'a str, suffix: &'a str) -> &'a str {
267 let start = match str.strip_prefix(prefix) {
268 Some(striped) => striped,
269 None => return str,
270 };
271 let end = match start.strip_suffix(suffix) {
272 Some(striped) => striped,
273 None => return str,
274 };
275
276 end
277}
278
279/// Retrieves a value from a JSON schema using a specified key.
280///
281/// # Arguments
282///
283/// * `schema`: A reference to the JSON schema as a `Value`.
284/// * `key`: The key used to retrieve the value from the schema.
285///
286/// # Returns
287///
288/// * A `String` containing the retrieved value, or an empty string if the key is not found.
289pub fn get_from_key(schema: &Value, key: &str) -> String {
290 if let Some(v) = resolve_pointer(schema, key) {
291 match v {
292 Value::Null => String::new(),
293 Value::Bool(b) => b.to_string(),
294 Value::Number(n) => n.to_string(),
295 Value::String(s) => s.clone(),
296 _ => String::new(),
297 }
298 } else {
299 String::new()
300 }
301}
302
303/// Checks if the value associated with a key in the schema is considered empty.
304///
305/// # Arguments
306///
307/// * `schema`: A reference to the JSON schema as a `Value`.
308/// * `key`: The key used to check the value in the schema.
309///
310/// # Returns
311///
312/// * `true` if the value is considered empty, otherwise `false`.
313pub fn is_empty_key(schema: &Value, key: &str) -> bool {
314 if let Some(value) = resolve_pointer(schema, key) {
315 match value {
316 Value::Object(map) => map.is_empty(),
317 Value::Array(arr) => arr.is_empty(),
318 Value::String(s) => s.is_empty(),
319 Value::Null => true,
320 Value::Number(_) => false,
321 Value::Bool(_) => false,
322 }
323 } else {
324 true
325 }
326}
327
328/// Checks if the value associated with a key in the schema is considered a boolean true.
329///
330/// # Arguments
331///
332/// * `schema`: A reference to the JSON schema as a `Value`.
333/// * `key`: The key used to check the value in the schema.
334///
335/// # Returns
336///
337/// * `true` if the value is considered a boolean true, otherwise `false`.
338pub fn is_bool_key(schema: &Value, key: &str) -> bool {
339 if let Some(value) = resolve_pointer(schema, key) {
340 match value {
341 Value::Object(obj) => !obj.is_empty(),
342 Value::Array(arr) => !arr.is_empty(),
343 Value::String(s) if s.is_empty() || s == "false" => false,
344 Value::String(s) => s.parse::<f64>().ok().map_or(true, |n| n > 0.0),
345 Value::Null => false,
346 Value::Number(n) => n.as_f64().map_or(false, |f| f > 0.0),
347 Value::Bool(b) => *b,
348 }
349 } else {
350 false
351 }
352}
353
354/// Checks if the value associated with a key in the schema is considered an array.
355///
356/// # Arguments
357///
358/// * `schema`: A reference to the JSON schema as a `Value`.
359/// * `key`: The key used to check the value in the schema.
360///
361/// # Returns
362///
363/// * `true` if the value is an array, otherwise `false`.
364pub fn is_array_key(schema: &Value, key: &str) -> bool {
365 if let Some(value) = resolve_pointer(schema, key) {
366 match value {
367 Value::Object(_) => true,
368 Value::Array(_) => true,
369 _ => false,
370 }
371 } else {
372 false
373 }
374}
375
376/// Checks if the value associated with a key in the schema is considered defined.
377///
378/// # Arguments
379///
380/// * `schema`: A reference to the JSON schema as a `Value`.
381/// * `key`: The key used to check the value in the schema.
382///
383/// # Returns
384///
385/// * `true` if the value is defined and not null, otherwise `false`.
386pub fn is_defined_key(schema: &Value, key: &str) -> bool {
387 match resolve_pointer(schema, key) {
388 Some(value) => !value.is_null(),
389 None => false,
390 }
391}
392
393/// Helper function to resolve a pointer-like key (e.g., "a->b->0") in a JSON Value.
394pub(crate) fn resolve_pointer<'a>(schema: &'a Value, key: &str) -> Option<&'a Value> {
395 if !key.contains(BIF_ARRAY) && !key.contains('/') {
396 return schema.get(key);
397 }
398
399 let mut current = schema;
400 let mut start = 0;
401 let bytes = key.as_bytes();
402 let len = bytes.len();
403
404 let bif_array_bytes = BIF_ARRAY.as_bytes();
405 let delim_len = bif_array_bytes.len();
406
407 let mut i = 0;
408 while i < len {
409 let is_slash = bytes[i] == b'/';
410 let is_arrow =
411 !is_slash && i + delim_len <= len && &bytes[i..i + delim_len] == bif_array_bytes;
412
413 if is_slash || is_arrow {
414 let part = &key[start..i];
415 if !part.is_empty() {
416 current = match current {
417 Value::Object(map) => map.get(part)?,
418 Value::Array(arr) => {
419 let idx = part.parse::<usize>().ok()?;
420 arr.get(idx)?
421 }
422 _ => return None,
423 };
424 }
425 if is_slash {
426 i += 1;
427 start = i;
428 } else {
429 i += delim_len;
430 start = i;
431 }
432 } else {
433 i += 1;
434 }
435 }
436
437 if start < len {
438 let part = &key[start..];
439 current = match current {
440 Value::Object(map) => map.get(part)?,
441 Value::Array(arr) => {
442 let idx = part.parse::<usize>().ok()?;
443 arr.get(idx)?
444 }
445 _ => return None,
446 };
447 }
448
449 Some(current)
450}
451
452/// Finds the position of the first occurrence of BIF_CODE_B in the source string,
453/// but only when it is not inside any nested brackets.
454///
455/// ```text
456/// .------------------------------> params
457/// | .----------------------> this
458/// | |
459/// | | .----> code
460/// | | |
461/// v v v
462/// ------------ -- ------------------------------
463/// {:!snippet; snippet_name >> <div>... {:* ... *:} ...</div> :}
464pub fn get_code_position(src: &str) -> Option<usize> {
465 if !src.contains(BIF_CODE) {
466 return None;
467 }
468
469 let mut level = 0;
470 let bytes = src.as_bytes();
471 let len = bytes.len();
472 let mut i = 0;
473
474 while i + 1 < len {
475 let b0 = bytes[i];
476 let b1 = bytes[i + 1];
477
478 if b0 == BIF_OPEN_B[0] && b1 == BIF_OPEN_B[1] {
479 level += 1;
480 i += 2;
481 } else if b0 == BIF_CLOSE_B[0] && b1 == BIF_CLOSE_B[1] {
482 level -= 1;
483 i += 2;
484 } else if b0 == BIF_CODE_B[0] && b1 == BIF_CODE_B[1] {
485 if level == 0 {
486 return Some(i);
487 }
488 i += 2;
489 } else {
490 i += 1;
491 }
492 }
493
494 None
495}
496
497/// Removes comments from the template source.
498pub fn remove_comments(raw_source: &str) -> String {
499 let mut result = String::new();
500 let mut blocks = Vec::new();
501 let bytes = raw_source.as_bytes();
502 let mut curr_pos: usize = 0;
503 let mut open_pos: usize;
504 let mut nested_comment = 0;
505 let len_open = BIF_COMMENT_OPEN_B.len();
506 let len_close = BIF_CLOSE_B.len();
507 let len_src = bytes.len();
508
509 while let Some(rel_pos) = raw_source[curr_pos..].find(BIF_COMMENT_OPEN) {
510 let absolute_pos = curr_pos + rel_pos;
511 curr_pos = absolute_pos + len_open;
512 open_pos = absolute_pos;
513
514 while let Some(delim_pos_rel) = raw_source[curr_pos..].find(BIF_DELIM) {
515 curr_pos += delim_pos_rel;
516
517 if curr_pos >= len_src {
518 break;
519 }
520
521 if bytes[curr_pos - 1] == BIF_OPEN0 && bytes[curr_pos + 1] == BIF_COMMENT_B {
522 nested_comment += 1;
523 curr_pos += 1;
524 continue;
525 }
526 if nested_comment > 0
527 && bytes[curr_pos + 1] == BIF_CLOSE1
528 && bytes[curr_pos - 1] == BIF_COMMENT_B
529 {
530 nested_comment -= 1;
531 curr_pos += 1;
532 continue;
533 }
534 if bytes[curr_pos + 1] == BIF_CLOSE1 && bytes[curr_pos - 1] == BIF_COMMENT_B {
535 curr_pos += len_close;
536 blocks.push((open_pos, curr_pos));
537 break;
538 } else {
539 curr_pos += 1;
540 }
541 }
542 }
543
544 let mut prev_end = 0;
545 for (start, end) in &blocks {
546 result.push_str(&raw_source[prev_end..*start]);
547 prev_end = *end;
548 }
549 result.push_str(&raw_source[curr_pos..]);
550
551 result
552}
553
554/// Performs a wildcard matching between a text and a pattern.
555///
556/// Used in bif "allow" and "declare"
557///
558/// # Arguments
559///
560/// * `text`: The text to match against the pattern.
561/// * `pattern`: The pattern containing wildcards ('.', '?', '*', '~').
562///
563/// # Returns
564///
565/// * `true` if the text matches the pattern, otherwise `false`.
566pub fn wildcard_match(text: &str, pattern: &str) -> bool {
567 let text_chars: Vec<char> = text.chars().collect();
568 let pattern_chars: Vec<char> = pattern.chars().collect();
569
570 fn match_recursive(text: &[char], pattern: &[char]) -> bool {
571 if pattern.is_empty() {
572 return text.is_empty();
573 }
574
575 let first_char = *pattern.first().unwrap();
576 let rest_pattern = &pattern[1..];
577
578 match first_char {
579 '\\' => {
580 if rest_pattern.is_empty() || text.is_empty() {
581 return false;
582 }
583 let escaped_char = rest_pattern.first().unwrap();
584 match_recursive(&text[1..], &rest_pattern[1..])
585 && *text.first().unwrap() == *escaped_char
586 }
587 '.' => {
588 match_recursive(text, rest_pattern)
589 || (!text.is_empty() && match_recursive(&text[1..], rest_pattern))
590 }
591 '?' => !text.is_empty() && match_recursive(&text[1..], rest_pattern),
592 '*' => {
593 match_recursive(text, rest_pattern)
594 || (!text.is_empty() && match_recursive(&text[1..], pattern))
595 }
596 '~' => text.is_empty(),
597 _ => {
598 if text.is_empty() || first_char != *text.first().unwrap() {
599 false
600 } else {
601 match_recursive(&text[1..], rest_pattern)
602 }
603 }
604 }
605 }
606
607 match_recursive(&text_chars, &pattern_chars)
608}
609
610/// Finds the position of a tag in the text.
611///
612/// It is used in the bif "moveto".
613///
614/// # Arguments
615///
616/// * `text`: The text to search for the tag.
617/// * `tag`: The tag to find.
618///
619/// # Returns
620///
621/// * `Some(usize)`: The position of the end of the tag, or None if the tag is not found.
622pub fn find_tag_position(text: &str, tag: &str) -> Option<usize> {
623 if let Some(start_pos) = text.find(tag) {
624 if !tag.starts_with("</") {
625 if let Some(end_tag_pos) = text[start_pos..].find('>') {
626 return Some(start_pos + end_tag_pos + 1);
627 }
628 } else {
629 return Some(start_pos);
630 }
631 }
632
633 None
634}
635
636/// Escapes special characters in a given input string.
637///
638/// This function replaces specific ASCII characters with their corresponding HTML entities.
639/// It is designed to handle both general HTML escaping and optional escaping of curly braces (`{` and `}`).
640///
641/// # Arguments
642///
643/// * `input` - The input string to escape.
644/// * `escape_braces` - A boolean flag indicating whether to escape curly braces (`{` and `}`).
645/// - If `true`, curly braces are escaped as `{` and `}`.
646/// - If `false`, curly braces are left unchanged.
647///
648/// # Escaped Characters
649///
650/// The following characters are always escaped:
651/// - `&` → `&`
652/// - `<` → `<`
653/// - `>` → `>`
654/// - `"` → `"`
655/// - `'` → `'`
656/// - `/` → `/`
657///
658/// If `escape_braces` is `true`, the following characters are also escaped:
659/// - `{` → `{`
660/// - `}` → `}`
661///
662/// # Examples
663///
664/// Basic usage without escaping curly braces:
665/// ```text
666/// let input = r#"Hello, <world> & "friends"! {example}"#;
667/// let escaped = escape_chars(input, false);
668/// assert_eq!(escaped, r#"Hello, <world> & "friends"! {example}"#);
669/// ```
670///
671/// Escaping curly braces:
672/// ```text
673/// let input = r#"Hello, <world> & "friends"! {example}"#;
674/// let escaped = escape_chars(input, true);
675/// assert_eq!(escaped, r#"Hello, <world> & "friends"! {example}"#);
676/// ```
677pub fn escape_chars<'a>(input: &'a str, escape_braces: bool) -> Cow<'a, str> {
678 let needs_escape = input.chars().any(|c| match c {
679 '&' | '<' | '>' | '"' | '\'' | '/' => true,
680 '{' | '}' if escape_braces => true,
681 _ => false,
682 });
683
684 if !needs_escape {
685 return Cow::Borrowed(input);
686 }
687
688 let mut result = String::with_capacity(input.len() * 2);
689
690 for c in input.chars() {
691 if c.is_ascii() {
692 match c {
693 '&' => result.push_str("&"),
694 '<' => result.push_str("<"),
695 '>' => result.push_str(">"),
696 '"' => result.push_str("""),
697 '\'' => result.push_str("'"),
698 '/' => result.push_str("/"),
699 '{' if escape_braces => result.push_str("{"),
700 '}' if escape_braces => result.push_str("}"),
701 _ => result.push(c),
702 }
703 } else {
704 result.push(c);
705 }
706 }
707 Cow::Owned(result)
708}
709
710/// Unescapes HTML entities in a given input string.
711///
712/// This function is designed specifically to reverse the escaping performed by `escape_chars`.
713/// It is not intended to be a general-purpose HTML decoder. It replaces the following HTML
714/// entities with their corresponding characters:
715/// - `&` → `&`
716/// - `<` → `<`
717/// - `>` → `>`
718/// - `"` → `"`
719/// - `'` → `'`
720/// - `/` → `/`
721///
722/// If `escape_braces` is `true`, it also replaces:
723/// - `{` → `{`
724/// - `}` → `}`
725///
726/// If an unrecognized entity is encountered, it is left unchanged in the output.
727///
728/// # Arguments
729///
730/// * `input` - The input string containing HTML entities to unescape.
731/// * `escape_braces` - A boolean flag indicating whether to unescape curly braces (`{` and `}`).
732/// - If `true`, `{` and `}` are unescaped to `{` and `}`.
733/// - If `false`, `{` and `}` are left unchanged.
734///
735/// # Examples
736///
737/// Basic usage:
738/// ```text
739/// let input = "<script>alert("Hello & 'World'");</script>";
740/// let unescaped = unescape_chars(input, false);
741/// assert_eq!(unescaped, r#"<script>alert("Hello & 'World'");</script>"#);
742/// ```
743///
744/// Unescaping curly braces:
745/// ```text
746/// let input = "{example}";
747/// let unescaped = unescape_chars(input, true);
748/// assert_eq!(unescaped, "{example}");
749/// ```
750///
751/// Unrecognized entities are preserved:
752/// ```text
753/// let input = "This is an &unknown; entity.";
754/// let unescaped = unescape_chars(input, false);
755/// assert_eq!(unescaped, "This is an &unknown; entity.");
756/// ```
757pub fn unescape_chars<'a>(input: &'a str, escape_braces: bool) -> Cow<'a, str> {
758 if !input.contains('&') {
759 return Cow::Borrowed(input);
760 }
761 let mut result = String::with_capacity(input.len());
762 let mut chars = input.chars().peekable();
763 while let Some(c) = chars.next() {
764 if c == '&' {
765 let mut entity = String::new();
766 let mut has_semicolon = false;
767 while let Some(&next_char) = chars.peek() {
768 if next_char == ';' {
769 chars.next();
770 has_semicolon = true;
771 break;
772 }
773 entity.push(chars.next().unwrap());
774 }
775 match (entity.as_str(), has_semicolon) {
776 ("amp", true) => result.push('&'),
777 ("lt", true) => result.push('<'),
778 ("gt", true) => result.push('>'),
779 ("quot", true) => result.push('"'),
780 ("#x27", true) => result.push('\''),
781 ("#x2F", true) => result.push('/'),
782 ("#123", true) if escape_braces => result.push('{'),
783 ("#125", true) if escape_braces => result.push('}'),
784 _ => {
785 result.push('&');
786 result.push_str(&entity);
787 if has_semicolon {
788 result.push(';');
789 }
790 }
791 }
792 } else {
793 result.push(c);
794 }
795 }
796 Cow::Owned(result)
797}
798
799/// Recursively filter a Value with the function escape_chars
800///
801/// # Arguments
802/// * `value` - A mutable reference to a JSON `Value`. It can be a string (`String`),
803/// an object (`Object`), or an array (`Array`).
804///
805pub fn filter_value(value: &mut Value) {
806 match value {
807 Value::String(s) => {
808 // First unescape, then escape - only allocate if changes are needed
809 let unescaped = unescape_chars(s, true);
810 let processed = match unescaped {
811 Cow::Borrowed(_) => escape_chars(s, true),
812 Cow::Owned(ref u) => escape_chars(u, true),
813 };
814 if let Cow::Owned(new_s) = processed {
815 *s = new_s;
816 }
817 }
818 Value::Object(obj) => {
819 for v in obj.values_mut() {
820 filter_value(v);
821 }
822 }
823 Value::Array(arr) => {
824 for item in arr.iter_mut() {
825 filter_value(item);
826 }
827 }
828 _ => {}
829 }
830}
831
832/// Recursively filters the keys (names) of a Value with the function escape_chars
833///
834/// # Arguments
835/// * `value` - A mutable reference to a JSON `Value`. It can be a string (`String`),
836/// an object (`Object`), or an array (`Array`).
837///
838pub fn filter_value_keys(value: &mut Value) {
839 match value {
840 Value::Object(obj) => {
841 // Check if any key needs escaping
842 let needs_change = obj.keys().any(|k| {
843 k.contains('&')
844 || k.chars()
845 .any(|c| matches!(c, '&' | '<' | '>' | '"' | '\'' | '/' | '{' | '}'))
846 });
847
848 if !needs_change {
849 // No key changes needed, just recurse into values
850 for val in obj.values_mut() {
851 filter_value_keys(val);
852 }
853 return;
854 }
855
856 // Keys need changes, create new Map with escaped keys
857 let mut new_obj = serde_json::Map::with_capacity(obj.len());
858 for (key, val) in obj.iter_mut() {
859 let unescaped = unescape_chars(key, true);
860 let processed = match unescaped {
861 Cow::Borrowed(_) => escape_chars(key, true),
862 Cow::Owned(ref u) => escape_chars(u, true),
863 };
864 let new_key = match processed {
865 Cow::Borrowed(b) => b.to_string(),
866 Cow::Owned(o) => o,
867 };
868 filter_value_keys(val);
869 new_obj.insert(new_key, std::mem::take(val));
870 }
871 *obj = new_obj;
872 }
873 Value::Array(arr) => {
874 for item in arr.iter_mut() {
875 filter_value_keys(item);
876 }
877 }
878 _ => {}
879 }
880}