Skip to main content

atypical_commit/
lib.rs

1// Syntax to follow:
2// <keyword>[<modifier>][<open_delim><enclosure><close_delim>]...[<modifier>]: <description>
3
4use chumsky::prelude::*;
5
6pub type DelimitedBy = [char; 2];
7
8#[doc(alias("Type", "Verb"))]
9pub type Keyword<'i> = &'i str;
10
11#[doc(alias("Importance", "BreakingChange"))]
12pub type Modifier<'i> = &'i str;
13
14#[doc(alias("Scope"))]
15pub type Enclosure<'i> = (&'i str, DelimitedBy);
16
17#[derive(Debug, Clone, PartialEq)]
18pub struct Prefix<'i> {
19    pub keyword: Keyword<'i>,
20    pub modifier: Option<Modifier<'i>>,
21    pub enclosures: Vec<Enclosure<'i>>,
22}
23
24#[doc(alias("Subject"))]
25pub type Description<'i> = &'i str;
26
27#[derive(Debug, Clone, PartialEq)]
28pub struct Header<'i> {
29    pub prefix: Prefix<'i>,
30    pub description: Description<'i>,
31}
32
33#[derive(Debug, Clone, PartialEq)]
34pub enum Sequence {
35    Pre,
36    Post,
37}
38
39pub type KeywordToken<'i> = Keyword<'i>;
40
41pub type ModifierToken<'i> = Modifier<'i>;
42
43#[derive(Debug, Clone, PartialEq)]
44pub enum EnclosureToken<'i> {
45    Flexible(DelimitedBy),
46    Strict(DelimitedBy, Vec<&'i str>),
47}
48
49impl<'i> EnclosureToken<'i> {
50    #[inline]
51    pub fn delimiters(&self) -> DelimitedBy {
52        match self {
53            EnclosureToken::Flexible(delimiters) => *delimiters,
54            EnclosureToken::Strict(delimiters, _) => *delimiters,
55        }
56    }
57}
58
59#[derive(Debug, Clone, PartialEq)]
60pub struct Tokens<'i> {
61    pub keywords: Vec<KeywordToken<'i>>,
62    pub modifiers: Vec<ModifierToken<'i>>,
63    pub enclosures: Vec<EnclosureToken<'i>>,
64    pub separator: char,
65
66    pub modifier_sequence: Sequence,
67}
68
69pub struct Positional {
70    pub modifier_sequence: Sequence,
71}
72
73impl Tokens<'_> {
74    pub fn preset_standard() -> Self {
75        Self {
76            keywords: vec!["add", "rem", "ref", "fix", "undo", "release"],
77            modifiers: vec!["?", "!", "!!"],
78            enclosures: vec![
79                EnclosureToken::Strict(
80                    ['(', ')'],
81                    vec!["exe", "lib", "test", "build", "doc", "ci", "cd"],
82                ),
83                EnclosureToken::Strict(
84                    ['[', ']'],
85                    vec![
86                        "int", "pre", "eff", "rel", "cmp", "mnt", "tmp", "exp",
87                        "sec", "upg", "ux", "pol", "sty",
88                    ],
89                ),
90            ],
91            separator: ':',
92            modifier_sequence: Sequence::Pre,
93        }
94    }
95}
96
97impl Default for Tokens<'_> {
98    fn default() -> Self {
99        Self::preset_standard()
100    }
101}
102
103pub type ExtraError<'i> = Rich<'i, char>;
104
105pub type ExtraState<'i> = ();
106
107#[doc(alias("Config", "Settings"))]
108#[derive(Debug, Clone, PartialEq)]
109pub struct ExtraContext<'i> {
110    pub tokens: Tokens<'i>,
111}
112
113impl<'i> ExtraContext<'i> {
114    pub fn new(tokens: &Tokens<'i>) -> Self {
115        fn sort(v: &mut Vec<&str>) {
116            v.sort_unstable_by(|a, b| b.len().cmp(&a.len()).then(a.cmp(b)));
117        }
118
119        let mut tokens = tokens.clone();
120
121        sort(&mut tokens.keywords);
122        sort(&mut tokens.modifiers);
123
124        Self { tokens }
125    }
126}
127
128impl<'i> Default for ExtraContext<'i> {
129    fn default() -> Self {
130        Self::new(&Tokens::default())
131    }
132}
133
134impl<'i> From<Tokens<'i>> for ExtraContext<'i> {
135    fn from(val: Tokens<'i>) -> Self {
136        ExtraContext::new(&val)
137    }
138}
139
140#[doc(alias("Config", "Settings"))]
141pub type Extra<'i> =
142    extra::Full<ExtraError<'i>, ExtraState<'i>, ExtraContext<'i>>;
143
144fn ident<'i>(
145    i: &mut chumsky::input::InputRef<'i, '_, &'i str, Extra<'i>>,
146) -> (&'i str, SimpleSpan) {
147    let before = i.cursor();
148
149    while i
150        .peek()
151        .is_some_and(|c: char| c.is_alphanumeric() || c == '_')
152    {
153        i.next();
154    }
155
156    (i.slice_since(&before..), i.span_since(&before))
157}
158
159fn expected_one_of(found: &str, kind: &str, expected: &[&str]) -> String {
160    let expected = expected.join(", ");
161
162    if found.is_empty() {
163        format!("expected {kind}, one of: {expected}")
164    } else {
165        format!("unknown {kind} `{found}`, expected one of: {expected}")
166    }
167}
168
169pub fn keyword<'i>() -> impl Parser<'i, &'i str, Keyword<'i>, Extra<'i>> {
170    use chumsky::input::InputRef;
171
172    custom(|i: &mut InputRef<&'i str, Extra<'i>>| {
173        let (s, span) = ident(i);
174        let keywords = &i.ctx().tokens.keywords;
175
176        if keywords.contains(&s) {
177            return Ok(s);
178        }
179
180        let message = expected_one_of(s, "keyword", keywords);
181
182        Err(Rich::custom(span, message))
183    })
184}
185
186pub fn modifier<'i>() -> impl Parser<'i, &'i str, Modifier<'i>, Extra<'i>> {
187    use chumsky::input::InputRef;
188
189    custom(|i: &mut InputRef<&'i str, Extra<'i>>| {
190        let parsers = i
191            .ctx()
192            .tokens
193            .modifiers
194            .iter()
195            .map(|&token| just(token))
196            .collect::<Vec<_>>();
197
198        i.parse(choice(parsers))
199    })
200}
201
202pub fn enclosures<'i>()
203-> impl Parser<'i, &'i str, Vec<Enclosure<'i>>, Extra<'i>> {
204    use chumsky::input::InputRef;
205
206    fn parser<'i>(
207        token: &EnclosureToken<'i>,
208    ) -> impl Parser<'i, &'i str, Enclosure<'i>, Extra<'i>> {
209        match *token {
210            EnclosureToken::Flexible([start, end]) => {
211                none_of::<'i, _, _, Extra>([start, end])
212                    .repeated()
213                    .to_slice()
214                    .delimited_by(just(start), just(end))
215                    .map(move |s| (s, [start, end]))
216                    .boxed()
217            }
218            EnclosureToken::Strict([start, end], ref allowed) => {
219                let allowed = allowed.clone();
220
221                custom(move |i: &mut InputRef<&'i str, Extra<'i>>| {
222                    let (s, span) = ident(i);
223
224                    if allowed.contains(&s) {
225                        return Ok(s);
226                    }
227
228                    let message = expected_one_of(s, "enclosure", &allowed);
229
230                    Err(Rich::custom(span, message))
231                })
232                .delimited_by(just(start), just(end))
233                .map(move |s| (s, [start, end]))
234                .boxed()
235            }
236        }
237    }
238
239    custom(|i: &mut InputRef<&'i str, Extra<'i>>| {
240        let ctx = i.ctx();
241        let delimiters = ctx.tokens.enclosures.clone();
242        let mut index = 0;
243        let mut results = Vec::new();
244
245        loop {
246            if index >= delimiters.len() {
247                break;
248            }
249
250            let next = i.peek();
251            let is_open = delimiters[index..]
252                .iter()
253                .any(|enclosure| Some(enclosure.delimiters()[0]) == next);
254
255            if !is_open {
256                break;
257            }
258
259            let parsers =
260                delimiters[index..].iter().map(parser).collect::<Vec<_>>();
261
262            let (content, delimited_by) = i.parse(choice(parsers))?;
263            let position = delimiters
264                .iter()
265                .position(|enclosure| enclosure.delimiters() == delimited_by)
266                .unwrap();
267            index += position + 1;
268            results.push((content, delimited_by));
269        }
270
271        Ok(results)
272    })
273}
274
275pub fn separator<'i>() -> impl Parser<'i, &'i str, char, Extra<'i>> {
276    use chumsky::input::InputRef;
277
278    custom(|i: &mut InputRef<&'i str, Extra<'i>>| {
279        let ctx = i.ctx();
280
281        i.parse(just(ctx.tokens.separator))
282    })
283}
284
285pub fn modifier_when<'i>(
286    sequence: Sequence,
287) -> impl Parser<'i, &'i str, Option<Modifier<'i>>, Extra<'i>> {
288    use chumsky::input::InputRef;
289
290    custom(move |i: &mut InputRef<&'i str, Extra<'i>>| {
291        if i.ctx().tokens.modifier_sequence != sequence {
292            return Ok(None);
293        }
294
295        i.parse(modifier().or_not())
296    })
297}
298
299pub fn description<'i>() -> impl Parser<'i, &'i str, Description<'i>, Extra<'i>>
300{
301    use chumsky::input::InputRef;
302
303    custom(|i: &mut InputRef<&'i str, Extra<'i>>| {
304        let before = i.cursor();
305
306        while i.peek().is_some_and(|c: char| c != '\n') {
307            i.next();
308        }
309
310        let s = i.slice_since(&before..);
311        let span = i.span_since(&before);
312
313        let Some(rest) = s.strip_prefix(' ') else {
314            let message = if s.trim().is_empty() {
315                "expected a description after the separator"
316            } else {
317                "expected a space before the description"
318            };
319
320            return Err(Rich::custom(span, message));
321        };
322
323        if rest.trim().is_empty() {
324            return Err(Rich::custom(
325                span,
326                "expected a description after the separator",
327            ));
328        }
329
330        Ok(rest.trim_end())
331    })
332}
333
334pub fn prefix<'i>() -> impl Parser<'i, &'i str, Prefix<'i>, Extra<'i>> {
335    let keyword = keyword();
336
337    let modifier_pre = modifier_when(Sequence::Pre);
338
339    let enclosures = enclosures();
340
341    let modifier_post = modifier_when(Sequence::Post);
342
343    let separator = separator();
344
345    group((keyword, modifier_pre, enclosures, modifier_post, separator)).map(
346        |(keyword, modifier_pre, enclosures, modifier_post, _)| Prefix {
347            keyword,
348            modifier: modifier_pre.or(modifier_post),
349            enclosures,
350        },
351    )
352}
353
354pub fn header<'i>() -> impl Parser<'i, &'i str, Header<'i>, Extra<'i>> {
355    group((prefix(), description())).map(|(prefix, description)| Header {
356        prefix,
357        description,
358    })
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn test_keyword() {
367        fn parser_standard<'i>()
368        -> impl Parser<'i, &'i str, Keyword<'i>, Extra<'i>> {
369            keyword().with_ctx(Tokens::preset_standard().into())
370        }
371
372        assert!(parser_standard().parse("").has_errors());
373
374        assert_eq!(parser_standard().parse("add").into_result(), Ok("add"));
375        assert_eq!(parser_standard().parse("rem").into_result(), Ok("rem"));
376        assert!(parser_standard().parse("feat").has_errors());
377    }
378
379    #[test]
380    fn test_modifier() {
381        fn parser_standard<'i>()
382        -> impl Parser<'i, &'i str, Modifier<'i>, Extra<'i>> {
383            modifier().with_ctx(Tokens::preset_standard().into())
384        }
385
386        assert!(parser_standard().parse("").has_errors());
387
388        assert_eq!(parser_standard().parse("?").into_result(), Ok("?"));
389        assert_eq!(parser_standard().parse("!!").into_result(), Ok("!!"));
390        assert!(parser_standard().parse("??").has_errors());
391    }
392
393    #[test]
394    fn test_enclosures() {
395        fn parser_standard<'i>()
396        -> impl Parser<'i, &'i str, Vec<Enclosure<'i>>, Extra<'i>> {
397            enclosures().with_ctx(Tokens::preset_standard().into())
398        }
399
400        assert_eq!(parser_standard().parse("").into_result(), Ok(vec![]));
401
402        assert_eq!(
403            parser_standard().parse("(lib)").into_result(),
404            Ok(vec![("lib", ['(', ')'])])
405        );
406        assert_eq!(
407            parser_standard().parse("[pre]").into_result(),
408            Ok(vec![("pre", ['[', ']'])])
409        );
410        assert_eq!(
411            parser_standard().parse("(exe)[int]").into_result(),
412            Ok(vec![("exe", ['(', ')']), ("int", ['[', ']'])])
413        );
414        assert!(parser_standard().parse("(").has_errors());
415        assert!(parser_standard().parse("(unsupported)").has_errors());
416        assert!(parser_standard().parse("{unsupported}").has_errors());
417        assert!(parser_standard().parse("[pre](lib)").has_errors());
418        assert!(parser_standard().parse("(exe)(lib)").has_errors());
419    }
420
421    #[test]
422    fn test_separator() {
423        fn parser_standard<'i>() -> impl Parser<'i, &'i str, char, Extra<'i>> {
424            separator().with_ctx(Tokens::preset_standard().into())
425        }
426
427        assert!(parser_standard().parse("").has_errors());
428
429        assert_eq!(parser_standard().parse(":").into_result(), Ok(':'));
430        assert!(parser_standard().parse(";").has_errors());
431    }
432
433    #[test]
434    fn test_prefix() {
435        fn parser_standard<'i>()
436        -> impl Parser<'i, &'i str, Prefix<'i>, Extra<'i>> {
437            prefix().with_ctx(Tokens::preset_standard().into())
438        }
439
440        assert!(parser_standard().parse("").has_errors());
441
442        assert_eq!(
443            parser_standard().parse("add:").into_result(),
444            Ok(Prefix {
445                keyword: "add",
446                modifier: None,
447                enclosures: vec![]
448            })
449        );
450        assert_eq!(
451            parser_standard().parse("rem?(lib):").into_result(),
452            Ok(Prefix {
453                keyword: "rem",
454                modifier: Some("?"),
455                enclosures: vec![("lib", ['(', ')'])]
456            })
457        );
458        assert_eq!(
459            parser_standard().parse("ref!![eff]:").into_result(),
460            Ok(Prefix {
461                keyword: "ref",
462                modifier: Some("!!"),
463                enclosures: vec![("eff", ['[', ']'])]
464            })
465        );
466        assert!(parser_standard().parse("add").has_errors());
467        assert!(parser_standard().parse("feat:").has_errors());
468        assert!(parser_standard().parse("add(exe)!:").has_errors());
469    }
470
471    #[test]
472    fn test_description() {
473        fn parser_standard<'i>()
474        -> impl Parser<'i, &'i str, Description<'i>, Extra<'i>> {
475            description().with_ctx(Tokens::preset_standard().into())
476        }
477
478        assert!(parser_standard().parse("").has_errors());
479        assert!(parser_standard().parse(" ").has_errors());
480        assert!(parser_standard().parse("no space").has_errors());
481
482        assert_eq!(parser_standard().parse(" ok").into_result(), Ok("ok"));
483        assert_eq!(
484            parser_standard().parse(" trailing ").into_result(),
485            Ok("trailing")
486        );
487    }
488
489    #[test]
490    fn test_header() {
491        fn parser_standard<'i>()
492        -> impl Parser<'i, &'i str, Header<'i>, Extra<'i>> {
493            header().with_ctx(Tokens::preset_standard().into())
494        }
495
496        assert!(parser_standard().parse("").has_errors());
497        assert!(parser_standard().parse("add:").has_errors());
498        assert!(parser_standard().parse("add: ").has_errors());
499        assert!(parser_standard().parse("add:no space").has_errors());
500
501        assert_eq!(
502            parser_standard()
503                .parse("add(exe)[int]: initial")
504                .into_result(),
505            Ok(Header {
506                prefix: Prefix {
507                    keyword: "add",
508                    modifier: None,
509                    enclosures: vec![("exe", ['(', ')']), ("int", ['[', ']'])]
510                },
511                description: "initial"
512            })
513        );
514    }
515}