use crate::{Error, Result};
const OPEN: &str = "<%";
const CLOSE: &str = "%>";
pub fn render(content: &str) -> Result<String> {
warn_legacy_delimiters(content, &mut std::io::stderr());
let translated = to_tera_syntax(content)?;
crate::tera::render_string(&translated, &serde_json::json!({}))
}
fn warn_legacy_delimiters(content: &str, out: &mut impl std::io::Write) {
if !has_legacy_delimiters(content) {
return;
}
let _ = writeln!(
out,
"warning: this config uses the legacy `{{{{ }}}}` template delimiters, which are not \
valid YAML and are rewritten by editors/formatters (see \
https://github.com/loco-rs/loco/issues/1727). Use the YAML-safe form instead, e.g. \
`<%= get_env(name=\"PORT\", default=\"5150\") %>`."
);
}
fn has_legacy_delimiters(content: &str) -> bool {
content.contains("{{") || content.contains("{%")
}
fn to_tera_syntax(content: &str) -> Result<String> {
if !content.contains(OPEN) {
return Ok(content.to_string());
}
let mut out = String::with_capacity(content.len());
let mut rest = content;
while let Some(start) = rest.find(OPEN) {
out.push_str(&rest[..start]);
let after_open = &rest[start + OPEN.len()..];
let Some(end) = after_open.find(CLOSE) else {
return Err(Error::Message(format!(
"unterminated `{OPEN}` template tag in configuration: expected a closing `{CLOSE}`"
)));
};
let (kind, body) = split_tag(&after_open[..end]);
let (tera_open, tera_close) = kind.tera_delimiters();
out.push_str(tera_open);
out.push_str(body);
out.push_str(tera_close);
rest = &after_open[end + CLOSE.len()..];
}
out.push_str(rest);
Ok(out)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TagKind {
Expression,
Statement,
Comment,
}
impl TagKind {
const fn tera_delimiters(self) -> (&'static str, &'static str) {
match self {
Self::Expression => ("{{", "}}"),
Self::Statement => ("{%", "%}"),
Self::Comment => ("{#", "#}"),
}
}
}
fn split_tag(inner: &str) -> (TagKind, &str) {
match inner.split_at_checked(1) {
Some(("=", body)) => (TagKind::Expression, body),
Some(("#", body)) => (TagKind::Comment, body),
_ => (TagKind::Statement, inner),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn translates_the_three_tag_kinds() {
assert_eq!(to_tera_syntax("a: <%= x %>").unwrap(), "a: {{ x }}");
assert_eq!(to_tera_syntax("<% if x %>").unwrap(), "{% if x %}");
assert_eq!(to_tera_syntax("<%# note %>").unwrap(), "{# note #}");
}
#[test]
fn leaves_untagged_content_untouched() {
let src = "a: 100%\nb: \"a %> b\"\nc: {not_a_tag}";
assert_eq!(to_tera_syntax(src).unwrap(), src);
}
#[test]
fn translates_multiple_tags_on_one_line_and_across_lines() {
assert_eq!(
to_tera_syntax("uri: \"<%= a %>://<%= b %>\"\nport: <%= c %>").unwrap(),
"uri: \"{{ a }}://{{ b }}\"\nport: {{ c }}"
);
}
#[test]
fn unterminated_tag_is_a_clear_error() {
let err = to_tera_syntax("port: <%= get_env(name=\"PORT\")").unwrap_err();
assert!(err.to_string().contains("unterminated"), "got: {err}");
}
#[test]
fn renders_env_lookup_with_default() {
let out =
render("port: <%= get_env(name=\"NOPE_UNSET_VAR\", default=\"5150\") %>").unwrap();
assert_eq!(out, "port: 5150");
}
#[test]
fn rendered_output_is_bare_so_yaml_keeps_the_type() {
let out =
render("port: <%= get_env(name=\"NOPE_UNSET_VAR\", default=\"5150\") %>").unwrap();
let v: serde_yaml::Value = serde_yaml::from_str(&out).unwrap();
assert!(
v["port"].is_i64(),
"expected an integer, got: {:?}",
v["port"]
);
}
#[test]
fn statement_tags_conditionally_render_config() {
let src = "\
<% if get_env(name=\"NOPE_UNSET_VAR\", default=\"off\") == \"on\" %>\
cache: {kind: Redis}\
<% else %>\
cache: {kind: Null}\
<% endif %>";
assert_eq!(render(src).unwrap(), "cache: {kind: Null}");
}
#[test]
fn comment_tags_are_stripped() {
let out = render("port: 5150<%# an internal note %>").unwrap();
assert_eq!(out, "port: 5150");
}
#[test]
fn reads_a_set_environment_variable() {
unsafe { std::env::set_var("LOCO_CONFIG_TEST_PORT", "9999") };
let out = render("port: <%= get_env(name=\"LOCO_CONFIG_TEST_PORT\") %>").unwrap();
assert_eq!(out, "port: 9999");
unsafe { std::env::remove_var("LOCO_CONFIG_TEST_PORT") };
}
#[test]
fn an_empty_environment_variable_is_still_a_value() {
unsafe { std::env::set_var("LOCO_CONFIG_TEST_EMPTY", "") };
let out = render(
"host: \"<%= get_env(name=\"LOCO_CONFIG_TEST_EMPTY\", default=\"fallback\") %>\"",
)
.unwrap();
assert_eq!(out, "host: \"\"");
unsafe { std::env::remove_var("LOCO_CONFIG_TEST_EMPTY") };
}
#[test]
fn missing_variable_without_default_is_an_error() {
assert!(render("port: <%= get_env(name=\"LOCO_CONFIG_NOPE\") %>").is_err());
}
#[test]
fn legacy_tera_delimiters_still_render() {
let out = render("port: {{ get_env(name=\"NOPE_UNSET_VAR\", default=\"5150\") }}").unwrap();
assert_eq!(out, "port: 5150");
}
#[test]
fn detects_legacy_delimiters() {
assert!(has_legacy_delimiters("a: {{ x }}"));
assert!(has_legacy_delimiters("{% if x %}"));
assert!(!has_legacy_delimiters("a: <%= x %>"));
assert!(!has_legacy_delimiters("a: plain"));
}
#[test]
fn the_legacy_delimiter_warning_is_written_out() {
let mut out = Vec::new();
warn_legacy_delimiters("port: {{ get_env(name=\"PORT\") }}", &mut out);
let written = String::from_utf8(out).expect("the notice is valid UTF-8");
assert!(
written.contains("legacy") && written.contains("1727"),
"the notice should name the problem and point at the issue: {written}"
);
assert!(
written.contains("<%="),
"the notice should show the replacement form: {written}"
);
}
#[test]
fn a_yaml_safe_config_warns_about_nothing() {
let mut out = Vec::new();
warn_legacy_delimiters("port: <%= get_env(name=\"PORT\") %>", &mut out);
assert!(out.is_empty(), "nothing to deprecate, nothing to say");
}
#[test]
fn unrendered_config_is_valid_yaml() {
let src = r#"
server:
port: <%= get_env(name="PORT", default="5150") %>
binding: <%= get_env(name="BINDING", default="localhost") %>
database:
uri: <%= get_env(name="DATABASE_URL", default="postgres://localhost/app") %>
"#;
let parsed: serde_yaml::Value =
serde_yaml::from_str(src).expect("unrendered config must be valid YAML");
assert!(parsed["server"]["port"].is_string());
let legacy = "port: {{ get_env(name=\"PORT\", default=\"5150\") }}";
let legacy_parsed = serde_yaml::from_str::<serde_yaml::Value>(legacy);
assert!(
legacy_parsed.map_or(true, |v| !v["port"].is_string()),
"legacy delimiters must not be seen as a plain string by a YAML parser"
);
}
#[test]
fn survives_yaml_formatter_round_trip() {
let src = "server:\n port: <%= get_env(name=\"NOPE_UNSET_VAR\", default=\"5150\") %>\n";
let as_value: serde_yaml::Value = serde_yaml::from_str(src).unwrap();
let reformatted = serde_yaml::to_string(&as_value).unwrap();
let from_original = render(src).unwrap();
let from_reformatted = render(&reformatted).unwrap();
let a: serde_yaml::Value = serde_yaml::from_str(&from_original).unwrap();
let b: serde_yaml::Value = serde_yaml::from_str(&from_reformatted).unwrap();
assert_eq!(a, b, "formatting the config changed its meaning");
assert_eq!(a["server"]["port"], serde_yaml::Value::from(5150));
}
}