Skip to main content

rumdl_lib/utils/
mdg.rs

1//! Line-level tokens the Markdown with Gherkin flavor inherits from Gherkin.
2//!
3//! Gherkin is line-oriented: its parser classifies Markdown content one line at
4//! a time. MDG embeds three of its recognized shapes in Markdown, where each
5//! one collides with a Markdown construct that would otherwise rewrite or
6//! delete it:
7//!
8//! - a Data Table or Examples row, matched as `/^\s\s\s?\s?\s?\|/`, whose
9//!   indentation overlaps the 4-column indented-code threshold;
10//! - a tag line, spelled in MDG as backtick-wrapped `@tags` so Gherkin's bare
11//!   `@tag` survives as Markdown, which binds to the structure on the very next
12//!   line;
13//! - a structure heading, `Keyword: name`, whose keyword is a dialect term that
14//!   names a structure only when spelled exactly.
15//!
16//! Each rule decides for itself what to do about a token; this module only says
17//! what Gherkin sees.
18
19use regex::Regex;
20use std::sync::LazyLock;
21
22/// The JavaScript reference matcher scans globally rather than requiring the
23/// whole line to consist of tags.
24static TAG_TOKEN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`(@[^`]+)`").unwrap());
25
26/// The narrowest indentation Gherkin accepts for a Data Table or Examples
27/// table, and therefore the canonical width such a table is normalized to.
28pub const MIN_TABLE_INDENT: usize = 2;
29
30/// The widest indentation Gherkin still reads as such a table.
31pub const MAX_TABLE_INDENT: usize = 5;
32
33/// Whether `line` is a row of a Gherkin Data Table or Examples table.
34///
35/// Gherkin matches such a row as 2-5 whitespace characters followed by a pipe,
36/// so a tab counts like a space. Anything else in that position, a blockquote
37/// marker or a list marker included, stays ordinary Markdown.
38pub fn is_table_row(line: &str) -> bool {
39    let indent = line.bytes().take_while(|&byte| byte == b' ' || byte == b'\t').count();
40    (MIN_TABLE_INDENT..=MAX_TABLE_INDENT).contains(&indent) && line.as_bytes().get(indent) == Some(&b'|')
41}
42
43/// Whether Gherkin finds at least one backtick-wrapped tag on a line.
44///
45/// This deliberately mirrors the reference `/`(@[^`]+)`/g` scan: surrounding
46/// prose and trailing comments do not disqualify a tag, and the tag body may
47/// contain whitespace or `#` as long as it reaches a closing backtick.
48pub fn is_tag_line(line: &str) -> bool {
49    TAG_TOKEN.is_match(line)
50}
51
52/// Split a structure heading into its keyword, colon included, and the name
53/// that follows, or `None` when the text names no structure.
54///
55/// The colon alone marks the structure: keywords are localized, so no list of
56/// them can name them all. A backtick before the colon does disqualify it
57/// though: dialect keywords are one or two plain words, so such a colon is
58/// inside a code span rather than behind a keyword.
59pub fn keyword_split(text: &str) -> Option<(&str, &str)> {
60    let colon = text.find(':')?;
61    (!text[..colon].contains('`')).then(|| text.split_at(colon + 1))
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67
68    #[test]
69    fn table_row_accepts_gherkins_whole_indent_range() {
70        for indent in ["  ", "   ", "    ", "     ", "\t\t", " \t", "\t   \t"] {
71            assert!(
72                is_table_row(&format!("{indent}| a | b |")),
73                "{indent:?} indents a table"
74            );
75        }
76    }
77
78    #[test]
79    fn table_row_rejects_an_indent_outside_the_range() {
80        for indent in ["", " ", "      ", "\t\t\t\t\t\t"] {
81            assert!(
82                !is_table_row(&format!("{indent}| a | b |")),
83                "{indent:?} is outside Gherkin's range"
84            );
85        }
86    }
87
88    #[test]
89    fn table_row_needs_a_pipe_behind_its_indent() {
90        for line in ["  > | a | b |", "  - | a | b |", "  text | a |", "   ", "  "] {
91            assert!(!is_table_row(line), "{line:?} is not a table row");
92        }
93    }
94
95    #[test]
96    fn tag_line_matches_gherkin_reference_scan() {
97        for line in [
98            "`@browser`",
99            "`@checkout` `@smoke`",
100            "  `@a`\t`@b`  ",
101            "`@a``@b`",
102            "`@comment_tag1` #a comment",
103            "prose `@a` after",
104            "`@a b`",
105            "`@comment_tag#2` #a comment",
106        ] {
107            assert!(is_tag_line(line), "{line:?} is a tag line");
108        }
109    }
110
111    #[test]
112    fn tag_line_requires_at_least_one_complete_wrapped_tag() {
113        for line in ["", "   ", "plain prose", "@browser", "`browser`", "`@`", "`@a"] {
114            assert!(!is_tag_line(line), "{line:?} is not a tag line");
115        }
116    }
117
118    #[test]
119    fn keyword_split_keeps_the_colon_with_the_keyword() {
120        assert_eq!(keyword_split("Feature: Checkout"), Some(("Feature:", " Checkout")));
121        assert_eq!(keyword_split("Scenario:name"), Some(("Scenario:", "name")));
122        assert_eq!(keyword_split("Examples:"), Some(("Examples:", "")));
123    }
124
125    #[test]
126    fn keyword_split_takes_the_first_colon() {
127        // A later colon belongs to the name, which stays prose.
128        assert_eq!(keyword_split("Scenario: a: b"), Some(("Scenario:", " a: b")));
129    }
130
131    #[test]
132    fn keyword_split_declines_a_colon_behind_a_backtick() {
133        assert_eq!(keyword_split("A `b: c` d"), None);
134        assert_eq!(keyword_split("`a: b"), None);
135    }
136
137    #[test]
138    fn keyword_split_declines_text_without_a_colon() {
139        assert_eq!(keyword_split("Notes"), None);
140        assert_eq!(keyword_split(""), None);
141    }
142
143    #[test]
144    fn keyword_split_takes_a_keyword_colon_that_precedes_a_backtick() {
145        assert_eq!(keyword_split("Scenario: a `b` c"), Some(("Scenario:", " a `b` c")));
146    }
147}