@@ -798,8 +798,12 @@ impl Evaluator {
798798 });
799799 }
800800
801− let var_warnings =
802− check_undefined_vars(&content, &ctx.variables, line_num, current_indent);
801+ let var_warnings = check_undefined_vars(
802+ &protect_escapes(&content),
803+ &ctx.variables,
804+ line_num,
805+ current_indent,
806+ );
803807 ctx.diagnostics.extend(var_warnings);
804808 track_var_refs(&content, &mut ctx.used_variables);
805809 let segments = parse_text_segments(&content, ctx);
@@ -1356,18 +1360,18 @@ fn parse_single_element(
13561360 let argument = if rest.is_empty() {
13571361 None
13581362 } else if kind == ElementKind::Link {
1359− let rest_sub = substitute_vars(&rest, &ctx.variables);
1363+ let rest_sub = substitute_vars(&protect_escapes(&rest), &ctx.variables);
13601364 if let Some((url, text)) = rest_sub.split_once(' ') {
13611365 let text = text.trim();
13621366 if !text.is_empty() {
13631367 children.push(Node::Text(parse_text_segments(text, ctx)));
13641368 }
1365− Some(url.to_string())
1369+ Some(restore_escapes(url))
13661370 } else {
1367− Some(rest_sub)
1371+ Some(restore_escapes(&rest_sub))
13681372 }
13691373 } else {
1370− Some(substitute_vars(&rest, &ctx.variables))
1374+ Some(restore_escapes(&substitute_vars(&protect_escapes(&rest), &ctx.variables)))
13711375 };
13721376
13731377 // For @slot, the argument is the slot name
@@ -2433,20 +2437,50 @@ fn split_chain(content: &str) -> Vec<String> {
24332437// Text segment parsing (inline {...} elements)
24342438// ---------------------------------------------------------------------------
24352439
2440+/// Escapes in text: `\@`, `\$`, `\{`, `\--` and `\\` stand for the
2441+/// character(s) themselves. While a text is processed, each is a private-use
2442+/// placeholder, so it can't start a variable, an inline element or a line.
2443+const ESCAPES: &[(&str, char, &str)] = &[
2444+ ("\\\\", '\u{E000}', "\\"),
2445+ ("\\$", '\u{E001}', "$"),
2446+ ("\\{", '\u{E002}', "{"),
2447+ ("\\@", '\u{E003}', "@"),
2448+ ("\\--", '\u{E004}', "--"),
2449+];
2450+
2451+fn protect_escapes(text: &str) -> String {
2452+ if !text.contains('\\') {
2453+ return text.to_string();
2454+ }
2455+ let mut out = text.to_string();
2456+ for (escape, placeholder, _) in ESCAPES {
2457+ out = out.replace(escape, &placeholder.to_string());
2458+ }
2459+ out
2460+}
2461+
2462+fn restore_escapes(text: &str) -> String {
2463+ let mut out = text.to_string();
2464+ for (_, placeholder, literal) in ESCAPES {
2465+ out = out.replace(*placeholder, literal);
2466+ }
2467+ out
2468+}
2469+
24362470fn parse_text_segments(input: &str, ctx: &mut ParseContext) -> Vec<TextSegment> {
24372471 let mut segments = Vec::new();
24382472 let mut current_text = String::new();
2439− let chars: Vec<char> = input.chars().collect();
2473+ let chars: Vec<char> = protect_escapes(input).chars().collect();
24402474 let mut i = 0;
24412475
24422476 while i < chars.len() {
24432477 if chars[i] == '{' && i + 1 < chars.len() && chars[i + 1] == '@' {
24442478 // Flush accumulated plain text
24452479 if !current_text.is_empty() {
2446− segments.push(TextSegment::Plain(substitute_vars(
2480+ segments.push(TextSegment::Plain(restore_escapes(&substitute_vars(
24472481 ¤t_text,
24482482 &ctx.variables,
2449− )));
2483+ ))));
24502484 current_text.clear();
24512485 }
24522486
@@ -2498,25 +2532,15 @@ fn parse_text_segments(input: &str, ctx: &mut ParseContext) -> Vec<TextSegment>
24982532 }
24992533
25002534 if !current_text.is_empty() {
2501− segments.push(TextSegment::Plain(substitute_vars(
2535+ segments.push(TextSegment::Plain(restore_escapes(&substitute_vars(
25022536 ¤t_text,
25032537 &ctx.variables,
2504− )));
2538+ ))));
25052539 }
25062540
25072541 segments
25082542}
25092543
2510−// ---------------------------------------------------------------------------
2511−// Variable substitution
2512−// ---------------------------------------------------------------------------
2513−
2514−// ---------------------------------------------------------------------------
2515−// Condition evaluation for @if
2516−// ---------------------------------------------------------------------------
2517−
2518−/// Inclusive integer range from `start` to `end` (counting down when
2519−/// `start > end`), stepping by `step`. Stops instead of overflowing.
25202544/// The items of a text list: a range `A..B [step N]`, or comma-separated.
25212545fn text_list_items(list: &str) -> Vec<String> {
25222546 if let Some((start, rest)) = list.split_once("..") {
@@ -2552,6 +2576,8 @@ fn bind_item(vars: &mut HashMap<String, String>, source: &str, target: &str) {
25522576 vars.entry(target.to_string()).or_default();
25532577}
25542578
2579+/// Inclusive integer range from `start` to `end` (counting down when
2580+/// `start > end`), stepping by `step`. Stops instead of overflowing.
25552581fn numeric_range(start: i64, end: i64, step: i64) -> Vec<String> {
25562582 let mut items = Vec::new();
25572583 let mut n = start;