Skip to main content

parse_changelog/
lib.rs

1// SPDX-License-Identifier: Apache-2.0 OR MIT
2
3/*!
4Simple changelog parser, written in Rust.
5
6### Usage
7
8<!-- Note: Document from sync-markdown-to-rustdoc:start through sync-markdown-to-rustdoc:end
9     is synchronized from README.md. Any changes to that range are not preserved. -->
10<!-- tidy:sync-markdown-to-rustdoc:start -->
11
12To use this crate as a library, add this to your `Cargo.toml`:
13
14```toml
15[dependencies]
16parse-changelog = { version = "0.6", default-features = false }
17```
18
19<div class="rustdoc-alert rustdoc-alert-note">
20
21> **ⓘ Note**
22>
23> We recommend disabling default features because they enable CLI-related
24> dependencies which the library part does not use.
25
26</div>
27
28<!-- omit in toc -->
29### Examples
30
31```
32let changelog = "\
33## 0.1.2 - 2020-03-01
34
35- Bug fixes.
36
37## 0.1.1 - 2020-02-01
38
39- Added `Foo`.
40- Added `Bar`.
41
42## 0.1.0 - 2020-01-01
43
44Initial release
45";
46
47// Parse changelog.
48let changelog = parse_changelog::parse(changelog).unwrap();
49
50// Get the latest release.
51assert_eq!(changelog[0].version, "0.1.2");
52assert_eq!(changelog[0].title, "0.1.2 - 2020-03-01");
53assert_eq!(changelog[0].notes, "- Bug fixes.");
54
55// Get the specified release.
56assert_eq!(changelog["0.1.0"].title, "0.1.0 - 2020-01-01");
57assert_eq!(changelog["0.1.0"].notes, "Initial release");
58assert_eq!(changelog["0.1.1"].title, "0.1.1 - 2020-02-01");
59assert_eq!(
60    changelog["0.1.1"].notes,
61    "- Added `Foo`.\n\
62     - Added `Bar`."
63);
64```
65
66<!-- omit in toc -->
67### Optional features
68
69- **`serde`** — Implements [`serde::Serialize`](https://docs.rs/serde/latest/serde/trait.Serialize.html) trait for parse-changelog types.
70
71## Supported Format
72
73By default, this crate is intended to support markdown-based changelogs
74that have the title of each release starts with the version format based on
75[Semantic Versioning][semver]. (e.g., [Keep a Changelog][keepachangelog]'s
76changelog format.)
77
78<!-- omit in toc -->
79### Headings
80
81The heading for each release must be Atx-style (1-6 `#`) or
82Setext-style (`=` or `-` in a line under text), and the heading levels
83must match with other releases.
84
85Atx-style headings:
86
87```markdown
88# 0.1.0
89```
90
91```markdown
92## 0.1.0
93```
94
95Setext-style headings:
96
97```markdown
980.1.0
99=====
100```
101
102```markdown
1030.1.0
104-----
105```
106
107<!-- omit in toc -->
108### Titles
109
110The title of each release must start with a text or a link text (text with
111`[` and `]`) that starts with a valid [version format](#versions) or
112[prefix format](#prefixes). For example:
113
114```markdown
115# [0.2.0]
116
117description...
118
119# 0.1.0
120
121description...
122```
123
124<!-- omit in toc -->
125#### Prefixes
126
127You can include characters before the version as prefix.
128
129```text
130## Version 0.1.0
131   ^^^^^^^^
132```
133
134By default only "v", "Version ", "Release ", and "" (no prefix) are
135allowed as prefixes.
136
137To customize the prefix format, use the [`Parser::prefix_format`] method (library) or `--prefix-format` option (CLI).
138
139<!-- omit in toc -->
140#### Versions
141
142```text
143## v0.1.0 -- 2020-01-01
144    ^^^^^
145```
146
147The default version format is based on [Semantic Versioning][semver].
148
149This is parsed by using the following regular expression:
150
151```text
152^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z\.-]+)?(\+[0-9A-Za-z\.-]+)?$|^Unreleased$
153```
154
155<div class="rustdoc-alert rustdoc-alert-note">
156
157> **ⓘ Note**
158>
159> To get the 'Unreleased' section in the CLI, you need to explicitly specify 'Unreleased' as the version.
160
161</div>
162
163To customize the version format, use the [`Parser::version_format`] method (library) or `--version-format` option (CLI).
164
165<!-- omit in toc -->
166#### Suffixes
167
168You can freely include characters after the version.
169
170```text
171# 0.1.0 - 2020-01-01
172       ^^^^^^^^^^^^^
173```
174
175## Related Projects
176
177- [create-gh-release-action]: GitHub Action for creating GitHub Releases based on changelog. This action uses this crate for changelog parsing.
178
179[`Parser::prefix_format`]: https://docs.rs/parse-changelog/latest/parse_changelog/struct.Parser.html#method.prefix_format
180[`Parser::version_format`]: https://docs.rs/parse-changelog/latest/parse_changelog/struct.Parser.html#method.version_format
181[create-gh-release-action]: https://github.com/taiki-e/create-gh-release-action
182[keepachangelog]: https://keepachangelog.com
183[semver]: https://semver.org
184
185<!-- tidy:sync-markdown-to-rustdoc:end -->
186*/
187
188#![no_std]
189#![doc(test(
190    no_crate_inject,
191    attr(allow(
192        dead_code,
193        unused_variables,
194        clippy::undocumented_unsafe_blocks,
195        clippy::unused_trait_names,
196    ))
197))]
198#![forbid(unsafe_code)]
199#![warn(
200    // Lints that may help when writing public library.
201    missing_debug_implementations,
202    missing_docs,
203    clippy::alloc_instead_of_core,
204    clippy::exhaustive_enums,
205    clippy::exhaustive_structs,
206    clippy::impl_trait_in_params,
207    clippy::std_instead_of_alloc,
208    clippy::std_instead_of_core,
209    // clippy::missing_inline_in_public_items,
210)]
211// docs.rs only (cfg is enabled by docs.rs, not build script)
212#![cfg_attr(docsrs, feature(doc_cfg))]
213
214extern crate alloc;
215extern crate std;
216
217#[cfg(test)]
218mod tests;
219
220#[cfg(test)]
221#[path = "gen/tests/assert_impl.rs"]
222mod assert_impl;
223#[cfg(feature = "serde")]
224#[path = "gen/serde.rs"]
225mod serde_impl;
226#[cfg(test)]
227#[path = "gen/tests/track_size.rs"]
228mod track_size;
229
230mod error;
231
232use alloc::{borrow::Cow, format, string::String};
233use core::mem;
234use std::sync::OnceLock;
235
236use indexmap::IndexMap;
237use memchr::memmem;
238use regex::Regex;
239
240pub use self::error::Error;
241use self::error::Result;
242
243/// A changelog.
244///
245/// The key is a version, and the value is the release note for that version.
246///
247/// The order is the same as the order written in the original text. (e.g., if
248/// [the latest version comes first][keepachangelog], `changelog[0]` is the
249/// release note for the latest version)
250///
251/// This type is returned by [`parse`] function or [`Parser::parse`] method.
252///
253/// [keepachangelog]: https://keepachangelog.com
254pub type Changelog<'a> = IndexMap<&'a str, Release<'a>>;
255
256/// Parses release notes from the given `text`.
257///
258/// This function uses the default version and prefix format. If you want to use
259/// another format, consider using the [`Parser`] type instead.
260///
261/// See the [crate-level documentation](crate) for changelog and version
262/// format supported by default.
263///
264/// # Errors
265///
266/// Returns an error if any of the following:
267///
268/// - There are multiple release notes for one version.
269/// - No release note was found. This usually means that the changelog isn't
270///   written in the supported format.
271///
272/// If you want to handle these cases manually without making errors,
273/// consider using [`parse_iter`].
274pub fn parse(text: &str) -> Result<Changelog<'_>> {
275    Parser::new().parse(text)
276}
277
278/// Returns an iterator over all release notes in the given `text`.
279///
280/// Unlike [`parse`] function, the returned iterator doesn't error on
281/// duplicate release notes or empty changelog.
282///
283/// This function uses the default version and prefix format. If you want to use
284/// another format, consider using the [`Parser`] type instead.
285///
286/// See the [crate-level documentation](crate) for changelog and version
287/// format supported by default.
288pub fn parse_iter(text: &str) -> ParseIter<'_, 'static> {
289    ParseIter::new(text, None, None)
290}
291
292/// A release note for a version.
293#[derive(Debug, Clone, PartialEq, Eq)]
294#[non_exhaustive]
295pub struct Release<'a> {
296    /// The version of this release.
297    ///
298    /// ```text
299    /// ## Version 0.1.0 -- 2020-01-01
300    ///            ^^^^^
301    /// ```
302    ///
303    /// This is the same value as the key of the [`Changelog`] type.
304    pub version: &'a str,
305    /// The title of this release.
306    ///
307    /// ```text
308    /// ## Version 0.1.0 -- 2020-01-01
309    ///    ^^^^^^^^^^^^^^^^^^^^^^^^^^^
310    /// ```
311    ///
312    /// Note:
313    /// - Leading and trailing [whitespaces](char::is_whitespace) have been removed.
314    /// - This retains links in the title. Use [`title_no_link`](Self::title_no_link)
315    ///   if you want to use the title with links removed.
316    pub title: &'a str,
317    /// The descriptions of this release.
318    ///
319    /// Note that leading and trailing newlines have been removed.
320    pub notes: &'a str,
321}
322
323impl<'a> Release<'a> {
324    /// Returns the title of this release with link removed.
325    #[must_use]
326    pub fn title_no_link(&self) -> Cow<'a, str> {
327        full_unlink(self.title)
328    }
329}
330
331/// A changelog parser.
332#[derive(Debug, Default)]
333pub struct Parser {
334    /// Version format. e.g., "0.1.0" in "# v0.1.0 (2020-01-01)".
335    ///
336    /// If `None`, `DEFAULT_VERSION_FORMAT` is used.
337    version_format: Option<Regex>,
338    /// Prefix format. e.g., "v" in "# v0.1.0 (2020-01-01)", "Version " in
339    /// "# Version 0.1.0 (2020-01-01)".
340    ///
341    /// If `None`, `DEFAULT_PREFIX_FORMAT` is used.
342    prefix_format: Option<Regex>,
343}
344
345impl Parser {
346    /// Creates a new changelog parser.
347    #[must_use]
348    pub fn new() -> Self {
349        Self::default()
350    }
351
352    /// Sets the version format.
353    ///
354    /// ```text
355    /// ## v0.1.0 -- 2020-01-01
356    ///     ^^^^^
357    /// ```
358    ///
359    /// *Tip*: To customize the text before the version number (e.g., "v" in "# v0.1.0",
360    /// "Version " in "# Version 0.1.0", etc.), use the [`prefix_format`] method
361    /// instead of this method.
362    ///
363    /// # Default
364    ///
365    /// The default version format is based on [Semantic Versioning][semver].
366    ///
367    /// This is parsed by using the following regular expression:
368    ///
369    /// ```text
370    /// ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z\.-]+)?(\+[0-9A-Za-z\.-]+)?$|^Unreleased$
371    /// ```
372    ///
373    /// **Note:** To get the 'Unreleased' section in the CLI, you need to explicitly specify 'Unreleased' as the version.
374    ///
375    /// # Errors
376    ///
377    /// Returns an error if any of the following:
378    ///
379    /// - The specified format is not a valid regular expression or supported by
380    ///   [regex] crate.
381    /// - The specified format is empty or contains only
382    ///   [whitespace](char::is_whitespace).
383    ///
384    /// [`prefix_format`]: Self::prefix_format
385    /// [regex]: https://docs.rs/regex
386    /// [semver]: https://semver.org
387    pub fn version_format(&mut self, format: &str) -> Result<&mut Self> {
388        if format.trim_start().is_empty() {
389            return Err(Error::format("empty or whitespace version format"));
390        }
391        self.version_format = Some(Regex::new(format).map_err(Error::new)?);
392        Ok(self)
393    }
394
395    /// Sets the prefix format.
396    ///
397    /// "Prefix" means the range from the first non-whitespace character after
398    /// heading to the character before the version (including whitespace
399    /// characters). For example:
400    ///
401    /// ```text
402    /// ## Version 0.1.0 -- 2020-01-01
403    ///    ^^^^^^^^
404    /// ```
405    ///
406    /// ```text
407    /// ## v0.1.0 -- 2020-01-01
408    ///    ^
409    /// ```
410    ///
411    /// # Default
412    ///
413    /// By default only "v", "Version ", "Release ", and "" (no prefix) are
414    /// allowed as prefixes.
415    ///
416    /// This is parsed by using the following regular expression:
417    ///
418    /// ```text
419    /// ^(v|Version |Release )?
420    /// ```
421    ///
422    /// # Errors
423    ///
424    /// Returns an error if any of the following:
425    ///
426    /// - The specified format is not a valid regular expression or supported by
427    ///   [regex] crate.
428    ///
429    /// [regex]: https://docs.rs/regex
430    pub fn prefix_format(&mut self, format: &str) -> Result<&mut Self> {
431        self.prefix_format = Some(Regex::new(format).map_err(Error::new)?);
432        Ok(self)
433    }
434
435    /// Parses release notes from the given `text`.
436    ///
437    /// See the [crate-level documentation](crate) for changelog and version
438    /// format supported by default.
439    ///
440    /// # Errors
441    ///
442    /// Returns an error if any of the following:
443    ///
444    /// - There are multiple release notes for one version.
445    /// - No release note was found. This usually means that the changelog isn't
446    ///   written in the supported format, or that the specified format is wrong
447    ///   if you specify your own format.
448    ///
449    /// If you want to handle these cases manually without making errors,
450    /// consider using [`parse_iter`].
451    ///
452    /// [`parse_iter`]: Self::parse_iter
453    pub fn parse<'a>(&self, text: &'a str) -> Result<Changelog<'a>> {
454        let mut map = IndexMap::new();
455        for release in self.parse_iter(text) {
456            if let Some(release) = map.insert(release.version, release) {
457                return Err(Error::parse(format!(
458                    "multiple release notes for '{}'",
459                    release.version
460                )));
461            }
462        }
463        if map.is_empty() {
464            return Err(Error::parse("no release note was found"));
465        }
466        Ok(map)
467    }
468
469    /// Returns an iterator over all release notes in the given `text`.
470    ///
471    /// Unlike [`parse`] method, the returned iterator doesn't error on
472    /// duplicate release notes or empty changelog.
473    ///
474    /// See the [crate-level documentation](crate) for changelog and version
475    /// format supported by default.
476    ///
477    /// [`parse`]: Self::parse
478    pub fn parse_iter<'a, 'r>(&'r self, text: &'a str) -> ParseIter<'a, 'r> {
479        ParseIter::new(text, self.version_format.as_ref(), self.prefix_format.as_ref())
480    }
481}
482
483/// An iterator over release notes.
484///
485/// This type is returned by [`parse_iter`] function or [`Parser::parse_iter`] method.
486#[allow(missing_debug_implementations)]
487#[must_use = "iterators are lazy and do nothing unless consumed"]
488pub struct ParseIter<'a, 'r> {
489    version_format: &'r Regex,
490    prefix_format: &'r Regex,
491    find_open: memmem::Finder<'static>,
492    find_close: memmem::Finder<'static>,
493    lines: Lines<'a>,
494    /// The heading level of release sections. 1-6
495    level: Option<u8>,
496}
497
498const OPEN: &[u8] = b"<!--";
499const CLOSE: &[u8] = b"-->";
500
501fn default_prefix_format() -> &'static Regex {
502    static DEFAULT_PREFIX_FORMAT: OnceLock<Regex> = OnceLock::new();
503    fn init() -> Regex {
504        Regex::new(r"^(v|Version |Release )?").unwrap()
505    }
506    DEFAULT_PREFIX_FORMAT.get_or_init(init)
507}
508fn default_version_format() -> &'static Regex {
509    static DEFAULT_VERSION_FORMAT: OnceLock<Regex> = OnceLock::new();
510    fn init() -> Regex {
511        Regex::new(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z\.-]+)?(\+[0-9A-Za-z\.-]+)?$|^Unreleased$")
512            .unwrap()
513    }
514    DEFAULT_VERSION_FORMAT.get_or_init(init)
515}
516
517impl<'a, 'r> ParseIter<'a, 'r> {
518    fn new(
519        text: &'a str,
520        version_format: Option<&'r Regex>,
521        prefix_format: Option<&'r Regex>,
522    ) -> Self {
523        Self {
524            version_format: version_format.unwrap_or_else(|| default_version_format()),
525            prefix_format: prefix_format.unwrap_or_else(|| default_prefix_format()),
526            find_open: memmem::Finder::new(OPEN),
527            find_close: memmem::Finder::new(CLOSE),
528            lines: Lines::new(text),
529            level: None,
530        }
531    }
532
533    fn end_release(
534        &self,
535        mut cur_release: Release<'a>,
536        release_note_start: usize,
537        line_start: usize,
538    ) -> Release<'a> {
539        assert!(!cur_release.version.is_empty());
540        if release_note_start < line_start {
541            // Remove trailing newlines.
542            cur_release.notes = self.lines.text[release_note_start..line_start - 1].trim_end();
543        }
544        cur_release
545    }
546
547    fn handle_comment(&self, on_comment: &mut bool, line: &'a str) {
548        let mut line = Some(line);
549        while let Some(l) = line {
550            match (self.find_open.find(l.as_bytes()), self.find_close.find(l.as_bytes())) {
551                (None, None) => {}
552                // <!-- ...
553                (Some(_), None) => *on_comment = true,
554                // ... -->
555                (None, Some(_)) => *on_comment = false,
556                (Some(open), Some(close)) => {
557                    if open < close {
558                        // <!-- ... -->
559                        *on_comment = false;
560                        line = l.get(close + CLOSE.len()..);
561                    } else {
562                        // --> ... <!--
563                        *on_comment = true;
564                        line = l.get(open + OPEN.len()..);
565                    }
566                    continue;
567                }
568            }
569            break;
570        }
571    }
572}
573
574impl<'a> Iterator for ParseIter<'a, '_> {
575    type Item = Release<'a>;
576
577    fn next(&mut self) -> Option<Self::Item> {
578        // If `true`, we are in a code block (``` or ~~~).
579        let mut on_code_block: Option<&[u8]> = None;
580        // TODO: nested case?
581        // If `true`, we are in a comment (`<!--` and `-->`).
582        let mut on_comment = false;
583        let mut release_note_start = None;
584        let mut cur_release = Release { version: "", title: "", notes: "" };
585
586        while let Some((line, line_start, line_end)) = self.lines.peek() {
587            let heading = if on_code_block.is_some() || on_comment {
588                None
589            } else {
590                heading(line, &mut self.lines)
591            };
592            if heading.is_none() {
593                self.lines.next();
594                if let Some(fence) = on_code_block {
595                    let line = trim_start(line).as_bytes();
596                    if line.starts_with(fence) {
597                        on_code_block = None;
598                    }
599                } else {
600                    let line = trim_start(line).as_bytes();
601                    if let Some(&b @ (b'`' | b'~')) = line.first() {
602                        let mut len = 1;
603                        while line.get(len) == Some(&b) {
604                            len += 1;
605                        }
606                        if len >= 3 {
607                            on_code_block = Some(&line[..len]);
608                        }
609                    }
610                }
611
612                if on_code_block.is_none() {
613                    self.handle_comment(&mut on_comment, line);
614                }
615
616                // Non-heading lines are always considered part of the current
617                // section.
618
619                if line_end == self.lines.text.len() {
620                    break;
621                }
622                continue;
623            }
624            let heading = heading.unwrap();
625            if let Some(release_level) = self.level {
626                if heading.level > release_level {
627                    // Consider sections that have lower heading levels than
628                    // release sections are part of the current section.
629                    self.lines.next();
630                    if line_end == self.lines.text.len() {
631                        break;
632                    }
633                    continue;
634                }
635                if heading.level < release_level {
636                    // Ignore sections that have higher heading levels than
637                    // release sections.
638                    self.lines.next();
639                    if let Some(release_note_start) = release_note_start {
640                        return Some(self.end_release(cur_release, release_note_start, line_start));
641                    }
642                    if line_end == self.lines.text.len() {
643                        break;
644                    }
645                    continue;
646                }
647                if let Some(release_note_start) = release_note_start {
648                    return Some(self.end_release(cur_release, release_note_start, line_start));
649                }
650            }
651
652            debug_assert!(release_note_start.is_none());
653            let version = extract_version_from_title(heading.text, self.prefix_format).0;
654            if !self.version_format.is_match(version) {
655                // Ignore non-release sections that have the same heading
656                // levels as release sections.
657                self.lines.next();
658                if line_end == self.lines.text.len() {
659                    break;
660                }
661                continue;
662            }
663
664            cur_release.version = version;
665            cur_release.title = heading.text;
666            self.level.get_or_insert(heading.level);
667
668            self.lines.next();
669            if heading.style == HeadingStyle::Setext {
670                // Skip an underline after a Setext-style heading.
671                self.lines.next();
672            }
673            while let Some((next, ..)) = self.lines.peek() {
674                if next.trim_start().is_empty() {
675                    // Skip newlines after a heading.
676                    self.lines.next();
677                } else {
678                    break;
679                }
680            }
681            if let Some((_, line_start, _)) = self.lines.peek() {
682                release_note_start = Some(line_start);
683            } else {
684                break;
685            }
686        }
687
688        if !cur_release.version.is_empty() {
689            if let Some(release_note_start) = release_note_start {
690                // Remove trailing newlines.
691                cur_release.notes = self.lines.text[release_note_start..].trim_end();
692            }
693            return Some(cur_release);
694        }
695
696        None
697    }
698}
699
700struct Lines<'a> {
701    text: &'a str,
702    iter: memchr::Memchr<'a>,
703    line_start: usize,
704    peeked: Option<(&'a str, usize, usize)>,
705    peeked2: Option<(&'a str, usize, usize)>,
706}
707
708impl<'a> Lines<'a> {
709    fn new(text: &'a str) -> Self {
710        Self {
711            text,
712            iter: memchr::memchr_iter(b'\n', text.as_bytes()),
713            line_start: 0,
714            peeked: None,
715            peeked2: None,
716        }
717    }
718
719    fn peek(&mut self) -> Option<(&'a str, usize, usize)> {
720        self.peeked = self.next();
721        self.peeked
722    }
723
724    fn peek2(&mut self) -> Option<(&'a str, usize, usize)> {
725        let peeked = self.next();
726        let peeked2 = self.next();
727        self.peeked = peeked;
728        self.peeked2 = peeked2;
729        self.peeked2
730    }
731}
732
733impl<'a> Iterator for Lines<'a> {
734    type Item = (&'a str, usize, usize);
735
736    fn next(&mut self) -> Option<Self::Item> {
737        if let Some(triple) = self.peeked.take() {
738            return Some(triple);
739        }
740        if let Some(triple) = self.peeked2.take() {
741            return Some(triple);
742        }
743        let (line, line_end) = match self.iter.next() {
744            Some(line_end) => (&self.text[self.line_start..line_end], line_end),
745            None => (self.text.get(self.line_start..)?, self.text.len()),
746        };
747        let line_start = mem::replace(&mut self.line_start, line_end + 1);
748        Some((line, line_start, line_end))
749    }
750}
751
752struct Heading<'a> {
753    text: &'a str,
754    level: u8,
755    style: HeadingStyle,
756}
757
758#[derive(PartialEq)]
759enum HeadingStyle {
760    /// Atx-style headings use 1-6 `#` characters at the start of the line,
761    /// corresponding to header levels 1-6.
762    Atx,
763    /// Setext-style headings are "underlined" using equal signs `=` (for
764    /// first-level headings) and dashes `-` (for second-level headings).
765    Setext,
766}
767
768fn heading<'a>(line: &'a str, lines: &mut Lines<'a>) -> Option<Heading<'a>> {
769    let line = trim_start(line);
770    if line.as_bytes().first() == Some(&b'#') {
771        let mut level = 1;
772        while level <= 7 && line.as_bytes().get(level) == Some(&b'#') {
773            level += 1;
774        }
775        // https://pandoc.org/try/?params=%7B%22text%22%3A%22%23%23%23%23%23%23%5Cn%3D%3D%3D%5Cn%5Cn%23%23%23%23%23%23%23%5Cn%3D%3D%3D%5Cn%5Cn%23%23%23%23%23%23+%5Cn%3D%3D%3D%5Cn%5Cn%23%23%23%23%23%23%5Ct%5Cn%3D%3D%3D%5Cn%5Cn%23%23%23%23%23%23+a%5Cn%3D%3D%3D%5Cn%5Cn%23%23%23%23%23%23%5Cta%5Cn%3D%3D%3D%5Cn%5Cn%23%23%23%23%23%23+b%5Cn%5Cn%22%2C%22to%22%3A%22html5%22%2C%22from%22%3A%22commonmark%22%2C%22standalone%22%3Afalse%2C%22embed-resources%22%3Afalse%2C%22table-of-contents%22%3Afalse%2C%22number-sections%22%3Afalse%2C%22citeproc%22%3Afalse%2C%22html-math-method%22%3A%22plain%22%2C%22wrap%22%3A%22auto%22%2C%22highlight-style%22%3Anull%2C%22files%22%3A%7B%7D%2C%22template%22%3Anull%7D
776        if level < 7 && line.as_bytes().get(level).is_none_or(|&b| matches!(b, b' ' | b'\t')) {
777            return Some(Heading {
778                text: line.get(level + 1..).map(str::trim).unwrap_or_default(),
779                #[allow(clippy::cast_possible_truncation)] // false positive: level is < 7: https://github.com/rust-lang/rust-clippy/issues/7486
780                level: level as u8,
781                style: HeadingStyle::Atx,
782            });
783        }
784    }
785    if let Some((next, ..)) = lines.peek2() {
786        let next = trim_start(next);
787        match next.as_bytes().first() {
788            Some(b'=') => {
789                if next[1..].trim_end().as_bytes().iter().all(|&b| b == b'=') {
790                    return Some(Heading {
791                        text: line.trim_end(),
792                        level: 1,
793                        style: HeadingStyle::Setext,
794                    });
795                }
796            }
797            Some(b'-') => {
798                if next[1..].trim_end().as_bytes().iter().all(|&b| b == b'-') {
799                    return Some(Heading {
800                        text: line.trim_end(),
801                        level: 2,
802                        style: HeadingStyle::Setext,
803                    });
804                }
805            }
806            _ => {}
807        }
808    }
809    None
810}
811
812fn trim_start(s: &str) -> &str {
813    let mut count = 0;
814    while s.as_bytes().get(count) == Some(&b' ') {
815        count += 1;
816        if count == 4 {
817            return s;
818        }
819    }
820    // Indents less than 4 are ignored.
821    &s[count..]
822}
823
824fn extract_version_from_title<'a>(mut text: &'a str, prefix_format: &Regex) -> (&'a str, &'a str) {
825    // Remove link from prefix
826    // [Version 1.0.0 2022-01-01]
827    // ^
828    text = text.strip_prefix('[').unwrap_or(text);
829    // Remove prefix
830    // Version 1.0.0 2022-01-01]
831    // ^^^^^^^^
832    if let Some(m) = prefix_format.find(text) {
833        text = &text[m.end()..];
834    }
835    // Remove whitespace after the version and the strings following it
836    // 1.0.0 2022-01-01]
837    //      ^^^^^^^^^^^^
838    text = text.split(char::is_whitespace).next().unwrap();
839    // Remove link from version
840    // Version [1.0.0 2022-01-01]
841    //         ^
842    // [Version 1.0.0] 2022-01-01
843    //               ^
844    // Version [1.0.0] 2022-01-01
845    //         ^     ^
846    unlink(text)
847}
848
849/// Remove a link from the given markdown text.
850///
851/// # Note
852///
853/// This is not a full "unlink" on markdown. See `full_unlink` for "full" version.
854fn unlink(mut s: &str) -> (&str, &str) {
855    // [1.0.0]
856    // ^
857    s = s.strip_prefix('[').unwrap_or(s);
858    if let Some(pos) = memchr::memchr(b']', s.as_bytes()) {
859        // 1.0.0]
860        //      ^
861        if pos + 1 == s.len() {
862            return (&s[..pos], "");
863        }
864        let remaining = &s[pos + 1..];
865        // 1.0.0](link)
866        //      ^^^^^^^
867        // 1.0.0][link]
868        //      ^^^^^^^
869        for (open, close) in [(b'(', b')'), (b'[', b']')] {
870            if remaining.as_bytes().first() == Some(&open) {
871                if let Some(r_pos) = memchr::memchr(close, &remaining.as_bytes()[1..]) {
872                    return (&s[..pos], &remaining[r_pos + 2..]);
873                }
874            }
875        }
876        return (&s[..pos], remaining);
877    }
878    (s, "")
879}
880
881/// Remove links from the given markdown text.
882fn full_unlink(s: &str) -> Cow<'_, str> {
883    let mut remaining = s;
884    if let Some(mut pos) = memchr::memchr(b'[', remaining.as_bytes()) {
885        let mut buf = String::with_capacity(remaining.len());
886        loop {
887            buf.push_str(&remaining[..pos]);
888            let (t, r) = unlink(&remaining[pos..]);
889            buf.push_str(t);
890            remaining = r;
891            match memchr::memchr(b'[', remaining.as_bytes()) {
892                Some(p) => pos = p,
893                None => break,
894            }
895        }
896        buf.push_str(remaining);
897        buf.into()
898    } else {
899        remaining.into()
900    }
901}