#![allow(clippy::shadow_reuse)]
use std::borrow::Cow;
use std::collections::HashMap;
use std::io::{self, BufRead, Write};
use typed_builder::TypedBuilder;
fn replacement<S: ::std::hash::BuildHasher>(
key: &str,
settings: &Settings<S>,
) -> io::Result<(bool, String)> {
settings.vars.get(key).map_or_else(
|| {
if settings.fail_on_missing {
Err(io::Error::new(
io::ErrorKind::NotFound,
format!("Undefined variable '{key}'"),
))
} else {
Ok((false, format!("${{{key}}}")))
}
},
|val| Ok((true, val.to_string())),
)
}
enum ReplState {
Text,
Dollar1,
Dollar2,
Key,
}
#[derive(TypedBuilder)]
pub struct Settings<S: ::std::hash::BuildHasher> {
vars: HashMap<String, String, S>,
#[builder(default = false)]
fail_on_missing: bool,
}
#[allow(unused_macros)]
#[macro_export]
macro_rules! settings {
($($p:ident:$v:expr),*) => {
Settings::builder()
$(.$p($v))*
.build()
}
}
#[allow(unused_imports)]
pub use settings;
#[must_use]
pub fn extract_from_string(input: &'_ str) -> Vec<&'_ str> {
let mut state = ReplState::Text;
let mut key_start = 0;
let mut keys = vec![];
for (idx, chr) in input.char_indices() {
match state {
ReplState::Text => {
if chr == '$' {
state = ReplState::Dollar1;
}
}
ReplState::Dollar1 => {
if chr == '$' {
state = ReplState::Dollar2;
} else if chr == '{' {
state = ReplState::Key;
key_start = idx + 1;
} else {
state = ReplState::Text;
}
}
ReplState::Dollar2 => {
if chr != '$' {
state = ReplState::Text;
}
}
ReplState::Key => {
if chr == '}' {
keys.push(input.get(key_start..idx).expect("Bad indices for the name part; should be impossilbe due to the logic we use."));
state = ReplState::Text;
}
}
}
}
keys
}
pub fn extract_from_stream(reader: &mut impl BufRead) -> io::Result<Vec<String>> {
let mut keys = vec![];
for line in cli_utils::lines_iterator(reader, false) {
extract_from_string(&line?)
.into_iter()
.map(str::to_owned)
.for_each(|key| keys.push(key));
}
Ok(keys)
}
pub fn extract_from_file(source: Option<&str>) -> io::Result<Vec<String>> {
let mut reader = cli_utils::create_input_reader(source)?;
extract_from_stream(&mut reader)
}
pub fn replace_in_string<'t, S: ::std::hash::BuildHasher>(
line: &'t str,
settings: &Settings<S>,
) -> io::Result<Cow<'t, str>> {
let mut state = ReplState::Text;
let mut key = String::with_capacity(64);
let mut buff_special = String::with_capacity(5);
let mut buff_out = String::with_capacity(line.len() * 3 / 2);
let mut replaced = false;
for chr in line.chars() {
match state {
ReplState::Text => {
if chr == '$' {
state = ReplState::Dollar1;
buff_special.push(chr);
} else {
buff_out.push(chr);
}
}
ReplState::Dollar1 => {
if chr == '$' {
state = ReplState::Dollar2;
buff_special.push(chr);
} else if chr == '{' {
state = ReplState::Key;
buff_special.clear();
} else {
state = ReplState::Text;
buff_out.push_str(&buff_special);
buff_special.clear();
}
}
ReplState::Dollar2 => {
buff_special.push(chr);
if chr != '$' {
if chr == '{' {
buff_special.remove(0);
replaced = true;
}
state = ReplState::Text;
buff_out.push_str(&buff_special);
buff_special.clear();
}
}
ReplState::Key => {
if chr == '}' {
let repl = replacement(&key, settings)?;
replaced = replaced || repl.0;
buff_out.push_str(&repl.1);
key.clear();
state = ReplState::Text;
} else {
key.push(chr);
}
}
}
}
if replaced {
buff_out.push_str(&buff_special);
if matches!(state, ReplState::Key) {
buff_out.push_str("${");
}
buff_out.push_str(&key);
Ok(Cow::Owned(buff_out))
} else {
Ok(Cow::Borrowed(line))
}
}
pub fn replace_in_stream<S: ::std::hash::BuildHasher>(
reader: &mut impl BufRead,
writer: &mut impl Write,
settings: &Settings<S>,
) -> io::Result<()> {
if tracing::enabled!(tracing::Level::DEBUG) {
for (key, value) in &settings.vars {
tracing::debug!("VARIABLE: {key}={value}");
}
}
for line in cli_utils::lines_iterator(reader, false) {
writer.write_all(replace_in_string(&line?, settings)?.as_bytes())?;
}
Ok(())
}
pub fn replace_in_file<S: ::std::hash::BuildHasher>(
source: Option<&str>,
destination: Option<&str>,
settings: &Settings<S>,
) -> io::Result<()> {
if tracing::enabled!(tracing::Level::DEBUG) {
if let Some(in_file) = source {
tracing::debug!("INPUT: {}", &in_file);
}
if let Some(out_file) = destination {
tracing::debug!("OUTPUT: {}", &out_file);
}
}
let mut reader = cli_utils::create_input_reader(source)?;
let mut writer = cli_utils::create_output_writer(destination)?;
replace_in_stream(&mut reader, &mut writer, settings)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_replace_in_string_no_vars() {
let vars = HashMap::new();
let input = "a ${key_a} $${key_a} b ${key_b} c";
let expected = "a ${key_a} ${key_a} b ${key_b} c";
let actual = replace_in_string(input, &settings! {vars: vars}).unwrap();
assert_eq!(expected, actual);
}
#[test]
fn test_replace_in_string_one_var() {
let mut vars = HashMap::new();
vars.insert("key_a".to_string(), "1".to_string());
let input = "a ${key_a} $${key_a} b ${key_b} c";
let expected = "a 1 ${key_a} b ${key_b} c";
let actual = replace_in_string(input, &settings! {vars: vars}).unwrap();
assert_eq!(expected, actual);
}
#[test]
fn test_replace_in_string_two_vars() {
let mut vars = HashMap::new();
vars.insert("key_a".to_string(), "1".to_string());
vars.insert("key_b".to_string(), "2".to_string());
let input = "a ${key_a} $${key_a} b ${key_b} c";
let expected = "a 1 ${key_a} b 2 c";
let actual = replace_in_string(input, &settings! {vars: vars}).unwrap();
assert_eq!(expected, actual);
}
#[test]
fn test_replace_in_string_case_sensitive() {
let mut vars = HashMap::new();
vars.insert("Key_A".to_string(), "1".to_string());
vars.insert("key_b".to_string(), "2".to_string());
let input = "a ${key_a} $${key_a} b ${key_b} c";
let expected = "a ${key_a} ${key_a} b 2 c";
let actual = replace_in_string(input, &settings! {vars: vars}).unwrap();
assert_eq!(expected, actual);
}
#[test]
fn test_replace_in_string_missing_closing_bracket() {
let mut vars = HashMap::new();
vars.insert("key_a".to_string(), "1".to_string());
let input = "a ${key_a";
let expected = "a ${key_a";
let actual = replace_in_string(input, &settings! {vars: vars}).unwrap();
assert_eq!(expected, actual);
}
#[test]
fn test_replace_in_string_missing_closing_bracket_and_key() {
let mut vars = HashMap::new();
vars.insert("key_a".to_string(), "1".to_string());
let input = "a ${";
let expected = "a ${";
let actual = replace_in_string(input, &settings! {vars: vars}).unwrap();
assert_eq!(expected, actual);
}
#[test]
fn test_replace_in_string_missing_closing_bracket_quoted() {
let mut vars = HashMap::new();
vars.insert("key_a".to_string(), "1".to_string());
let input = "a $${key_a";
let expected = "a ${key_a"; let actual = replace_in_string(input, &settings! {vars: vars}).unwrap();
assert_eq!(expected, actual);
}
#[test]
fn test_replace_in_string_missing_closing_bracket_and_key_quoted() {
let vars = HashMap::new();
let input = "a $${";
let expected = "a ${"; let actual = replace_in_string(input, &settings! {vars: vars}).unwrap();
assert_eq!(expected, actual);
}
#[test]
fn test_replace_in_string_with_var_with_dollar_value() {
let mut vars = HashMap::new();
vars.insert("key_a".to_string(), "1$2".to_string());
let input = "a ${key_a} $${key_a} b ${key_b} c";
let expected = "a 1$2 ${key_a} b ${key_b} c";
let actual = replace_in_string(input, &settings! {vars: vars}).unwrap();
assert_eq!(expected, actual);
}
}