far 0.2.1

Find And Replace string template engine
Documentation
#![doc = include_str!("../README.md")]
#![warn(missing_docs)]
#![warn(clippy::missing_docs_in_private_items)]

use std::marker::PhantomData;

/// Automatically implement [`Render`](far_shared::Render)
///
/// This can only be used on structs with named fields, for example:
///
/// ```
/// # use far_macros::Render;
/// # use far_shared::Render;
/// #[derive(Render)]
/// struct Replacements {
///     foo: String,
///     bar: usize,
/// }
/// ```
///
/// By default, the [`Display`](std::fmt::Display) impl will be used to
/// convert each field to a [`String`](std::string::String). However, this
/// can be changed using the `#[far]` field attribute:
///
/// ```
/// # use far_macros::Render;
/// # use far_shared::Render;
/// #[derive(Render)]
/// struct Replacements {
///     #[far(fmt = "{:?}")]
///     //          ^^^^^^
///     foo: String,
/// }
/// ```
///
/// The underlined section is passed directly to [`format!`](std::format)'s
/// first argument, so all of the options listed [here](std::fmt) are
/// available.
pub use far_macros::Render;
pub use far_shared::Render;

mod errors;
#[cfg(test)]
mod tests;

pub use errors::{Error, Errors};

/// A cached template
#[derive(Debug)]
pub struct Found<R> {
    /// The original string template
    inner: String,

    /// The keys to be replaced and their start and end byte positions
    replaces: Vec<(String, (usize, usize))>,

    /// The total byte length of all the key strings
    keys_size: usize,

    /// Remembers the [`Render`](far_shared::Render)able this template is for
    _replace: PhantomData<R>,
}

/// Strictness setting for parsing templates
#[derive(PartialEq, Eq, Hash, Debug, Clone, Copy)]
pub enum Mode {
    /// Keys available but missing in the template will cause an error
    Strict,

    /// Keys available but missing in the template will be ignored
    AllowMissing,
}

impl Default for Mode {
    fn default() -> Self {
        Self::Strict
    }
}

/// Find placeholder keys in a string and produce a [cached template][0]
///
/// This runs in [`Strict`][Mode::Strict] mode.
///
/// [0]: Found
pub fn find<S, R>(template: S) -> Result<Found<R>, Errors>
where
    S: AsRef<str>,
    R: Render,
{
    find_with_mode(template, Default::default())
}

/// [`find`][find], but with control over what causes an error
pub fn find_with_mode<S, R>(template: S, mode: Mode) -> Result<Found<R>, Errors>
where
    S: AsRef<str>,
    R: Render,
{
    let template = template.as_ref();

    // Stores (key, (key_start, key_end))
    let mut replaces = Vec::new();

    let mut errors = Vec::new();

    // Current position in the format string
    let mut cursor = 0;

    while cursor < template.len() {
        let start = if let Some(start) = (&template[cursor..]).find("{{") {
            cursor += start + "{{".len();
            cursor
        } else {
            // No more keys
            break;
        };

        if template[cursor..].starts_with("{{}}") {
            // This is a literal double left curly bracket
            cursor += "{{}}".len();
            replaces.push((
                // The extracted key
                "{{".to_owned(),
                (
                    // The beginning of a `{{key}}` expression
                    start - "{{".len(),
                    // The end of a `{{key}}` expression
                    start + "{{}}".len(),
                ),
            ));
            continue;
        } else if template[cursor..].starts_with("}}}}") {
            // This is a literal double right curly bracket
            cursor += "}}}}".len();
            replaces.push((
                // The extracted key
                "}}".to_owned(),
                (
                    // The beginning of a `{{key}}` expression
                    start - "{{".len(),
                    // The end of a `{{key}}` expression
                    start + "}}}}".len(),
                ),
            ));
            continue;
        }

        let end = if let Some(end) = (&template[cursor..]).find("}}") {
            cursor += end + "}}".len();
            cursor
        } else {
            errors.push(Error::Unclosed(start));

            // Bail immediately: if there's an unclosed delimiter, then
            // we basically can't guess about what provided key-value
            // pairs are needed
            return Err(errors.into());
        };

        let key = template[start..(end - "}}".len())].to_owned();

        replaces.push((
            // The extracted key
            key,
            (
                // The beginning of a `{{key}}` expression
                start - "{{".len(),
                // The end of a `{{key}}` expression
                end,
            ),
        ));
    }

    let mut warnings = Vec::new();

    for pk in R::keys() {
        if !replaces.iter().any(|(tk, (..))| tk == pk) {
            if mode == Mode::AllowMissing {
                warnings.push(Error::Missing(pk.to_string()));
            } else {
                errors.push(Error::Missing(pk.to_string()));
            }
        }
    }

    if !warnings.is_empty() && mode == Mode::AllowMissing {
        // Log the missing keys
        let warnings = Errors::from(warnings);
        tracing::debug!("{}", warnings);
    }

    // Wait on bailing out if there are errors so we can display all the errors
    // at once instead of making the user have to try to fix it twice.

    // Calculate the amount of space the keys take in the original text
    let mut keys_size = 0;

    for (tk, _) in &replaces {
        if R::keys().any(|pk| (pk == tk || tk == "{{" || tk == "}}")) {
            keys_size += tk.len();
        } else {
            errors.push(Error::Extra((*tk).to_string()));
        }
    }

    // If there were errors, bail out
    if !errors.is_empty() {
        return Err(errors.into());
    }

    Ok(Found {
        inner: template.to_owned(),
        replaces,
        keys_size,
        _replace: PhantomData,
    })
}

impl<R> Found<R>
where
    R: Render,
{
    /// Perform the replacement
    ///
    /// This substitutes all of the keys with their values, producing a rendered
    /// template.
    pub fn replace(&self, replacements: &R) -> String
    where
        R: Render,
    {
        let rendered = replacements.render();

        let values_size = rendered.iter().fold(0, |acc, (k, v)| {
            let occurrence_count =
                self.replaces.iter().fold(0, |acc, (rk, _)| {
                    if rk == k {
                        acc + 1
                    } else {
                        acc
                    }
                });

            occurrence_count * (acc + v.len())
        });

        let final_size = (self.inner.len()
            - ("{{}}".len() * self.replaces.len()))
            + values_size
            - self.keys_size
            + self.replaces.iter().fold(0, |acc, (key, _)| {
                match key.as_str() {
                    "}}" => acc + "}}".len(),
                    "{{" => acc + "{{".len(),
                    _ => acc,
                }
            });

        let mut replaced = String::with_capacity(final_size);

        let mut cursor = 0;

        for (key, (start, end)) in self.replaces.iter() {
            replaced.push_str(&self.inner[cursor..(*start)]);

            // TODO Swap in the literal values in `find_with_mode` to decrease
            // the amount of work this function has to do, since this one is
            // likely to be called in relatively tight loops.
            let replacement = match key.as_str() {
                "}}" => "}}",
                "{{" => "{{",

                // Unwrapping should be safe at this point because we should
                // have caught it while calculating replace_size.
                key => rendered.get(key).unwrap(),
            };

            replaced.push_str(replacement);

            cursor = *end;
        }

        // If there's more text after the final `{{}}`
        if cursor < self.inner.len() {
            replaced.push_str(&self.inner[cursor..]);
        }

        #[cfg(test)]
        assert_eq!(replaced.len(), final_size);

        replaced
    }
}