diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index d4ef355..15c42dd 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -22,14 +22,14 @@ jobs: uses: actions-rs/cargo@v1 with: command: build - args: --workspace --all-targets --all-features --color=always + args: --workspace --all-targets --features toml,yaml --color=always - name: Test uses: actions-rs/cargo@v1 with: command: test - args: --workspace --all-targets --all-features --color=always + args: --workspace --all-targets --features toml,yaml --color=always - name: Clippy uses: actions-rs/clippy-check@v1 with: token: ${{ secrets.GITHUB_TOKEN }} - args: --workspace --all-targets --all-features + args: --workspace --all-targets --features toml,yaml diff --git a/CHANGELOG b/CHANGELOG index 6fb9502..2237a09 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,6 @@ +# Unreleased +- [add][minor] Add support for substitution in all string values of TOML data. + # Version 0.3.2 - 2024-06-25 - [add][minor] Allow re-use of parsed templates with `Template`, `TemplateBuf`, `ByteTemplate` and `ByteTemplateBuf`. diff --git a/Cargo.toml b/Cargo.toml index 65d1051..cc13ec9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,17 +14,21 @@ categories = ["template-engine", "value-formatting"] edition = "2021" [features] +toml = ["dep:serde", "dep:toml"] yaml = ["dep:serde", "dep:serde_yaml"] +preserve-order = ["toml?/preserve_order"] +doc-cfg = [] [dependencies] memchr = "2.4.1" serde = { version = "1.0.0", optional = true } serde_yaml = { version = "0.9.13", optional = true } +toml = { version = "0.8.14", optional = true } unicode-width = "0.1.9" [dev-dependencies] assert2 = "0.3.6" -subst = { path = ".", features = ["yaml"] } +subst = { path = ".", features = ["toml", "yaml"] } serde = { version = "1.0.0", features = ["derive"] } [package.metadata.docs.rs] diff --git a/README.md b/README.md index 9b6ed4d..24a29b0 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Shell-like variable substitution for strings and byte strings. * Long format: `"Hello ${name}!"` * Default values: `"Hello ${name:person}!"` * Recursive substitution in default values: `"${XDG_CONFIG_HOME:$HOME/.config}/my-app/config.toml"` -* Perform substitution on all string values in YAML data (optional, requires the `yaml` feature). +* Perform substitution on all string values in TOML or YAML data (optional, requires the `toml` or `yaml` feature). Variable names can consist of alphanumeric characters and underscores. They are allowed to start with numbers. diff --git a/src/lib.rs b/src/lib.rs index ec8c150..057facd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,7 +8,7 @@ //! * Long format: `"Hello ${name}!"` //! * Default values: `"Hello ${name:person}!"` //! * Recursive substitution in default values: `"${XDG_CONFIG_HOME:$HOME/.config}/my-app/config.toml"` -//! * Perform substitution on all string values in YAML data (optional, requires the `yaml` feature). +//! * Perform substitution on all string values in TOML or YAML data (optional, requires the `toml` or `yaml` feature). //! //! Variable names can consist of alphanumeric characters and underscores. //! They are allowed to start with numbers. @@ -79,7 +79,9 @@ //! # Ok(()) //! # } //! ``` + #![warn(missing_docs, missing_debug_implementations)] +#![cfg_attr(feature = "doc-cfg", feature(doc_cfg))] pub mod error; pub use error::Error; @@ -91,8 +93,13 @@ mod template; pub use template::*; #[cfg(feature = "yaml")] +#[cfg_attr(feature = "doc-cfg", doc(cfg(feature = "yaml")))] pub mod yaml; +#[cfg(feature = "toml")] +#[cfg_attr(feature = "doc-cfg", doc(cfg(feature = "toml")))] +pub mod toml; + /// Substitute variables in a string. /// /// Variables have the form `$NAME`, `${NAME}` or `${NAME:default}`. diff --git a/src/toml.rs b/src/toml.rs new file mode 100644 index 0000000..c412a8a --- /dev/null +++ b/src/toml.rs @@ -0,0 +1,228 @@ +//! Support for variable substitution in TOML data. + +use serde::de::DeserializeOwned; + +use crate::VariableMap; + +/// Parse a struct from TOML data, after performing variable substitution on string values. +/// +/// This function first parses the data into a [`toml::Value`], +/// then performs variable substitution on all string values, +/// and then parses it further into the desired type. +pub fn from_slice<'a, T: DeserializeOwned, M>(data: &[u8], variables: &'a M) -> Result +where + M: VariableMap<'a> + ?Sized, + M::Value: AsRef, +{ + from_str(std::str::from_utf8(data)?, variables) +} + +/// Parse a struct from TOML data, after performing variable substitution on string values. +/// +/// This function first parses the data into a [`toml::Value`], +/// then performs variable substitution on all string values, +/// and then parses it further into the desired type. +pub fn from_str<'a, T: DeserializeOwned, M>(data: &str, variables: &'a M) -> Result +where + M: VariableMap<'a> + ?Sized, + M::Value: AsRef, +{ + let mut value: toml::Value = toml::from_str(data)?; + substitute_string_values(&mut value, variables)?; + Ok(T::deserialize(value)?) +} + +/// Perform variable substitution on string values of a TOML value. +pub fn substitute_string_values<'a, M>(value: &mut toml::Value, variables: &'a M) -> Result<(), crate::Error> +where + M: VariableMap<'a> + ?Sized, + M::Value: AsRef, +{ + visit_string_values(value, |value| { + *value = crate::substitute(value.as_str(), variables)?; + Ok(()) + }) +} + +/// Error for parsing TOML with variable substitution. +#[derive(Debug)] +pub enum Error { + /// The input contains invalid UTF-8. + InvalidUtf8(std::str::Utf8Error), + + /// An error occurred while parsing TOML. + Toml(toml::de::Error), + + /// An error occurred while performing variable substitution. + Subst(crate::Error), +} + +impl From for Error { + #[inline] + fn from(other: std::str::Utf8Error) -> Self { + Self::InvalidUtf8(other) + } +} + +impl From for Error { + #[inline] + fn from(other: toml::de::Error) -> Self { + Self::Toml(other) + } +} + +impl From for Error { + #[inline] + fn from(other: crate::Error) -> Self { + Self::Subst(other) + } +} + +impl std::error::Error for Error {} + +impl std::fmt::Display for Error { + #[inline] + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidUtf8(e) => std::fmt::Display::fmt(e, f), + Self::Toml(e) => std::fmt::Display::fmt(e, f), + Self::Subst(e) => std::fmt::Display::fmt(e, f), + } + } +} + +/// Recursively apply a function to all string values in a TOML value. +fn visit_string_values(value: &mut toml::Value, fun: F) -> Result<(), E> +where + F: Copy + Fn(&mut String) -> Result<(), E>, +{ + match value { + toml::Value::Boolean(_) => Ok(()), + toml::Value::Integer(_) => Ok(()), + toml::Value::Float(_) => Ok(()), + toml::Value::Datetime(_) => Ok(()), + toml::Value::String(val) => fun(val), + toml::Value::Array(seq) => { + for value in seq { + visit_string_values(value, fun)?; + } + Ok(()) + }, + toml::Value::Table(map) => { + for (_key, value) in map.iter_mut() { + visit_string_values(value, fun)?; + } + Ok(()) + }, + } +} + +#[cfg(test)] +#[rustfmt::skip] +mod test { + use std::collections::HashMap; + + use super::*; + use assert2::{assert, let_assert}; + + #[test] + fn test_from_str() { + #[derive(Debug, serde::Deserialize)] + struct Struct { + bar: String, + baz: String, + } + + let mut variables = HashMap::new(); + variables.insert("bar", "aap"); + variables.insert("baz", "noot"); + #[rustfmt::skip] + let_assert!(Ok(parsed) = from_str( + concat!( + "bar = \"$bar\"\n", + "baz = \"$baz/with/stuff\"\n", + ), + &variables, + )); + + let parsed: Struct = parsed; + assert!(parsed.bar == "aap"); + assert!(parsed.baz == "noot/with/stuff"); + } + + #[test] + fn test_from_str_no_substitution() { + #[derive(Debug, serde::Deserialize)] + struct Struct { + bar: String, + baz: String, + } + + let mut variables = HashMap::new(); + variables.insert("bar", "aap"); + variables.insert("baz", "noot"); + #[rustfmt::skip] + let_assert!(Ok(parsed) = from_str( + concat!( + "bar = \"aap\"\n", + "baz = \"noot/with/stuff\"\n", + ), + &crate::NoSubstitution, + )); + + let parsed: Struct = parsed; + assert!(parsed.bar == "aap"); + assert!(parsed.baz == "noot/with/stuff"); + } + + #[test] + fn test_toml_in_var_is_not_parsed() { + #[derive(Debug, serde::Deserialize)] + struct Struct { + bar: String, + baz: String, + } + + let mut variables = HashMap::new(); + variables.insert("bar", "aap\nbaz = \"mies\""); + variables.insert("baz", "noot"); + #[rustfmt::skip] + let_assert!(Ok(parsed) = from_str( + concat!( + "bar = \"$bar\"\n", + "baz = \"$baz\"\n", + ), + &variables, + )); + + let parsed: Struct = parsed; + assert!(parsed.bar == "aap\nbaz = \"mies\""); + assert!(parsed.baz == "noot"); + } + + #[test] + fn test_dyn_variable_map() { + #[derive(Debug, serde::Deserialize)] + struct Struct { + bar: String, + baz: String, + } + + let mut variables = HashMap::new(); + variables.insert("bar", "aap"); + variables.insert("baz", "noot"); + let variables: &dyn VariableMap = &variables; + #[rustfmt::skip] + let_assert!(Ok(parsed) = from_str( + concat!( + "bar = \"$bar\"\n", + "baz = \"$baz/with/stuff\"\n", + ), + variables, + )); + + let parsed: Struct = parsed; + assert!(parsed.bar == "aap"); + assert!(parsed.baz == "noot/with/stuff"); + } +}