use crate::parser::tests::cst::{ExpectedNode, assert_equivalent};
use crate::{SyntaxKind, parse};
#[test]
fn text_single_word() {
assert_equivalent(
parse("Hello\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
}
}
}),
);
}
#[test]
fn text_multiple_words() {
assert_equivalent(
parse("Hello world\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
}
}
}),
);
}
#[test]
fn text_with_period() {
assert_equivalent(
parse("Hello.\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
}
}
}),
);
}
#[test]
fn text_with_comma() {
assert_equivalent(
parse("Hello, world\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
}
}
}),
);
}
#[test]
fn text_with_exclamation() {
assert_equivalent(
parse("It works!\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
}
}
}),
);
}
#[test]
fn text_with_question() {
assert_equivalent(
parse("How are you?\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
}
}
}),
);
}
#[test]
fn text_with_colon() {
assert_equivalent(
parse("Name: Bob\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
}
}
}),
);
}
#[test]
fn text_with_semicolon() {
assert_equivalent(
parse("A; B\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
}
}
}),
);
}
#[test]
fn text_with_parentheses() {
assert_equivalent(
parse("Hello (world)\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
}
}
}),
);
}
#[test]
fn text_with_quotes() {
assert_equivalent(
parse("She said \"hello\"\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
}
}
}),
);
}
#[test]
fn text_with_numbers() {
assert_equivalent(
parse("Player 1\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
}
}
}),
);
}
#[test]
fn text_at_eof() {
assert_equivalent(
parse("Hello"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
}
}
}),
);
}
#[test]
fn text_with_equals() {
assert_equivalent(
parse("A = B\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
}
}
}),
);
}
#[test]
fn glue_between_text() {
assert_equivalent(
parse("a<>b\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
GLUE_NODE
TEXT
}
}
}),
);
}
#[test]
fn glue_with_spaces() {
assert_equivalent(
parse("hello <>world\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
GLUE_NODE
TEXT
}
}
}),
);
}
#[test]
fn glue_at_start() {
assert_equivalent(
parse("<>continued\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
GLUE_NODE
TEXT
}
}
}),
);
}
#[test]
fn glue_at_end() {
assert_equivalent(
parse("text<>\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
GLUE_NODE
}
}
}),
);
}
#[test]
fn multiple_glues() {
assert_equivalent(
parse("a<>b<>c\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
GLUE_NODE
TEXT
GLUE_NODE
TEXT
}
}
}),
);
}
#[test]
fn consecutive_glues() {
assert_equivalent(
parse("<><>\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
GLUE_NODE
GLUE_NODE
}
}
}),
);
}
#[test]
fn glue_only() {
assert_equivalent(
parse("<>\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
GLUE_NODE
}
}
}),
);
}
#[test]
fn glue_before_divert() {
assert_equivalent(
parse("text<> -> knot\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
GLUE_NODE
}
DIVERT_NODE {
SIMPLE_DIVERT {
DIVERT_TARGET_WITH_ARGS {
PATH
}
}
}
}
}),
);
}
#[test]
fn escape_hash() {
assert_equivalent(
parse("\\# not a tag\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
ESCAPE
TEXT
}
}
}),
);
}
#[test]
fn escape_open_brace() {
assert_equivalent(
parse("\\{ not logic\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
ESCAPE
TEXT
}
}
}),
);
}
#[test]
fn escape_backslash() {
assert_equivalent(
parse("\\\\ text\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
ESCAPE
TEXT
}
}
}),
);
}
#[test]
fn escape_pipe() {
assert_equivalent(
parse("\\| not branch\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
ESCAPE
TEXT
}
}
}),
);
}
#[test]
fn escape_mid_text() {
assert_equivalent(
parse("Hello \\# world\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
ESCAPE
TEXT
}
}
}),
);
}
#[test]
fn escape_at_end() {
assert_equivalent(
parse("Hello \\#\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
ESCAPE
}
}
}),
);
}
#[test]
fn escape_at_start() {
assert_equivalent(
parse("\\# tag\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
ESCAPE
TEXT
}
}
}),
);
}
#[test]
fn multiple_escapes() {
assert_equivalent(
parse("\\# and \\{\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
ESCAPE
TEXT
ESCAPE
}
}
}),
);
}
#[test]
fn consecutive_escapes() {
assert_equivalent(
parse("\\#\\{\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
ESCAPE
ESCAPE
}
}
}),
);
}
#[test]
fn escape_close_brace() {
assert_equivalent(
parse("\\} text\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
ESCAPE
TEXT
}
}
}),
);
}
#[test]
fn text_then_escape() {
assert_equivalent(
parse("Hello \\#\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
ESCAPE
}
}
}),
);
}
#[test]
fn glue_then_escape() {
assert_equivalent(
parse("<>\\#\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
GLUE_NODE
ESCAPE
}
}
}),
);
}
#[test]
fn escape_then_glue() {
assert_equivalent(
parse("\\#<>text\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
ESCAPE
GLUE_NODE
TEXT
}
}
}),
);
}
#[test]
fn text_escape_glue_text() {
assert_equivalent(
parse("a\\#<>b\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
ESCAPE
GLUE_NODE
TEXT
}
}
}),
);
}
#[test]
fn text_inline_text() {
assert_equivalent(
parse("Hello {x} world\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
INLINE_LOGIC {
INNER_EXPRESSION {
PATH
}
}
TEXT
}
}
}),
);
}
#[test]
fn glue_inline_glue() {
assert_equivalent(
parse("<>{x}<>\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
GLUE_NODE
INLINE_LOGIC {
INNER_EXPRESSION {
PATH
}
}
GLUE_NODE
}
}
}),
);
}
#[test]
fn inline_at_start() {
assert_equivalent(
parse("{x} world\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
INLINE_LOGIC {
INNER_EXPRESSION {
PATH
}
}
TEXT
}
}
}),
);
}
#[test]
fn inline_at_end() {
assert_equivalent(
parse("Hello {x}\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
INLINE_LOGIC {
INNER_EXPRESSION {
PATH
}
}
}
}
}),
);
}
#[test]
fn content_only() {
assert_equivalent(
parse("Hello\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
}
}
}),
);
}
#[test]
fn content_with_divert() {
assert_equivalent(
parse("Hello -> knot\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
}
DIVERT_NODE {
SIMPLE_DIVERT {
DIVERT_TARGET_WITH_ARGS {
PATH
}
}
}
}
}),
);
}
#[test]
fn content_with_tag() {
assert_equivalent(
parse("Hello #tag\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
}
TAGS {
TAG
}
}
}),
);
}
#[test]
fn content_with_two_tags() {
assert_equivalent(
parse("Hello #tag1 #tag2\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
}
TAGS {
TAG
TAG
}
}
}),
);
}
#[test]
fn content_divert_tags() {
assert_equivalent(
parse("Hello -> knot #tag\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
}
DIVERT_NODE {
SIMPLE_DIVERT {
DIVERT_TARGET_WITH_ARGS {
PATH
}
}
}
TAGS {
TAG
}
}
}),
);
}
#[test]
fn divert_only() {
assert_equivalent(
parse("-> knot\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
DIVERT_NODE {
SIMPLE_DIVERT {
DIVERT_TARGET_WITH_ARGS {
PATH
}
}
}
}
}),
);
}
#[test]
fn divert_with_tags() {
assert_equivalent(
parse("-> knot #tag\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
DIVERT_NODE {
SIMPLE_DIVERT {
DIVERT_TARGET_WITH_ARGS {
PATH
}
}
}
TAGS {
TAG
}
}
}),
);
}
#[test]
fn content_then_tunnel_call() {
assert_equivalent(
parse("Hello -> target ->\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
}
DIVERT_NODE {
TUNNEL_CALL_NODE {
DIVERT_TARGET_WITH_ARGS {
PATH
}
}
}
}
}),
);
}
#[test]
fn content_then_tunnel_call_onwards() {
assert_equivalent(
parse("Hello -> target ->->\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
}
DIVERT_NODE {
TUNNEL_CALL_NODE {
DIVERT_TARGET_WITH_ARGS {
PATH
}
}
}
DIVERT_NODE {
TUNNEL_ONWARDS_NODE
}
}
}),
);
}
#[test]
fn divert_tunnel_call_onwards() {
assert_equivalent(
parse("-> target ->->\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
DIVERT_NODE {
TUNNEL_CALL_NODE {
DIVERT_TARGET_WITH_ARGS {
PATH
}
}
}
DIVERT_NODE {
TUNNEL_ONWARDS_NODE
}
}
}),
);
}
#[test]
fn indented_content_strips_leading_whitespace() {
let p = parse(" Hello world\n");
let text_nodes: Vec<String> = p
.syntax()
.descendants()
.filter(|n| n.kind() == SyntaxKind::TEXT)
.map(|n| n.text().to_string())
.collect();
assert_eq!(text_nodes.len(), 1);
assert_eq!(
text_nodes[0], "Hello world",
"leading indentation should be stripped from TEXT node"
);
}
#[test]
fn space_after_glue_preserved_in_text() {
let p = parse("<> world\n");
let text_nodes: Vec<String> = p
.syntax()
.descendants()
.filter(|n| n.kind() == SyntaxKind::TEXT)
.map(|n| n.text().to_string())
.collect();
assert_eq!(text_nodes.len(), 1);
assert_eq!(
text_nodes[0], " world",
"space after glue is content, not indentation"
);
}
#[test]
fn indented_template_strips_leading_whitespace() {
let p = parse(" Hello {name} world\n");
let text_nodes: Vec<String> = p
.syntax()
.descendants()
.filter(|n| n.kind() == SyntaxKind::TEXT)
.map(|n| n.text().to_string())
.collect();
assert_eq!(text_nodes.len(), 2);
assert_eq!(
text_nodes[0], "Hello ",
"leading indentation stripped, trailing space preserved"
);
assert_eq!(text_nodes[1], " world", "space before 'world' preserved");
}
#[test]
fn indented_content_in_multiline_conditional_choice() {
let src = "=== test ===\n{true:\n + A choice\n Vaue of local var is: {x}\n -> END\n}\n->->\n";
let p = parse(src);
assert_eq!(src, p.syntax().text().to_string(), "lossless round-trip");
let text_nodes: Vec<String> = p
.syntax()
.descendants()
.filter(|n| n.kind() == SyntaxKind::TEXT)
.map(|n| n.text().to_string())
.collect();
let has_indented_text = text_nodes
.iter()
.any(|t| t.starts_with(" Vaue") || t.starts_with(" Vaue"));
assert!(
!has_indented_text,
"TEXT nodes should not contain leading indentation, found: {text_nodes:?}"
);
let has_clean_text = text_nodes.iter().any(|t| t.starts_with("Vaue"));
assert!(
has_clean_text,
"expected TEXT node starting with 'Vaue', found: {text_nodes:?}"
);
}
#[test]
fn tunnel_onwards_only() {
assert_equivalent(
parse("->->\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
DIVERT_NODE {
TUNNEL_ONWARDS_NODE
}
}
}),
);
}
#[test]
fn content_at_eof() {
assert_equivalent(
parse("Hello"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
}
}
}),
);
}
#[test]
fn glue_content_divert_tags() {
assert_equivalent(
parse("Hi<>there -> knot #tag\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
GLUE_NODE
TEXT
}
DIVERT_NODE {
SIMPLE_DIVERT {
DIVERT_TARGET_WITH_ARGS {
PATH
}
}
}
TAGS {
TAG
}
}
}),
);
}
#[test]
fn inline_bare_expr() {
assert_equivalent(
parse("Hello {x}\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
INLINE_LOGIC {
INNER_EXPRESSION {
PATH
}
}
}
}
}),
);
}
#[test]
fn inline_conditional() {
assert_equivalent(
parse("Hello {x: yes}\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
INLINE_LOGIC {
CONDITIONAL_WITH_EXPR {
PATH
INLINE_BRANCHES_COND {
BRANCH_CONTENT {
TEXT
}
}
}
}
}
}
}),
);
}
#[test]
fn inline_between_text() {
assert_equivalent(
parse("before {x} after\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
INLINE_LOGIC {
INNER_EXPRESSION {
PATH
}
}
TEXT
}
}
}),
);
}
#[test]
fn multiple_inlines() {
assert_equivalent(
parse("{a} and {b}\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
INLINE_LOGIC {
INNER_EXPRESSION {
PATH
}
}
TEXT
INLINE_LOGIC {
INNER_EXPRESSION {
PATH
}
}
}
}
}),
);
}
#[test]
fn space_between_inlines_preserved() {
assert_equivalent(
parse("{a} {b}\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
INLINE_LOGIC {
INNER_EXPRESSION {
PATH
}
}
TEXT
INLINE_LOGIC {
INNER_EXPRESSION {
PATH
}
}
}
}
}),
);
}
#[test]
fn inline_with_glue() {
assert_equivalent(
parse("<>{x}\n"),
cst!(SOURCE_FILE {
CONTENT_LINE {
MIXED_CONTENT {
GLUE_NODE
INLINE_LOGIC {
INNER_EXPRESSION {
PATH
}
}
}
}
}),
);
}
#[test]
fn content_in_knot_body() {
assert_equivalent(
parse("=== myKnot ===\nHello.\n"),
cst!(SOURCE_FILE {
KNOT_DEF {
KNOT_HEADER {
IDENTIFIER
}
KNOT_BODY {
CONTENT_LINE {
MIXED_CONTENT {
TEXT
}
}
}
}
}),
);
}
#[test]
fn choice_with_content() {
assert_equivalent(
parse("* Hello\n"),
cst!(SOURCE_FILE {
CHOICE {
CHOICE_BULLETS
CHOICE_START_CONTENT {
TEXT
}
}
}),
);
}
#[test]
fn gather_with_content() {
assert_equivalent(
parse("- Hello\n"),
cst!(SOURCE_FILE {
GATHER {
GATHER_DASHES
MIXED_CONTENT {
TEXT
}
}
}),
);
}
#[test]
fn choice_content_with_glue() {
assert_equivalent(
parse("* Hello<>world\n"),
cst!(SOURCE_FILE {
CHOICE {
CHOICE_BULLETS
CHOICE_START_CONTENT {
TEXT
GLUE_NODE
TEXT
}
}
}),
);
}
#[test]
fn gather_content_with_escape() {
assert_equivalent(
parse("- Hello \\# tag\n"),
cst!(SOURCE_FILE {
GATHER {
GATHER_DASHES
MIXED_CONTENT {
TEXT
ESCAPE
TEXT
}
}
}),
);
}
const MIXED_CONTENT_CHILDREN: [SyntaxKind; 4] = [
SyntaxKind::TEXT,
SyntaxKind::GLUE_NODE,
SyntaxKind::ESCAPE,
SyntaxKind::INLINE_LOGIC,
];
fn assert_content_invariants(src: &str) {
let p = parse(src);
assert!(p.errors().is_empty(), "unexpected errors: {:?}", p.errors());
for node in p.syntax().descendants() {
match node.kind() {
SyntaxKind::CONTENT_LINE => {
let mixed_count = node
.children()
.filter(|c| c.kind() == SyntaxKind::MIXED_CONTENT)
.count();
assert!(
mixed_count <= 1,
"CONTENT_LINE should have at most 1 MIXED_CONTENT, found {mixed_count} in `{src}`"
);
}
SyntaxKind::MIXED_CONTENT => {
let composite_count = node
.children()
.filter(|c| MIXED_CONTENT_CHILDREN.contains(&c.kind()))
.count();
assert!(
composite_count >= 1,
"MIXED_CONTENT should have at least one composite child, found 0 in `{src}`"
);
}
SyntaxKind::TEXT | SyntaxKind::ESCAPE | SyntaxKind::GLUE_NODE => {
let composite_children: Vec<_> = node.children().map(|c| c.kind()).collect();
assert!(
composite_children.is_empty(),
"{:?} should have no composite children, found {:?} in `{src}`",
node.kind(),
composite_children,
);
}
_ => {}
}
}
}
#[test]
fn invariants_plain_text() {
assert_content_invariants("Hello\n");
}
#[test]
fn invariants_glue() {
assert_content_invariants("a<>b\n");
}
#[test]
fn invariants_escape() {
assert_content_invariants("\\# tag\n");
}
#[test]
fn invariants_inline() {
assert_content_invariants("Hello {x}\n");
}
#[test]
fn invariants_mixed() {
assert_content_invariants("Hello \\# <>world {x}\n");
}
#[test]
fn invariants_divert() {
assert_content_invariants("Hello -> knot\n");
}
#[test]
fn invariants_tags() {
assert_content_invariants("Hello #tag\n");
}
#[test]
fn invariants_all() {
assert_content_invariants("Hello \\# <>world {x} -> knot #tag\n");
}
#[test]
fn invariants_multiple_lines() {
assert_content_invariants("Line 1.\nLine 2.\n");
}
#[test]
fn invariants_eof_no_newline() {
assert_content_invariants("Hello");
}
#[test]
fn backslash_at_eof() {
let src = "\\";
let p = parse(src);
assert_eq!(src, p.syntax().text().to_string(), "lossless round-trip");
}
#[test]
fn backslash_before_newline() {
let src = "\\\n";
let p = parse(src);
assert_eq!(src, p.syntax().text().to_string(), "lossless round-trip");
}
#[test]
fn unclosed_inline_logic() {
let src = "Hello {name\n";
let p = parse(src);
assert_eq!(src, p.syntax().text().to_string(), "lossless round-trip");
assert!(
!p.errors().is_empty(),
"expected parse error for unclosed inline logic"
);
}
#[test]
fn stray_rbrace_in_content() {
let src = "Hello } world\n";
let p = parse(src);
assert_eq!(src, p.syntax().text().to_string(), "lossless round-trip");
}