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