#[must_use]
pub fn humanize(expression: &str) -> String {
let trimmed = expression.trim();
match split_conditional(trimmed) {
Some((condition, when_true, when_false)) => format!(
"if {} then {} else {}",
humanize(condition),
humanize(when_true),
humanize(when_false)
),
None => unescape_literals(trimmed),
}
}
fn unescape_literals(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut quote: Option<char> = None;
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
let Some(open) = quote else {
if c == '"' || c == '\'' {
quote = Some(c);
}
out.push(c);
continue;
};
if c == '\\' {
if matches!(chars.peek(), Some(&('\\' | '"'))) {
if let Some(escaped) = chars.next() {
out.push(escaped);
}
} else {
out.push(c);
}
continue;
}
if c == open {
quote = None;
}
out.push(c);
}
out
}
const BACKSLASH: u8 = 92;
fn split_conditional(text: &str) -> Option<(&str, &str, &str)> {
let bytes = text.as_bytes();
let mut depth = 0i32;
let mut quote: Option<u8> = None;
let mut question: Option<usize> = None;
let mut pending = 0i32;
for (index, byte) in bytes.iter().enumerate() {
if let Some(open) = quote {
let escaped = index > 0 && bytes.get(index - 1) == Some(&BACKSLASH);
if *byte == open && !escaped {
quote = None;
}
continue;
}
match byte {
b'"' | b'\'' => quote = Some(*byte),
b'(' | b'[' | b'{' => depth += 1,
b')' | b']' | b'}' => depth -= 1,
b'?' if depth == 0 => {
if question.is_none() {
question = Some(index);
} else {
pending += 1;
}
}
b':' if depth == 0 && question.is_some() => {
if pending > 0 {
pending -= 1;
} else {
let at = question?;
return Some((
text.get(..at)?.trim(),
text.get(at + 1..index)?.trim(),
text.get(index + 1..)?.trim(),
));
}
}
_ => {}
}
}
None
}
#[cfg(test)]
mod tests {
use super::humanize;
#[test]
fn a_conditional_reads_as_a_sentence() {
assert_eq!(
humanize(
"Step.DataSource != 'PassFail' ? PassFail = Evaluate(Step.DataSource) : False"
),
"if Step.DataSource != 'PassFail' then PassFail = Evaluate(Step.DataSource) else False"
);
}
#[test]
fn a_question_mark_inside_brackets_does_not_split() {
let source = "Evaluate(a ? b : c)";
assert_eq!(humanize(source), source);
}
#[test]
fn a_colon_inside_quotes_does_not_split() {
let source = "Locals.Message = 'ratio 1:2'";
assert_eq!(humanize(source), source);
}
#[test]
fn a_nested_conditional_keeps_its_branches() {
assert_eq!(
humanize("a ? b ? c : d : e"),
"if a then if b then c else d else e"
);
}
#[test]
fn an_expression_without_a_conditional_is_untouched() {
for source in [
"Locals.Counter = Locals.Counter + 1",
"Status == 'Done' && Locals.Ready",
"",
] {
assert_eq!(humanize(source), source);
}
}
#[test]
fn a_quoted_path_reads_without_its_escapes() {
assert_eq!(
humanize(r#"Locals.Path + "\\Doc\\Help\\""#),
r#"Locals.Path + "\Doc\Help\""#,
);
}
#[test]
fn a_backslash_outside_a_literal_is_untouched() {
assert_eq!(humanize(r"Locals.A \ Locals.B"), r"Locals.A \ Locals.B");
}
#[test]
fn an_escaped_quote_does_not_end_the_literal() {
assert_eq!(humanize(r#""say \"hi\"""#), r#""say "hi"""#);
}
}