quillmark_content/
normalize.rs1#[inline]
12pub(crate) fn is_bidi_char(c: char) -> bool {
13 matches!(
14 c,
15 '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}' | '\u{202B}' | '\u{202C}' | '\u{202D}' | '\u{202E}' | '\u{2066}' | '\u{2067}' | '\u{2068}' | '\u{2069}' )
28}
29
30pub fn strip_bidi_formatting(s: &str) -> String {
35 if !s.chars().any(is_bidi_char) {
36 return s.to_string();
37 }
38
39 s.chars().filter(|c| !is_bidi_char(*c)).collect()
40}
41
42pub fn fix_html_comment_fences(s: &str) -> String {
49 if !s.contains("-->") {
50 return s.to_string();
51 }
52
53 let mut result = String::with_capacity(s.len() + 16);
54 let mut current_pos = 0;
55
56 while let Some(open_idx) = s[current_pos..].find("<!--") {
57 let abs_open = current_pos + open_idx;
58
59 if let Some(close_idx) = s[abs_open..].find("-->") {
60 let abs_close = abs_open + close_idx;
61 let mut after_fence = abs_close + 3;
62
63 let opener_has_extra_hyphen = s
66 .get(abs_open + 4..)
67 .is_some_and(|rest| rest.starts_with('-'));
68 if opener_has_extra_hyphen
69 && s.get(after_fence..)
70 .is_some_and(|rest| rest.starts_with('-'))
71 {
72 after_fence += 1;
73 }
74
75 result.push_str(&s[current_pos..after_fence]);
76
77 let after_content = &s[after_fence..];
78
79 let needs_newline = if after_content.is_empty()
80 || after_content.starts_with('\n')
81 || after_content.starts_with("\r\n")
82 {
83 false
84 } else {
85 let next_newline = after_content.find('\n');
86 let until_newline = match next_newline {
87 Some(pos) => &after_content[..pos],
88 None => after_content,
89 };
90 !until_newline.trim().is_empty()
91 };
92
93 if needs_newline {
94 result.push('\n');
95 }
96
97 current_pos = after_fence;
98 } else {
99 result.push_str(&s[current_pos..]);
101 current_pos = s.len();
102 break;
103 }
104 }
105
106 if current_pos < s.len() {
107 result.push_str(&s[current_pos..]);
108 }
109
110 result
111}
112
113pub fn normalize_markdown(markdown: &str) -> String {
116 let cleaned = normalize_line_endings(markdown);
117 let cleaned = strip_bidi_formatting(&cleaned);
118 fix_html_comment_fences(&cleaned)
119}
120
121fn normalize_line_endings(s: &str) -> String {
127 if !s.contains('\r') {
128 return s.to_string();
129 }
130 let mut out = String::with_capacity(s.len());
131 let mut chars = s.chars().peekable();
132 while let Some(c) = chars.next() {
133 if c == '\r' {
134 if chars.peek() == Some(&'\n') {
135 chars.next();
136 }
137 out.push('\n');
138 } else {
139 out.push(c);
140 }
141 }
142 out
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 #[test]
150 fn test_strip_bidi_formatting_cases() {
151 let cases: &[(&str, &str)] = &[
152 ("hello world", "hello world"),
153 ("", ""),
154 ("**bold** text", "**bold** text"),
155 ("he\u{202D}llo", "hello"),
156 ("**asdf** or \u{202D}**(1234**", "**asdf** or **(1234**"),
157 ("a\u{200E}b\u{200F}c", "abc"),
158 ("\u{202A}text\u{202B}more\u{202C}", "textmore"),
159 ("\u{2066}a\u{2067}b\u{2068}c\u{2069}", "abc"),
160 (
161 "\u{061C}\u{200E}\u{200F}\u{202A}\u{202B}\u{202C}\u{202D}\u{202E}\u{2066}\u{2067}\u{2068}\u{2069}",
162 "",
163 ),
164 ("hello\u{061C}world", "helloworld"),
165 ("\u{061C}**bold**", "**bold**"),
166 ("你好世界", "你好世界"),
167 ("مرحبا", "مرحبا"),
168 ("🎉", "🎉"),
169 ];
170
171 for (input, expected) in cases {
172 assert_eq!(strip_bidi_formatting(input), *expected, "input: {:?}", input);
173 }
174 }
175
176 #[test]
177 fn test_normalize_markdown_basic() {
178 assert_eq!(normalize_markdown("hello"), "hello");
179 assert_eq!(
180 normalize_markdown("**bold** \u{202D}**more**"),
181 "**bold** **more**"
182 );
183 }
184
185 #[test]
186 fn test_normalize_markdown_html_comment() {
187 assert_eq!(
188 normalize_markdown("<!-- comment -->Some text"),
189 "<!-- comment -->\nSome text"
190 );
191 }
192
193 #[test]
194 fn test_fix_html_comment_fences_cases() {
195 let cases: &[(&str, &str)] = &[
196 ("hello world", "hello world"),
197 ("**bold** text", "**bold** text"),
198 ("", ""),
199 (
200 "<!-- comment -->Same line text",
201 "<!-- comment -->\nSame line text",
202 ),
203 (
204 "<!-- comment -->\nNext line text",
205 "<!-- comment -->\nNext line text",
206 ),
207 (
208 "<!-- comment --> \nSome text",
209 "<!-- comment --> \nSome text",
210 ),
211 (
212 "<!--\nmultiline\ncomment\n-->Trailing text",
213 "<!--\nmultiline\ncomment\n-->\nTrailing text",
214 ),
215 (
216 "<!--\nmultiline\n-->\n\nParagraph text",
217 "<!--\nmultiline\n-->\n\nParagraph text",
218 ),
219 (
220 "<!-- first -->Text\n\n<!-- second -->More text",
221 "<!-- first -->\nText\n\n<!-- second -->\nMore text",
222 ),
223 (
224 "Some text before <!-- comment -->",
225 "Some text before <!-- comment -->",
226 ),
227 ("-->some text", "-->some text"),
228 ("<!-- <!-- -->Trailing", "<!-- <!-- -->\nTrailing"),
230 (
231 "<!-- valid -->FixMe\ntext --> Ignore\n<!-- valid2 -->FixMe2",
232 "<!-- valid -->\nFixMe\ntext --> Ignore\n<!-- valid2 -->\nFixMe2",
233 ),
234 (
235 "<!-- comment -->\r\nSome text",
236 "<!-- comment -->\r\nSome text",
237 ),
238 (
239 "<!--- comment --->Trailing text",
240 "<!--- comment --->\nTrailing text",
241 ),
242 ];
243
244 for (input, expected) in cases {
245 assert_eq!(
246 fix_html_comment_fences(input),
247 *expected,
248 "input: {:?}",
249 input
250 );
251 }
252 }
253}