yolk/templating/
comment_style.rs

1use std::borrow::Cow;
2
3pub const COMMENT_START: &str = "<yolk> ";
4
5use crate::util::create_regex;
6
7use super::element::{Block, Element};
8
9const PREFIX_COMMENT_SYMBOLS: [&str; 8] = ["//", "#", "--", ";", "%", "\"", "'", "rem"];
10const CIRCUMFIX_COMMENT_SYMBOLS: [(&str, &str); 5] = [
11    ("/*", "*/"),
12    ("<!--", "-->"),
13    ("{-", "-}"),
14    ("--[[", "]]"),
15    ("(", ")"),
16];
17
18#[derive(Debug, Clone, Eq, PartialEq, arbitrary::Arbitrary)]
19pub enum CommentStyle {
20    Prefix(String),
21    Circumfix(String, String),
22}
23
24impl Default for CommentStyle {
25    fn default() -> Self {
26        CommentStyle::Prefix("#".to_string())
27    }
28}
29
30// TODO: Technically, a lot of this could already be done in the parser
31// We could parse the indent, and yolk-comment-start and end stuff during the main parsing phase already
32// That would allow us to avoid having to regex here, which would potentially be noticably more performant,
33// and maybe even more elegant.
34// However, that would require the parser to be a lot more complex,
35// as well as requiring us to add a lot more information into the parsed data structure.
36// The performance benefits are likely more than negligible, so not worth it for now.
37
38impl CommentStyle {
39    /// Try to infer the CommentStyle from a line
40    pub fn try_infer(element: &Element<'_>) -> Option<Self> {
41        let line = match &element {
42            Element::Inline { line, .. }
43            | Element::NextLine {
44                tagged_line: line, ..
45            }
46            | Element::MultiLine {
47                block: Block {
48                    tagged_line: line, ..
49                },
50                ..
51            } => line,
52            Element::Conditional { blocks, .. } => &blocks.first()?.tagged_line,
53            Element::Plain(_) => return None,
54        };
55        let (left, right) = (line.left, line.right);
56
57        for (prefix, postfix) in &CIRCUMFIX_COMMENT_SYMBOLS {
58            if left.trim_end().ends_with(prefix) && right.trim_start().starts_with(postfix) {
59                return Some(CommentStyle::Circumfix(
60                    prefix.to_string(),
61                    postfix.to_string(),
62                ));
63            }
64        }
65        for prefix in &PREFIX_COMMENT_SYMBOLS {
66            if left.trim_end().ends_with(prefix) {
67                return Some(CommentStyle::Prefix(prefix.to_string()));
68            }
69        }
70        None
71    }
72
73    pub fn try_infer_from_elements(elements: &[Element<'_>]) -> Option<Self> {
74        for e in elements {
75            if let Some(style) = Self::try_infer(e) {
76                return Some(style);
77            }
78        }
79        None
80    }
81
82    #[allow(unused)]
83    pub fn prefix(left: &str) -> Self {
84        CommentStyle::Prefix(left.to_string())
85    }
86    #[allow(unused)]
87    pub fn circumfix(left: &str, right: &str) -> Self {
88        CommentStyle::Circumfix(left.to_string(), right.to_string())
89    }
90    pub fn left(&self) -> &str {
91        match self {
92            CommentStyle::Prefix(left) => left,
93            CommentStyle::Circumfix(left, _) => left,
94        }
95    }
96
97    pub fn toggle_string(&self, s: &str, enable: bool) -> String {
98        // TODO: Technically this could return Cow<'_, str> instead, but that's hard
99        s.split('\n')
100            .map(|x| self.toggle_line(x, enable))
101            .collect::<Vec<_>>()
102            .join("\n")
103    }
104
105    pub fn toggle_line<'a>(&self, line: &'a str, enable: bool) -> Cow<'a, str> {
106        if enable {
107            self.enable_line(line)
108        } else {
109            self.disable_line(line)
110        }
111    }
112
113    pub fn enable_line<'a>(&self, line: &'a str) -> Cow<'a, str> {
114        let left = self.left();
115        let re = create_regex(format!(
116            "{}{}",
117            regex::escape(left),
118            regex::escape(COMMENT_START)
119        ))
120        .unwrap();
121        let left_done = re.replace_all(line, "");
122        if let CommentStyle::Circumfix(_, right) = self {
123            let re_right = create_regex(regex::escape(right)).unwrap();
124            Cow::Owned(re_right.replace_all(&left_done, "").to_string())
125        } else {
126            left_done
127        }
128    }
129
130    pub fn is_disabled(&self, line: &str) -> bool {
131        let re = match self {
132            CommentStyle::Prefix(left) => {
133                format!("^.*{}{}", regex::escape(left), regex::escape(COMMENT_START))
134            }
135            CommentStyle::Circumfix(left, right) => format!(
136                "^.*{}{}.*{}",
137                regex::escape(left),
138                regex::escape(COMMENT_START),
139                regex::escape(right)
140            ),
141        };
142        create_regex(re).unwrap().is_match(line)
143    }
144
145    pub fn disable_line<'a>(&self, line: &'a str) -> Cow<'a, str> {
146        if self.is_disabled(line) || line.trim().is_empty() {
147            return line.into();
148        }
149        let left = self.left();
150        let re = create_regex("^(\\s*)(.*)$").unwrap();
151        let (indent, remaining_line) = re
152            .captures(line)
153            .and_then(|x| x.get(1).zip(x.get(2)))
154            .map(|(a, b)| (a.as_str(), b.as_str()))
155            .unwrap_or_default();
156        let right = match self {
157            CommentStyle::Prefix(_) => "".to_string(),
158            CommentStyle::Circumfix(_, right) => right.to_string(),
159        };
160        format!("{indent}{left}{COMMENT_START}{remaining_line}{right}",).into()
161    }
162}
163
164#[cfg(test)]
165mod test {
166    use crate::util::test_util::TestResult;
167
168    use crate::templating::element::Element;
169
170    use super::CommentStyle;
171
172    use pretty_assertions::assert_eq;
173    use rstest::rstest;
174
175    #[rstest]
176    #[case("  foo", "  #<yolk> foo", CommentStyle::prefix("#"))]
177    #[case("  foo", "  /*<yolk> foo*/", CommentStyle::circumfix("/*", "*/"))]
178    #[case("foo", "#<yolk> foo", CommentStyle::prefix("#"))]
179    #[case("foo", "/*<yolk> foo*/", CommentStyle::circumfix("/*", "*/"))]
180    fn test_disable_enable_roundtrip(
181        #[case] start: &str,
182        #[case] expected_disabled: &str,
183        #[case] comment_style: CommentStyle,
184    ) {
185        let disabled = comment_style.disable_line(start);
186        let enabled = comment_style.enable_line(disabled.as_ref());
187        assert_eq!(expected_disabled, disabled);
188        assert_eq!(start, enabled);
189    }
190
191    #[rstest]
192    #[case(CommentStyle::prefix("#"), "\tfoo")]
193    #[case(CommentStyle::prefix("#"), "foo  ")]
194    #[case(CommentStyle::circumfix("/*", "*/"), "  foo  ")]
195    fn test_enable_idempotent(#[case] comment_style: CommentStyle, #[case] line: &str) {
196        let enabled = comment_style.enable_line(line);
197        let enabled_again = comment_style.enable_line(enabled.as_ref());
198        assert_eq!(enabled, enabled_again);
199    }
200
201    #[rstest]
202    #[case(CommentStyle::prefix("#"), "\tfoo")]
203    #[case(CommentStyle::prefix("#"), "foo  ")]
204    #[case(CommentStyle::circumfix("/*", "*/"), "  foo  ")]
205    fn test_disable_idempotent(#[case] comment_style: CommentStyle, #[case] line: &str) {
206        let disabled = comment_style.disable_line(line);
207        let disabled_again = comment_style.disable_line(disabled.as_ref());
208        assert_eq!(disabled, disabled_again);
209    }
210
211    #[rstest]
212    #[case("# {< foo >}", Some(CommentStyle::prefix("#")))]
213    #[case("/* {< foo >} */", Some(CommentStyle::circumfix("/*", "*/")))]
214    fn test_infer_comment_syntax(
215        #[case] input: &str,
216        #[case] expected: Option<CommentStyle>,
217    ) -> TestResult {
218        assert_eq!(
219            CommentStyle::try_infer(&Element::try_from_str(input)?),
220            expected
221        );
222        Ok(())
223    }
224}