use active_core::{parse, Engine};
fn json(src: &str) -> String {
let module = parse(src, "interp").unwrap_or_else(|d| panic!("parse failed: {d:?}"));
let engine = Engine::new();
let (value, diags) = engine
.evaluate(&module)
.unwrap_or_else(|e| panic!("evaluation failed: {e}"));
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
active_core::convert::value_to_pretty_json(&value)
}
fn codes(src: &str) -> Vec<String> {
match parse(src, "interp") {
Ok(module) => match Engine::new().evaluate(&module) {
Ok((_, diags)) => diags.into_iter().map(|d| d.code).collect(),
Err(e) => vec![e.code().to_string()],
},
Err(diags) => diags.into_iter().map(|d| d.code).collect(),
}
}
#[test]
fn arithmetic_inside_interpolation() {
let out = json("[calc]\nbase = 10\ndoubled = \"value: ${base * 2}\"\n");
assert!(out.contains("\"value: 20\""), "{out}");
}
#[test]
fn comparisons_and_booleans_inside_interpolation() {
let out = json("[t]\na = 5\nb = 3\nm = \"sum=${a + b} gt=${a > b} both=${a > 0 && b > 0}\"\n");
assert!(out.contains("\"sum=8 gt=true both=true\""), "{out}");
}
#[test]
fn env_default_inside_interpolation() {
let out = json("[t]\ns = \"host=${env.ACTIVE_INTERP_UNSET ?: 'local'}\"\n");
assert!(out.contains("\"host=local\""), "{out}");
}
#[test]
fn nested_interpolation() {
let out = json("[t]\nname = \"x\"\ns = \"outer ${ \\\"inner ${name}\\\" }\"\n");
assert!(out.contains("\"outer inner x\""), "{out}");
}
#[test]
fn single_quoted_strings_do_not_interpolate() {
let out = json("[t]\nname = \"x\"\ns = 'literal ${name}'\n");
assert!(out.contains("\"literal ${name}\""), "{out}");
}
#[test]
fn adjacent_interpolations_and_literals() {
let out = json("[t]\nx = 1\ny = 2\ns = \"a${x}b${y}c\"\n");
assert!(out.contains("\"a1b2c\""), "{out}");
}
#[test]
fn escaped_dollar_is_literal() {
let out = json("[t]\ns = \"cost is \\${5}\"\n");
assert!(out.contains("\"cost is ${5}\""), "{out}");
}
#[test]
fn secret_in_interpolation_is_refused() {
let codes = codes("let p = secret('k')\n[t]\nmsg = \"a${p}\"\n");
assert_eq!(codes, vec!["S004"], "secret interpolation must be S004");
}
#[test]
fn cycle_through_an_interpolation_is_detected() {
let codes = codes("let a = \"x${b}\"\nlet b = \"y${a}\"\n[t]\nv = a\n");
assert_eq!(codes, vec!["S001"], "cycle through interpolation");
}
#[test]
fn unterminated_interpolation_is_e012() {
let diags = codes("[t]\nbad = \"oops ${1\"\n");
assert!(diags.contains(&"E012".to_string()), "{diags:?}");
}
#[test]
fn empty_interpolation_is_e013() {
let diags = codes("[t]\nbad = \"oops ${}\"\n");
assert!(diags.contains(&"E013".to_string()), "{diags:?}");
}
#[test]
fn interpolation_with_units() {
let out = json("let t = 5s\n[x]\nmsg = \"timeout ${t}\"\n");
assert!(out.contains("\"timeout 5000ms\""), "{out}");
}
#[test]
fn interpolation_span_is_reported_for_broken_expressions() {
let src = "[t]\nbad = \"start ${1 +} end\"\n";
match parse(src, "interp") {
Err(diags) => {
let d = diags
.iter()
.find(|d| d.code.starts_with('E'))
.expect("an error diagnostic");
let span = d.span.as_ref().expect("span");
assert_eq!(span.line, 2, "span must be on the string's line: {span:?}");
assert!(
span.column > 9,
"span must point inside the string, got column {}",
span.column
);
}
Ok(_) => panic!("broken embedded expression must fail"),
}
}
#[test]
fn backslash_parity_is_preserved() {
let out = json("[t]\nx = 7\ns = \"a\\\\${x}b\"\n");
assert!(
out.contains("\"a\\\\7b\""),
"backslash + interpolation: {out}"
);
let out2 = json("[t]\nx = 7\ns = \"a\\${x}b\"\n");
assert!(
out2.contains("\"a${x}b\""),
"escaped dollar is literal: {out2}"
);
}
#[test]
fn inline_table_inside_interpolation_is_a_type_error() {
let diags = codes("[t]\ns = \"${{k = 1}}\"\n");
assert_eq!(
diags,
vec!["T001"],
"interpolating a table must be a type error, never a placeholder"
);
}
#[test]
fn leftover_tokens_after_the_expression_are_diagnosed() {
let diags = codes("[t]\nbad = \"${1 2}\"\n");
assert!(
diags.iter().any(|c| c == "E002"),
"trailing tokens must be diagnosed: {diags:?}"
);
}
#[test]
fn sub_expression_lexical_errors_are_preserved() {
let diags = codes("[t]\nbad = \"${1 @ 2}\"\n");
assert!(
diags.iter().any(|c| c == "E005" || c == "E002"),
"sub-lexer diagnostics must be preserved: {diags:?}"
);
}
#[test]
fn repeated_interpolation_does_not_blow_up_the_scope() {
let mut src = String::from("[t]\na = 1\ns = \"");
for _ in 0..4000 {
src.push_str("${a}");
}
src.push_str("\"\n");
let module = parse(&src, "interp").expect("must parse");
let engine = Engine::new();
let (value, diags) = engine.evaluate(&module).expect("must evaluate");
assert!(diags.is_empty(), "{diags:?}");
let out = active_core::convert::value_to_pretty_json(&value);
assert!(
out.contains("1111"),
"each segment interpolates: {}",
&out[..80.min(out.len())]
);
}
#[test]
fn single_quoted_values_with_dollar_and_newline_round_trip() {
use active_core::{fmt::format, Style};
let src = "[t]\ns = 'a ${b}\nc'\n";
let module = parse(src, "interp").expect("must parse");
let formatted = format(&module, Style::Brace);
let reparsed = parse(&formatted, "interp")
.unwrap_or_else(|d| panic!("formatted output must re-parse: {d:?}\n{formatted}"));
let engine = Engine::new();
let (a, _) = engine.evaluate(&module).expect("original evaluates");
let (b, _) = engine.evaluate(&reparsed).expect("formatted evaluates");
assert_eq!(
active_core::convert::value_to_pretty_json(&a),
active_core::convert::value_to_pretty_json(&b),
"value must survive formatting:\n{formatted}"
);
}