on master help sign in
Backslash escapes in text
86bef11iko
DESIGN.md+10
@@ -53,6 +53,16 @@ attributes is its content, and `{...}` puts elements inside a line of text:
53@section [padding 8] Text after the attributes is content too.
53@section [padding 8] Text after the attributes is content too.
54```
54```
55
55
56+A backslash makes the next character literal: `\@` and `\--` let a text line
57+start with `@` or `--`, `\$` shows a `$` that isn't a variable, `\{` a brace
58+that isn't an inline element, and `\\` a backslash.
59+
60+```
61+\@htmlang on social media
62+@let price 5
63+@text \$price is $price dollars
64+```
65+
56### Comments
66### Comments
57
67
58`--` at the start of a line begins a comment. Comments must be on their own
68`--` at the start of a line begins a comment. Comments must be on their own
crates/htmlang-core/src/parser.rs+47−21
@@ -798,8 +798,12 @@ impl Evaluator {
798 });
798 });
799 }
799 }
800
800
801− let var_warnings =
801+ let var_warnings = check_undefined_vars(
802− check_undefined_vars(&content, &ctx.variables, line_num, current_indent);
802+ &protect_escapes(&content),
803+ &ctx.variables,
804+ line_num,
805+ current_indent,
806+ );
803 ctx.diagnostics.extend(var_warnings);
807 ctx.diagnostics.extend(var_warnings);
804 track_var_refs(&content, &mut ctx.used_variables);
808 track_var_refs(&content, &mut ctx.used_variables);
805 let segments = parse_text_segments(&content, ctx);
809 let segments = parse_text_segments(&content, ctx);
@@ -1356,18 +1360,18 @@ fn parse_single_element(
1356 let argument = if rest.is_empty() {
1360 let argument = if rest.is_empty() {
1357 None
1361 None
1358 } else if kind == ElementKind::Link {
1362 } 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);
1360 if let Some((url, text)) = rest_sub.split_once(' ') {
1364 if let Some((url, text)) = rest_sub.split_once(' ') {
1361 let text = text.trim();
1365 let text = text.trim();
1362 if !text.is_empty() {
1366 if !text.is_empty() {
1363 children.push(Node::Text(parse_text_segments(text, ctx)));
1367 children.push(Node::Text(parse_text_segments(text, ctx)));
1364 }
1368 }
1365− Some(url.to_string())
1369+ Some(restore_escapes(url))
1366 } else {
1370 } else {
1367− Some(rest_sub)
1371+ Some(restore_escapes(&rest_sub))
1368 }
1372 }
1369 } else {
1373 } else {
1370− Some(substitute_vars(&rest, &ctx.variables))
1374+ Some(restore_escapes(&substitute_vars(&protect_escapes(&rest), &ctx.variables)))
1371 };
1375 };
1372
1376
1373 // For @slot, the argument is the slot name
1377 // For @slot, the argument is the slot name
@@ -2433,20 +2437,50 @@ fn split_chain(content: &str) -> Vec<String> {
2433// Text segment parsing (inline {...} elements)
2437// Text segment parsing (inline {...} elements)
2434// ---------------------------------------------------------------------------
2438// ---------------------------------------------------------------------------
2435
2439
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+
2436fn parse_text_segments(input: &str, ctx: &mut ParseContext) -> Vec<TextSegment> {
2470fn parse_text_segments(input: &str, ctx: &mut ParseContext) -> Vec<TextSegment> {
2437 let mut segments = Vec::new();
2471 let mut segments = Vec::new();
2438 let mut current_text = String::new();
2472 let mut current_text = String::new();
2439− let chars: Vec<char> = input.chars().collect();
2473+ let chars: Vec<char> = protect_escapes(input).chars().collect();
2440 let mut i = 0;
2474 let mut i = 0;
2441
2475
2442 while i < chars.len() {
2476 while i < chars.len() {
2443 if chars[i] == '{' && i + 1 < chars.len() && chars[i + 1] == '@' {
2477 if chars[i] == '{' && i + 1 < chars.len() && chars[i + 1] == '@' {
2444 // Flush accumulated plain text
2478 // Flush accumulated plain text
2445 if !current_text.is_empty() {
2479 if !current_text.is_empty() {
2446− segments.push(TextSegment::Plain(substitute_vars(
2480+ segments.push(TextSegment::Plain(restore_escapes(&substitute_vars(
2447 &current_text,
2481 &current_text,
2448 &ctx.variables,
2482 &ctx.variables,
2449− )));
2483+ ))));
2450 current_text.clear();
2484 current_text.clear();
2451 }
2485 }
2452
2486
@@ -2498,25 +2532,15 @@ fn parse_text_segments(input: &str, ctx: &mut ParseContext) -> Vec<TextSegment>
2498 }
2532 }
2499
2533
2500 if !current_text.is_empty() {
2534 if !current_text.is_empty() {
2501− segments.push(TextSegment::Plain(substitute_vars(
2535+ segments.push(TextSegment::Plain(restore_escapes(&substitute_vars(
2502 &current_text,
2536 &current_text,
2503 &ctx.variables,
2537 &ctx.variables,
2504− )));
2538+ ))));
2505 }
2539 }
2506
2540
2507 segments
2541 segments
2508}
2542}
2509
2543
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.
2520/// The items of a text list: a range `A..B [step N]`, or comma-separated.
2544/// The items of a text list: a range `A..B [step N]`, or comma-separated.
2521fn text_list_items(list: &str) -> Vec<String> {
2545fn text_list_items(list: &str) -> Vec<String> {
2522 if let Some((start, rest)) = list.split_once("..") {
2546 if let Some((start, rest)) = list.split_once("..") {
@@ -2552,6 +2576,8 @@ fn bind_item(vars: &mut HashMap<String, String>, source: &str, target: &str) {
2552 vars.entry(target.to_string()).or_default();
2576 vars.entry(target.to_string()).or_default();
2553}
2577}
2554
2578
2579+/// Inclusive integer range from `start` to `end` (counting down when
2580+/// `start > end`), stepping by `step`. Stops instead of overflowing.
2555fn numeric_range(start: i64, end: i64, step: i64) -> Vec<String> {
2581fn numeric_range(start: i64, end: i64, step: i64) -> Vec<String> {
2556 let mut items = Vec::new();
2582 let mut items = Vec::new();
2557 let mut n = start;
2583 let mut n = start;
tests/regressions.rs+11
@@ -440,3 +440,14 @@ fn style_aliases_point_to_css() {
440 assert!(out.contains("border:1px solid red"), "{}", out);
440 assert!(out.contains("border:1px solid red"), "{}", out);
441 assert!(out.contains("border-radius:4px 4px 0 0"), "{}", out);
441 assert!(out.contains("border-radius:4px 4px 0 0"), "{}", out);
442}
442}
443+
444+#[test]
445+fn backslash_escapes_in_text() {
446+ let out = compile(
447+ "@let price 9\n\\@user says\n\\-- not a comment\n@text \\$price is $price, \\{@b} \\\\\n@paragraph\n Now {@text \\$5} only\n",
448+ );
449+ assert!(out.contains("@user says"), "{}", out);
450+ assert!(out.contains("-- not a comment"), "{}", out);
451+ assert!(out.contains("$price is 9, {@b} \\<"), "{}", out);
452+ assert!(out.contains(">$5</span> only"), "{}", out);
453+}