markdown_utils/
heading.rs

1use crate::transformers::{transform_line_by_line_skipping_codeblocks};
2
3/**
4 * Modify the headings offset of Markdown content.
5 * 
6 * Only works for number sign (``#``) headings syntax.
7 *
8 * This function is secure, so if you try to decrease the offset
9 * of a heading to a negative value that exceeds the top level
10 * heading, it will be set to the minimum valid possible to not
11 * modify the hierarchy of section nodes.
12 *
13 * Args:
14 *   text (str): Text to modify.
15 *   offset (int): Relative offset for headings, can be a positive
16 *     or a negative number.
17 *
18 * Returns:
19 *   str: New modified content.
20 **/
21 pub fn modify_headings_offset(text: &str, offset: i8) -> String {
22    if offset >= 0 {
23        transform_line_by_line_skipping_codeblocks(
24            text,
25            &transform_positive_headings_offset_function_factory(
26                offset.try_into().unwrap(),
27            ),
28        )
29    } else {
30        let usize_abs_offset = offset.abs().try_into().unwrap();
31        transform_line_by_line_skipping_codeblocks(
32            text,
33            &transform_negative_headings_offset_function_factory(
34                usize_abs_offset,
35                parse_max_valid_negative_heading_offset(
36                    text,
37                    usize_abs_offset,
38                ),
39            ),
40        )
41    }
42}
43
44fn transform_positive_headings_offset_function_factory(
45    offset: usize,
46) -> impl Fn(String) -> String {
47    move |line| {
48        match line.starts_with("#") {
49            true => {
50                let mut new_line = "#".repeat(offset);
51                new_line.push_str(&line);
52                new_line
53            },
54            false => line.to_string(),
55        }
56    }
57}
58
59fn parse_heading_line_offset(line: &str) -> usize {
60    let mut offset = 0;
61    for c in line.chars() {
62        if c != '#' {
63            break
64        }
65        offset += 1;
66    }
67    offset
68}
69
70fn parse_max_valid_negative_heading_offset(
71    text: &str,
72    offset: usize,
73) -> usize {
74    let mut max_valid_offset = 5;
75    for line in text.lines() {
76        if !line.starts_with("#") {
77            continue;
78        }
79        let current_line_offset = parse_heading_line_offset(line);
80
81        if current_line_offset > offset {
82            let relative_offset = current_line_offset - offset;
83            if relative_offset > max_valid_offset {
84                max_valid_offset = relative_offset;
85            }
86        } else {
87            if current_line_offset > 0 && max_valid_offset > current_line_offset - 1 {
88                max_valid_offset = current_line_offset - 1;
89            }
90        }
91        if max_valid_offset == 0 {
92            break;
93        }
94    }
95    max_valid_offset
96}
97
98fn transform_negative_headings_offset_function_factory(
99    offset: usize,
100    max_valid_offset: usize,
101) -> impl Fn(String) -> String {
102    move |line| {
103        if !line.starts_with("#") || max_valid_offset == 0 {
104            return line;
105        }
106
107        let current_line_offset = parse_heading_line_offset(&line);
108        let mut new_line: String;
109
110        if offset > max_valid_offset {
111            new_line = "#".repeat(current_line_offset - max_valid_offset)
112        } else {
113            new_line = "#".repeat(current_line_offset - offset);
114        }
115        new_line.push_str(line.trim_start_matches("#"));
116        return new_line;
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use rstest::rstest;
124
125    #[rstest]
126    #[case(&"# A\n## B\n\n###C", 1, "## A\n### B\n\n####C")]
127    #[case(&"# A\n## B\n\n###C", 0, "# A\n## B\n\n###C")]
128    #[case(&"## A\n### B\n\n####C", -1, "# A\n## B\n\n###C")]
129    #[case(&"### A\n#### B\n\n#####C", -2, "# A\n## B\n\n###C")]
130    #[case(&"### A\n#### B\n\n#####C", -5, "# A\n## B\n\n###C")]
131    #[case(&"### A\n# B\n\n##C", -2, "### A\n# B\n\n##C")]
132    #[case(&"#### A\n## B\n\n###C", -2, "### A\n# B\n\n##C")]
133    #[case(
134        &"# A\n```\n## B\n```\n###C",
135        1,
136        "## A\n```\n## B\n```\n####C",
137    )]
138    #[case(
139        &"# A\n~~~\n## B\n~~~\n###C",
140        1,
141        "## A\n~~~\n## B\n~~~\n####C",
142    )]
143    #[case(
144        &"# A\n\n    ## B\n\n###C",
145        1,
146        "## A\n\n    ## B\n\n####C",
147    )]
148    fn modify_headings_offset_test(
149        #[case] text: &str,
150        #[case] offset: i8,
151        #[case] expected: String,
152    ) {
153        assert_eq!(
154            modify_headings_offset(
155                text,
156                offset,
157            ),
158            expected,
159        );
160    }
161}
162