1use regex::Regex;
20use std::sync::LazyLock;
21
22static TAG_TOKEN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`(@[^`]+)`").unwrap());
25
26pub const MIN_TABLE_INDENT: usize = 2;
29
30pub const MAX_TABLE_INDENT: usize = 5;
32
33pub 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
43pub fn is_tag_line(line: &str) -> bool {
49 TAG_TOKEN.is_match(line)
50}
51
52pub 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 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}