markdown_utils/
heading.rs1use crate::transformers::{transform_line_by_line_skipping_codeblocks};
2
3pub 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