/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ use icu_segmenter::GraphemeClusterSegmenter; use serde_json::{Map, value::Value}; use crate::{NimbusError, Result}; #[allow(dead_code)] pub fn fmt(template: &str, context: &T) -> Result { let obj: Value = match serde_json::to_value(context) { Ok(v) => v, Err(e) => { return Err(NimbusError::JSONError( "obj = nimbus::strings::fmt::serde_json::to_value".into(), e.to_string(), )); } }; fmt_with_value(template, &obj) } #[allow(dead_code)] pub fn fmt_with_value(template: &str, value: &Value) -> Result { if let Value::Object(map) = value { Ok(fmt_with_map(template, map)) } else { Err(NimbusError::EvaluationError( "Can only format json objects".to_string(), )) } } pub fn fmt_with_map(input: &str, context: &Map) -> String { let mut output = String::with_capacity(input.len()); let mut iter = iter_graphemes(input); let mut last_index = 0; // This is exceedingly simple; never refer to this as a parser. while let Some((index, c)) = iter.next() { if c == "{" { let open_index = index; for (index, c) in iter.by_ref() { if c == "}" { let close_index = index; let field_name = &input[open_index + 1..close_index]; // If we decided to embed JEXL into this templating language, // this would be the place to put it. // However, we'd likely want to make this be able to detect balanced braces, // which this does not. let replace_string = match context.get(field_name) { Some(Value::Bool(v)) => v.to_string(), Some(Value::String(v)) => v.to_string(), Some(Value::Number(v)) => v.to_string(), _ => format!("{{{v}}}", v = field_name), }; output.push_str(&input[last_index..open_index]); output.push_str(&replace_string); // +1 skips the closing } last_index = close_index + 1; break; } } } } output.push_str(&input[last_index..input.len()]); output } /// Iterate over graphemes /// /// Each iteration will yield the start index of the grapheme and it's content. /// /// This is intended to be equivalent to `grapheme_indices(true)` from the `unicode_segmentation` /// crate. fn iter_graphemes(input: &str) -> impl Iterator { let mut last_idx = None; let segmenter = GraphemeClusterSegmenter::new(); segmenter .segment_str(input) .filter_map(move |idx| { let next = last_idx.map(|last_idx| (last_idx, idx)); last_idx = Some(idx); next }) .map(|(last_idx, idx)| (last_idx, &input[last_idx..idx])) } #[cfg(test)] mod unit_tests { use serde_json::json; use super::*; #[test] fn smoke_tests() { let c = json!({ "string": "STRING".to_string(), "number": 42, "boolean": true, }); let c = c.as_object().unwrap(); assert_eq!( fmt_with_map("A {string}, a {number}, a {boolean}.", c), "A STRING, a 42, a true.".to_string() ); } #[test] fn test_unicode_boundaries() { let c = json!({ "empty": "".to_string(), "unicode": "a̐éö̲".to_string(), "a̐éö̲": "unicode".to_string(), }); let c = c.as_object().unwrap(); assert_eq!(fmt_with_map("fîré{empty}ƒøüX", c), "fîréƒøüX".to_string()); assert_eq!(fmt_with_map("a̐éö̲{unicode}a̐éö̲", c), "a̐éö̲a̐éö̲a̐éö̲".to_string()); assert_eq!( fmt_with_map("is this {a̐éö̲}?", c), "is this unicode?".to_string() ); } #[test] fn test_pathological_cases() { let c = json!({ "empty": "".to_string(), "foo}́": "foo-with-accented-brace", }); let c = c.as_object().unwrap(); assert_eq!( fmt_with_map("A {notthere}.", c), "A {notthere}.".to_string() ); assert_eq!( fmt_with_map("aa { unclosed", c), "aa { unclosed".to_string() ); // }́ shouldn't close the name since we only match on graphemes assert_eq!( fmt_with_map("aa {foo}́}", c), "aa foo-with-accented-brace".to_string() ); } }