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`] 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#![doc(test(
189    no_crate_inject,
190    attr(
191        deny(warnings, rust_2018_idioms, single_use_lifetimes),
192        allow(dead_code, unused_variables)
193    )
194))]
195#![forbid(unsafe_code)]
196#![warn(
197    // Lints that may help when writing public library.
198    missing_debug_implementations,
199    missing_docs,
200    clippy::alloc_instead_of_core,
201    clippy::exhaustive_enums,
202    clippy::exhaustive_structs,
203    clippy::impl_trait_in_params,
204    // clippy::missing_inline_in_public_items,
205    // clippy::std_instead_of_alloc,
206    clippy::std_instead_of_core,
207)]
208// docs.rs only (cfg is enabled by docs.rs, not build script)
209#![cfg_attr(docsrs, feature(doc_cfg))]
210
211#[cfg(test)]
212mod tests;
213
214#[cfg(test)]
215#[path = "gen/tests/assert_impl.rs"]
216mod assert_impl;
217#[cfg(feature = "serde")]
218#[cfg_attr(docsrs, doc(cfg(feature = "serde")))]
219#[path = "gen/serde.rs"]
220mod serde_impl;
221#[cfg(test)]
222#[path = "gen/tests/track_size.rs"]
223mod track_size;
224
225mod error;
226
227use core::mem;
228use std::{borrow::Cow, sync::OnceLock};
229
230use indexmap::IndexMap;
231use memchr::memmem;
232use regex::Regex;
233
234pub use crate::error::Error;
235use crate::error::Result;
236
237/// A changelog.
238///
239/// The key is a version, and the value is the release note for that version.
240///
241/// The order is the same as the order written in the original text. (e.g., if
242/// [the latest version comes first][keepachangelog], `changelog[0]` is the
243/// release note for the latest version)
244///
245/// This type is returned by [`parse`] function or [`Parser::parse`] method.
246///
247/// [keepachangelog]: https://keepachangelog.com
248pub type Changelog<'a> = IndexMap<&'a str, Release<'a>>;
249
250/// Parses release notes from the given `text`.
251///
252/// This function uses the default version and prefix format. If you want to use
253/// another format, consider using the [`Parser`] type instead.
254///
255/// See the [crate-level documentation](crate) for changelog and version
256/// format supported by default.
257///
258/// # Errors
259///
260/// Returns an error if any of the following:
261///
262/// - There are multiple release notes for one version.
263/// - No release note was found. This usually means that the changelog isn't
264///   written in the supported format.
265///
266/// If you want to handle these cases manually without making errors,
267/// consider using [`parse_iter`].
268pub fn parse(text: &str) -> Result<Changelog<'_>> {
269    Parser::new().parse(text)
270}
271
272/// Returns an iterator over all release notes in the given `text`.
273///
274/// Unlike [`parse`] function, the returned iterator doesn't error on
275/// duplicate release notes or empty changelog.
276///
277/// This function uses the default version and prefix format. If you want to use
278/// another format, consider using the [`Parser`] type instead.
279///
280/// See the [crate-level documentation](crate) for changelog and version
281/// format supported by default.
282pub fn parse_iter(text: &str) -> ParseIter<'_, 'static> {
283    ParseIter::new(text, None, None)
284}
285
286/// A release note for a version.
287#[derive(Debug, Clone, PartialEq, Eq)]
288#[non_exhaustive]
289pub struct Release<'a> {
290    /// The version of this release.
291    ///
292    /// ```text
293    /// ## Version 0.1.0 -- 2020-01-01
294    ///            ^^^^^
295    /// ```
296    ///
297    /// This is the same value as the key of the [`Changelog`] type.
298    pub version: &'a str,
299    /// The title of this release.
300    ///
301    /// ```text
302    /// ## Version 0.1.0 -- 2020-01-01
303    ///    ^^^^^^^^^^^^^^^^^^^^^^^^^^^
304    /// ```
305    ///
306    /// Note:
307    /// - Leading and trailing [whitespaces](char::is_whitespace) have been removed.
308    /// - This retains links in the title. Use [`title_no_link`](Self::title_no_link)
309    ///   if you want to use the title with links removed.
310    pub title: &'a str,
311    /// The descriptions of this release.
312    ///
313    /// Note that leading and trailing newlines have been removed.
314    pub notes: &'a str,
315}
316
317impl<'a> Release<'a> {
318    /// Returns the title of this release with link removed.
319    #[must_use]
320    pub fn title_no_link(&self) -> Cow<'a, str> {
321        full_unlink(self.title)
322    }
323}
324
325/// A changelog parser.
326#[derive(Debug, Default)]
327pub struct Parser {
328    /// Version format. e.g., "0.1.0" in "# v0.1.0 (2020-01-01)".
329    ///
330    /// If `None`, `DEFAULT_VERSION_FORMAT` is used.
331    version_format: Option<Regex>,
332    /// Prefix format. e.g., "v" in "# v0.1.0 (2020-01-01)", "Version " in
333    /// "# Version 0.1.0 (2020-01-01)".
334    ///
335    /// If `None`, `DEFAULT_PREFIX_FORMAT` is used.
336    prefix_format: Option<Regex>,
337}
338
339impl Parser {
340    /// Creates a new changelog parser.
341    #[must_use]
342    pub fn new() -> Self {
343        Self::default()
344    }
345
346    /// Sets the version format.
347    ///
348    /// ```text
349    /// ## v0.1.0 -- 2020-01-01
350    ///     ^^^^^
351    /// ```
352    ///
353    /// *Tip*: To customize the text before the version number (e.g., "v" in "# v0.1.0",
354    /// "Version " in "# Version 0.1.0", etc.), use the [`prefix_format`] method
355    /// instead of this method.
356    ///
357    /// # Default
358    ///
359    /// The default version format is based on [Semantic Versioning][semver].
360    ///
361    /// This is parsed by using the following regular expression:
362    ///
363    /// ```text
364    /// ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z\.-]+)?(\+[0-9A-Za-z\.-]+)?$|^Unreleased$
365    /// ```
366    ///
367    /// **Note:** To get the 'Unreleased' section in the CLI, you need to explicitly specify 'Unreleased' as the version.
368    ///
369    /// # Errors
370    ///
371    /// Returns an error if any of the following:
372    ///
373    /// - The specified format is not a valid regular expression or supported by
374    ///   [regex] crate.
375    /// - The specified format is empty or contains only
376    ///   [whitespace](char::is_whitespace).
377    ///
378    /// [`prefix_format`]: Self::prefix_format
379    /// [regex]: https://docs.rs/regex
380    /// [semver]: https://semver.org
381    pub fn version_format(&mut self, format: &str) -> Result<&mut Self> {
382        if format.trim_start().is_empty() {
383            return Err(Error::format("empty or whitespace version format"));
384        }
385        self.version_format = Some(Regex::new(format).map_err(Error::new)?);
386        Ok(self)
387    }
388
389    /// Sets the prefix format.
390    ///
391    /// "Prefix" means the range from the first non-whitespace character after
392    /// heading to the character before the version (including whitespace
393    /// characters). For example:
394    ///
395    /// ```text
396    /// ## Version 0.1.0 -- 2020-01-01
397    ///    ^^^^^^^^
398    /// ```
399    ///
400    /// ```text
401    /// ## v0.1.0 -- 2020-01-01
402    ///    ^
403    /// ```
404    ///
405    /// # Default
406    ///
407    /// By default only "v", "Version ", "Release ", and "" (no prefix) are
408    /// allowed as prefixes.
409    ///
410    /// This is parsed by using the following regular expression:
411    ///
412    /// ```text
413    /// ^(v|Version |Release )?
414    /// ```
415    ///
416    /// # Errors
417    ///
418    /// Returns an error if any of the following:
419    ///
420    /// - The specified format is not a valid regular expression or supported by
421    ///   [regex] crate.
422    ///
423    /// [regex]: https://docs.rs/regex
424    pub fn prefix_format(&mut self, format: &str) -> Result<&mut Self> {
425        self.prefix_format = Some(Regex::new(format).map_err(Error::new)?);
426        Ok(self)
427    }
428
429    /// Parses release notes from the given `text`.
430    ///
431    /// See the [crate-level documentation](crate) for changelog and version
432    /// format supported by default.
433    ///
434    /// # Errors
435    ///
436    /// Returns an error if any of the following:
437    ///
438    /// - There are multiple release notes for one version.
439    /// - No release note was found. This usually means that the changelog isn't
440    ///   written in the supported format, or that the specified format is wrong
441    ///   if you specify your own format.
442    ///
443    /// If you want to handle these cases manually without making errors,
444    /// consider using [`parse_iter`].
445    ///
446    /// [`parse_iter`]: Self::parse_iter
447    pub fn parse<'a>(&self, text: &'a str) -> Result<Changelog<'a>> {
448        let mut map = IndexMap::new();
449        for release in self.parse_iter(text) {
450            if let Some(release) = map.insert(release.version, release) {
451                return Err(Error::parse(format!(
452                    "multiple release notes for '{}'",
453                    release.version
454                )));
455            }
456        }
457        if map.is_empty() {
458            return Err(Error::parse("no release note was found"));
459        }
460        Ok(map)
461    }
462
463    /// Returns an iterator over all release notes in the given `text`.
464    ///
465    /// Unlike [`parse`] method, the returned iterator doesn't error on
466    /// duplicate release notes or empty changelog.
467    ///
468    /// See the [crate-level documentation](crate) for changelog and version
469    /// format supported by default.
470    ///
471    /// [`parse`]: Self::parse
472    pub fn parse_iter<'a, 'r>(&'r self, text: &'a str) -> ParseIter<'a, 'r> {
473        ParseIter::new(text, self.version_format.as_ref(), self.prefix_format.as_ref())
474    }
475}
476
477/// An iterator over release notes.
478///
479/// This type is returned by [`parse_iter`] function or [`Parser::parse_iter`] method.
480#[allow(missing_debug_implementations)]
481#[must_use = "iterators are lazy and do nothing unless consumed"]
482pub struct ParseIter<'a, 'r> {
483    version_format: &'r Regex,
484    prefix_format: &'r Regex,
485    find_open: memmem::Finder<'static>,
486    find_close: memmem::Finder<'static>,
487    lines: Lines<'a>,
488    /// The heading level of release sections. 1-6
489    level: Option<u8>,
490}
491
492const OPEN: &[u8] = b"<!--";
493const CLOSE: &[u8] = b"-->";
494
495fn default_prefix_format() -> &'static Regex {
496    static DEFAULT_PREFIX_FORMAT: OnceLock<Regex> = OnceLock::new();
497    fn init() -> Regex {
498        Regex::new(r"^(v|Version |Release )?").unwrap()
499    }
500    DEFAULT_PREFIX_FORMAT.get_or_init(init)
501}
502fn default_version_format() -> &'static Regex {
503    static DEFAULT_VERSION_FORMAT: OnceLock<Regex> = OnceLock::new();
504    fn init() -> Regex {
505        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$")
506            .unwrap()
507    }
508    DEFAULT_VERSION_FORMAT.get_or_init(init)
509}
510
511impl<'a, 'r> ParseIter<'a, 'r> {
512    fn new(
513        text: &'a str,
514        version_format: Option<&'r Regex>,
515        prefix_format: Option<&'r Regex>,
516    ) -> Self {
517        Self {
518            version_format: version_format.unwrap_or_else(|| default_version_format()),
519            prefix_format: prefix_format.unwrap_or_else(|| default_prefix_format()),
520            find_open: memmem::Finder::new(OPEN),
521            find_close: memmem::Finder::new(CLOSE),
522            lines: Lines::new(text),
523            level: None,
524        }
525    }
526
527    fn end_release(
528        &self,
529        mut cur_release: Release<'a>,
530        release_note_start: usize,
531        line_start: usize,
532    ) -> Release<'a> {
533        assert!(!cur_release.version.is_empty());
534        if release_note_start < line_start {
535            // Remove trailing newlines.
536            cur_release.notes = self.lines.text[release_note_start..line_start - 1].trim_end();
537        }
538        cur_release
539    }
540
541    fn handle_comment(&self, on_comment: &mut bool, line: &'a str) {
542        let mut line = Some(line);
543        while let Some(l) = line {
544            match (self.find_open.find(l.as_bytes()), self.find_close.find(l.as_bytes())) {
545                (None, None) => {}
546                // <!-- ...
547                (Some(_), None) => *on_comment = true,
548                // ... -->
549                (None, Some(_)) => *on_comment = false,
550                (Some(open), Some(close)) => {
551                    if open < close {
552                        // <!-- ... -->
553                        *on_comment = false;
554                        line = l.get(close + CLOSE.len()..);
555                    } else {
556                        // --> ... <!--
557                        *on_comment = true;
558                        line = l.get(open + OPEN.len()..);
559                    }
560                    continue;
561                }
562            }
563            break;
564        }
565    }
566}
567
568impl<'a> Iterator for ParseIter<'a, '_> {
569    type Item = Release<'a>;
570
571    fn next(&mut self) -> Option<Self::Item> {
572        // If `true`, we are in a code block ("```").
573        let mut on_code_block = false;
574        // TODO: nested case?
575        // If `true`, we are in a comment (`<!--` and `-->`).
576        let mut on_comment = false;
577        let mut release_note_start = None;
578        let mut cur_release = Release { version: "", title: "", notes: "" };
579
580        while let Some((line, line_start, line_end)) = self.lines.peek() {
581            let heading =
582                if on_code_block || on_comment { None } else { heading(line, &mut self.lines) };
583            if heading.is_none() {
584                self.lines.next();
585                if trim_start(line).starts_with("```") {
586                    on_code_block = !on_code_block;
587                }
588
589                if !on_code_block {
590                    self.handle_comment(&mut on_comment, line);
591                }
592
593                // Non-heading lines are always considered part of the current
594                // section.
595
596                if line_end == self.lines.text.len() {
597                    break;
598                }
599                continue;
600            }
601            let heading = heading.unwrap();
602            if let Some(release_level) = self.level {
603                if heading.level > release_level {
604                    // Consider sections that have lower heading levels than
605                    // release sections are part of the current section.
606                    self.lines.next();
607                    if line_end == self.lines.text.len() {
608                        break;
609                    }
610                    continue;
611                }
612                if heading.level < release_level {
613                    // Ignore sections that have higher heading levels than
614                    // release sections.
615                    self.lines.next();
616                    if let Some(release_note_start) = release_note_start {
617                        return Some(self.end_release(cur_release, release_note_start, line_start));
618                    }
619                    if line_end == self.lines.text.len() {
620                        break;
621                    }
622                    continue;
623                }
624                if let Some(release_note_start) = release_note_start {
625                    return Some(self.end_release(cur_release, release_note_start, line_start));
626                }
627            }
628
629            debug_assert!(release_note_start.is_none());
630            let version = extract_version_from_title(heading.text, self.prefix_format).0;
631            if !self.version_format.is_match(version) {
632                // Ignore non-release sections that have the same heading
633                // levels as release sections.
634                self.lines.next();
635                if line_end == self.lines.text.len() {
636                    break;
637                }
638                continue;
639            }
640
641            cur_release.version = version;
642            cur_release.title = heading.text;
643            self.level.get_or_insert(heading.level);
644
645            self.lines.next();
646            if heading.style == HeadingStyle::Setext {
647                // Skip an underline after a Setext-style heading.
648                self.lines.next();
649            }
650            while let Some((next, ..)) = self.lines.peek() {
651                if next.trim_start().is_empty() {
652                    // Skip newlines after a heading.
653                    self.lines.next();
654                } else {
655                    break;
656                }
657            }
658            if let Some((_, line_start, _)) = self.lines.peek() {
659                release_note_start = Some(line_start);
660            } else {
661                break;
662            }
663        }
664
665        if !cur_release.version.is_empty() {
666            if let Some(release_note_start) = release_note_start {
667                if let Some(nodes) = self.lines.text.get(release_note_start..) {
668                    // Remove trailing newlines.
669                    cur_release.notes = nodes.trim_end();
670                }
671            }
672            return Some(cur_release);
673        }
674
675        None
676    }
677}
678
679struct Lines<'a> {
680    text: &'a str,
681    iter: memchr::Memchr<'a>,
682    line_start: usize,
683    peeked: Option<(&'a str, usize, usize)>,
684    peeked2: Option<(&'a str, usize, usize)>,
685}
686
687impl<'a> Lines<'a> {
688    fn new(text: &'a str) -> Self {
689        Self {
690            text,
691            iter: memchr::memchr_iter(b'\n', text.as_bytes()),
692            line_start: 0,
693            peeked: None,
694            peeked2: None,
695        }
696    }
697
698    fn peek(&mut self) -> Option<(&'a str, usize, usize)> {
699        self.peeked = self.next();
700        self.peeked
701    }
702
703    fn peek2(&mut self) -> Option<(&'a str, usize, usize)> {
704        let peeked = self.next();
705        let peeked2 = self.next();
706        self.peeked = peeked;
707        self.peeked2 = peeked2;
708        self.peeked2
709    }
710}
711
712impl<'a> Iterator for Lines<'a> {
713    type Item = (&'a str, usize, usize);
714
715    fn next(&mut self) -> Option<Self::Item> {
716        if let Some(triple) = self.peeked.take() {
717            return Some(triple);
718        }
719        if let Some(triple) = self.peeked2.take() {
720            return Some(triple);
721        }
722        let (line, line_end) = match self.iter.next() {
723            Some(line_end) => (&self.text[self.line_start..line_end], line_end),
724            None => (self.text.get(self.line_start..)?, self.text.len()),
725        };
726        let line_start = mem::replace(&mut self.line_start, line_end + 1);
727        Some((line, line_start, line_end))
728    }
729}
730
731struct Heading<'a> {
732    text: &'a str,
733    level: u8,
734    style: HeadingStyle,
735}
736
737#[derive(PartialEq)]
738enum HeadingStyle {
739    /// Atx-style headings use 1-6 `#` characters at the start of the line,
740    /// corresponding to header levels 1-6.
741    Atx,
742    /// Setext-style headings are “underlined” using equal signs `=` (for
743    /// first-level headings) and dashes `-` (for second-level headings).
744    Setext,
745}
746
747fn heading<'a>(line: &'a str, lines: &mut Lines<'a>) -> Option<Heading<'a>> {
748    let line = trim_start(line);
749    if line.as_bytes().first() == Some(&b'#') {
750        let mut level = 1;
751        while level <= 7 && line.as_bytes().get(level) == Some(&b'#') {
752            level += 1;
753        }
754        // 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
755        if level < 7 && line.as_bytes().get(level).map_or(true, |&b| matches!(b, b' ' | b'\t')) {
756            return Some(Heading {
757                text: line.get(level + 1..).map(str::trim).unwrap_or_default(),
758                #[allow(clippy::cast_possible_truncation)] // false positive: level is < 7: https://github.com/rust-lang/rust-clippy/issues/7486
759                level: level as u8,
760                style: HeadingStyle::Atx,
761            });
762        }
763    }
764    if let Some((next, ..)) = lines.peek2() {
765        let next = trim_start(next);
766        match next.as_bytes().first() {
767            Some(b'=') => {
768                if next[1..].trim_end().as_bytes().iter().all(|&b| b == b'=') {
769                    return Some(Heading {
770                        text: line.trim_end(),
771                        level: 1,
772                        style: HeadingStyle::Setext,
773                    });
774                }
775            }
776            Some(b'-') => {
777                if next[1..].trim_end().as_bytes().iter().all(|&b| b == b'-') {
778                    return Some(Heading {
779                        text: line.trim_end(),
780                        level: 2,
781                        style: HeadingStyle::Setext,
782                    });
783                }
784            }
785            _ => {}
786        }
787    }
788    None
789}
790
791fn trim_start(s: &str) -> &str {
792    let mut count = 0;
793    while s.as_bytes().get(count) == Some(&b' ') {
794        count += 1;
795        if count == 4 {
796            return s;
797        }
798    }
799    // Indents less than 4 are ignored.
800    &s[count..]
801}
802
803fn extract_version_from_title<'a>(mut text: &'a str, prefix_format: &Regex) -> (&'a str, &'a str) {
804    // Remove link from prefix
805    // [Version 1.0.0 2022-01-01]
806    // ^
807    text = text.strip_prefix('[').unwrap_or(text);
808    // Remove prefix
809    // Version 1.0.0 2022-01-01]
810    // ^^^^^^^^
811    if let Some(m) = prefix_format.find(text) {
812        text = &text[m.end()..];
813    }
814    // Remove whitespace after the version and the strings following it
815    // 1.0.0 2022-01-01]
816    //      ^^^^^^^^^^^^
817    text = text.split(char::is_whitespace).next().unwrap();
818    // Remove link from version
819    // Version [1.0.0 2022-01-01]
820    //         ^
821    // [Version 1.0.0] 2022-01-01
822    //               ^
823    // Version [1.0.0] 2022-01-01
824    //         ^     ^
825    unlink(text)
826}
827
828/// Remove a link from the given markdown text.
829///
830/// # Note
831///
832/// This is not a full "unlink" on markdown. See `full_unlink` for "full" version.
833fn unlink(mut s: &str) -> (&str, &str) {
834    // [1.0.0]
835    // ^
836    s = s.strip_prefix('[').unwrap_or(s);
837    if let Some(pos) = memchr::memchr(b']', s.as_bytes()) {
838        // 1.0.0]
839        //      ^
840        if pos + 1 == s.len() {
841            return (&s[..pos], "");
842        }
843        let remaining = &s[pos + 1..];
844        // 1.0.0](link)
845        //      ^^^^^^^
846        // 1.0.0][link]
847        //      ^^^^^^^
848        for (open, close) in [(b'(', b')'), (b'[', b']')] {
849            if remaining.as_bytes().first() == Some(&open) {
850                if let Some(r_pos) = memchr::memchr(close, &remaining.as_bytes()[1..]) {
851                    return (&s[..pos], &remaining[r_pos + 2..]);
852                }
853            }
854        }
855        return (&s[..pos], remaining);
856    }
857    (s, "")
858}
859
860/// Remove links from the given markdown text.
861fn full_unlink(s: &str) -> Cow<'_, str> {
862    let mut remaining = s;
863    if let Some(mut pos) = memchr::memchr(b'[', remaining.as_bytes()) {
864        let mut buf = String::with_capacity(remaining.len());
865        loop {
866            buf.push_str(&remaining[..pos]);
867            let (t, r) = unlink(&remaining[pos..]);
868            buf.push_str(t);
869            remaining = r;
870            match memchr::memchr(b'[', remaining.as_bytes()) {
871                Some(p) => pos = p,
872                None => break,
873            }
874        }
875        buf.push_str(remaining);
876        buf.into()
877    } else {
878        remaining.into()
879    }
880}