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