1use crate::pattern::*;
6use crate::preprocess::PreprocessOptions;
7use crate::scan::*;
8
9fn trailing_backtick_run(content: &str) -> Option<(usize, usize)> {
12 let bytes = content.as_bytes();
13 let mut end = bytes.len();
14 while end > 0 && is_ws(content[..end].chars().next_back().unwrap()) {
16 end -= 1;
17 }
18 let mut start = end;
19 while start > 0 && bytes[start - 1] == b'`' {
20 start -= 1;
21 }
22 if end > start {
23 Some((start, end - start))
24 } else {
25 None
26 }
27}
28
29pub fn fix_code(content: &str) -> String {
34 let is_inside = is_inside_unclosed_code_block(content);
35
36 let (cleaned, was_cleaned) = remove_trailing_incomplete_backticks(content);
37
38 if is_inside && was_cleaned {
39 fix_code_block(&cleaned)
40 } else if !was_cleaned {
41 let after_block = fix_code_block(&cleaned);
42 fix_inline_code(&after_block)
43 } else {
44 cleaned
45 }
46}
47
48fn remove_trailing_incomplete_backticks(content: &str) -> (String, bool) {
49 let Some((run_start, run_len)) = trailing_backtick_run(content) else {
50 return (content.to_string(), false);
51 };
52 let seq = &content[run_start..run_start + run_len];
53 let seq_len = seq.chars().count();
54 let before = &content[..run_start];
55 let after = &content[run_start + run_len..];
56
57 if seq_len == 1 {
58 let last_para = last_paragraph(before, false);
60 let without_code = strip_closed_code_blocks(last_para);
61 let count = count_of(&without_code, "`");
62 let in_code = is_within_code_block(before, before.len());
63 if count % 2 == 1 && !in_code {
64 return (content.to_string(), false);
66 }
67 let trimmed = before.trim_end_matches(is_ws);
68 let mut out = trimmed.to_string();
69 out.push_str(after);
70 (out, true)
71 } else if seq_len == 2 {
72 let trimmed = before.trim_end_matches(is_ws);
73 let mut out = trimmed.to_string();
74 out.push_str(after);
75 (out, true)
76 } else if seq_len == 3 {
77 let in_code = is_within_code_block(before, before.len());
78 if in_code {
79 (content.to_string(), false)
80 } else {
81 let trimmed = before.trim_end_matches(is_ws);
82 let mut out = trimmed.to_string();
83 out.push_str(after);
84 (out, true)
85 }
86 } else {
87 let trimmed = before.trim_end_matches(is_ws);
88 let mut out = trimmed.to_string();
89 out.push_str(after);
90 (out, true)
91 }
92}
93
94pub fn strip_closed_code_blocks(text: &str) -> String {
96 let ranges = find_closed_code_block_ranges(text);
97 let mut out = String::with_capacity(text.len());
98 let mut cursor = 0;
99 for &(start, end) in &ranges {
100 out.push_str(&text[cursor..start]);
101 cursor = end;
102 }
103 out.push_str(&text[cursor..]);
104 out
105}
106
107fn fix_code_block(content: &str) -> String {
108 if is_inside_unclosed_code_block(content) {
109 if let Some(rel) = memchr::memmem::rfind(content.as_bytes(), b"```") {
110 let after_fence = &content[rel + 3..];
111 let has_newline = after_fence.contains('\n');
112 let first_line = after_fence.split('\n').next().unwrap_or("");
113 let has_language = !first_line.trim().is_empty();
114 if has_language || has_newline {
115 if content.ends_with('\n') {
116 return format!("{}```", content);
117 }
118 return format!("{}\n```", content);
119 }
120 }
121 }
122 content.to_string()
123}
124
125fn fix_inline_code(content: &str) -> String {
126 let (start_line, offset) = last_paragraph_range(content, false);
127 let last_para = &content[offset..];
128 let without_code = strip_closed_code_blocks(last_para);
129 let count = count_of(&without_code, "`");
130
131 if count % 2 == 1 {
132 let bytes = last_para.as_bytes();
134 let mut last_pos: isize = -1;
135 let mut i = 0;
136 while i < bytes.len() {
137 if bytes[i] != b'`' {
138 i += 1;
139 continue;
140 }
141 if crate::scan::is_triple_backtick_at(last_para.as_bytes(), i) {
142 if let Some(close_rel) = memchr::memmem::find(&last_para.as_bytes()[i + 3..], b"```") {
143 i += 3 + close_rel + 3 - 1;
144 continue;
145 }
146 }
147 if !is_backtick_part_of_triple(last_para, i) {
148 last_pos = i as isize;
149 }
150 i += 1;
151 }
152 if last_pos >= 0 {
153 let actual = offset + last_pos as usize;
154 let after_last = content[actual + 1..].trim();
155 if !after_last.is_empty() {
156 return format!("{}`", content);
157 }
158 }
159 }
160 let _ = start_line;
161 content.to_string()
162}
163
164fn is_unclosed_html_fragment(fragment: &str) -> bool {
169 if !fragment.starts_with('<') || fragment.contains('>') {
170 return false;
171 }
172 if fragment == "<" {
173 return true;
174 }
175 if fragment.chars().count() <= 1 {
176 return false;
177 }
178 let rest = &fragment[1..];
179 if rest.starts_with("<!--") || rest.starts_with("<?") {
181 return true;
182 }
183 if rest.starts_with('!') {
185 let after = rest[1..].trim_start_matches(is_ws);
186 return after
187 .chars()
188 .next()
189 .is_some_and(|c| c.is_ascii_alphabetic());
190 }
191 let closing = rest.starts_with('/');
192 let after = rest.trim_start_matches(|c| c == '/' || is_ws(c));
193 let Some(first) = after.chars().next() else {
194 return false;
195 };
196 if !first.is_ascii_alphabetic() {
197 return false;
198 }
199 let tail = &after[first.len_utf8()..];
201 let word_end = tail
202 .char_indices()
203 .find(|(_, c)| !(c.is_ascii_alphanumeric() || *c == '-' || *c == '_'))
204 .map(|(i, _)| i)
205 .unwrap_or(tail.len());
206 let after_word = &tail[word_end..];
207 if closing {
208 after_word.chars().all(is_ws)
210 } else {
211 let trimmed = after_word.trim_start_matches(is_ws);
213 if trimmed.is_empty() {
214 return true;
215 }
216 let c = trimmed.chars().next().unwrap();
217 c != '<' && c != '>'
218 }
219}
220
221pub fn fix_html(content: &str) -> String {
222 if content.is_empty() || is_inside_unclosed_code_block(content) {
223 return content.to_string();
224 }
225 let ws_offset = trailing_ws_offset(content);
226 if ws_offset == 0 {
227 return content.to_string();
228 }
229 let visible = &content[..ws_offset];
230 let Some(fragment_start) = memchr::memrchr(b'<', visible.as_bytes()) else {
231 return content.to_string();
232 };
233 if fragment_start > 0 && content.as_bytes()[fragment_start - 1] == b'\\' {
234 return content.to_string();
235 }
236 let fragment = &visible[fragment_start..];
237 if !is_unclosed_html_fragment(fragment) {
238 return content.to_string();
239 }
240 let code_ranges = find_closed_code_block_ranges(content);
241 if is_position_in_ranges(fragment_start, &code_ranges) {
242 return content.to_string();
243 }
244 let inline_ranges = find_inline_code_ranges(content, &code_ranges);
245 if is_position_in_ranges(fragment_start, &inline_ranges) {
246 return content.to_string();
247 }
248 let before = content[..fragment_start].trim_end_matches([' ', '\t']);
249 let trailing = &content[ws_offset..];
250 format!("{}{}", before, trailing)
251}
252
253pub fn has_footnote_reference(content: &str) -> bool {
259 !find_footnote_refs(content).is_empty()
260}
261
262pub fn has_footnote_definition(content: &str) -> bool {
263 !fn_def_ranges(content).is_empty()
264}
265
266fn fn_def_ranges(content: &str) -> Vec<(usize, usize)> {
267 let mut ranges = Vec::new();
268 let bytes = content.as_bytes();
269 let mut i = 0;
270 while i < bytes.len() {
271 if bytes[i] != b'[' {
272 i += 1;
273 continue;
274 }
275 if bytes.get(i + 1) != Some(&b'^') {
276 i += 1;
277 continue;
278 }
279 let mut j = i + 2;
281 let mut label_len = 0;
282 while j < bytes.len() && label_len < 200 {
283 let c = bytes[j];
284 if c == b']' {
285 break;
286 }
287 if is_ws(c as char) {
288 label_len = 201;
289 break;
290 }
291 j += 1;
292 label_len += 1;
293 }
294 if (1..=200).contains(&label_len)
295 && bytes.get(j) == Some(&b']')
296 && bytes.get(j + 1) == Some(&b':')
297 {
298 ranges.push((i, j + 2));
299 i = j + 2;
300 } else {
301 i += 1;
302 }
303 }
304 ranges
305}
306
307pub fn find_footnote_refs(content: &str) -> Vec<(usize, usize, String)> {
309 let mut refs = Vec::new();
310 let bytes = content.as_bytes();
311 let mut i = 0;
312 while i < bytes.len() {
313 if bytes[i] != b'[' || bytes.get(i + 1) != Some(&b'^') {
314 i += 1;
315 continue;
316 }
317 let mut j = i + 2;
318 let mut label_len = 0;
319 let mut label = String::new();
320 while j < bytes.len() && label_len < 200 {
321 let c = bytes[j];
322 if c == b']' {
323 break;
324 }
325 if is_ws(c as char) {
326 label_len = 201;
327 break;
328 }
329 label.push(c as char);
330 j += 1;
331 label_len += 1;
332 }
333 if (1..=200).contains(&label_len) && bytes.get(j) == Some(&b']') {
334 if bytes.get(j + 1) != Some(&b':') {
336 refs.push((i, j + 1, label));
337 }
338 i = j + 1;
339 } else {
340 i += 1;
341 }
342 }
343 refs
344}
345
346fn get_defined_footnote_labels(content: &str) -> std::collections::HashSet<String> {
347 let without_code = strip_closed_code_blocks(content);
348 fn_def_ranges(&without_code)
349 .iter()
350 .map(|&(start, _)| {
351 let inner = &without_code[start + 2..];
352 let end = memchr::memchr(b']', inner.as_bytes()).unwrap_or(0);
353 inner[..end].to_string()
354 })
355 .collect()
356}
357
358fn remove_incomplete_ref_in_last_paragraph(content: &str) -> String {
359 let (start_line, offset) = last_paragraph_range(content, false);
360 let last_para = &content[offset..];
361 if !last_para.contains("[^") {
363 return content.to_string();
364 }
365 let bytes = last_para.as_bytes();
367 let mut incomplete_pos = -1isize;
368 let mut i = 0;
369 while i < bytes.len() {
370 if bytes[i] == b'[' && bytes.get(i + 1) == Some(&b'^') {
371 let rest = &last_para[i + 2..];
372 if !rest.contains(']') {
373 incomplete_pos = i as isize;
374 }
375 }
376 i += 1;
377 }
378 if incomplete_pos < 0 {
379 return content.to_string();
380 }
381 let incomplete_pos = incomplete_pos as usize;
382 let abs = absolute_position(offset, start_line, incomplete_pos, content);
383 let code_ranges = find_closed_code_block_ranges(content);
384 let inline_ranges = find_inline_code_ranges(content, &code_ranges);
385 if is_position_in_ranges(abs, &code_ranges) || is_position_in_ranges(abs, &inline_ranges) {
386 return content.to_string();
387 }
388 let line_end = memchr::memchr(b'\n', &last_para.as_bytes()[incomplete_pos..])
389 .map(|p| incomplete_pos + p)
390 .unwrap_or(last_para.len());
391 let mut ref_start = incomplete_pos;
392 if ref_start > 0 && last_para.as_bytes()[ref_start - 1] == b' ' {
393 ref_start -= 1;
394 }
395 let abs_start = absolute_position(offset, start_line, ref_start, content);
396 let abs_end = absolute_position(offset, start_line, line_end, content);
397 let mut out = content[..abs_start].to_string();
398 out.push_str(&content[abs_end..]);
399 out
400}
401
402fn absolute_position(
403 paragraph_offset: usize,
404 _start_line: usize,
405 relative: usize,
406 _content: &str,
407) -> usize {
408 paragraph_offset + relative
409}
410
411fn collect_complete_references(
412 content: &str,
413 code_ranges: &[(usize, usize)],
414 inline_ranges: &[(usize, usize)],
415 def_ranges: &[(usize, usize)],
416) -> Vec<(usize, usize, String)> {
417 find_footnote_refs(content)
418 .into_iter()
419 .filter(|&(start, _, _)| {
420 !is_position_in_ranges(start, code_ranges)
421 && !is_position_in_ranges(start, inline_ranges)
422 && !is_position_in_ranges(start, def_ranges)
423 })
424 .collect()
425}
426
427pub fn fix_footnote(content: &str) -> String {
428 if is_inside_unclosed_code_block(content) {
429 return content.to_string();
430 }
431 let defined = get_defined_footnote_labels(content);
432 let mut result = remove_incomplete_ref_in_last_paragraph(content);
433
434 let code_ranges = find_closed_code_block_ranges(&result);
435 let inline_ranges = find_inline_code_ranges(&result, &code_ranges);
436 let def_ranges = fn_def_ranges(&result);
437 let references =
438 collect_complete_references(&result, &code_ranges, &inline_ranges, &def_ranges);
439 if references.is_empty() {
440 return result;
441 }
442 for (start, end, label) in references.iter().rev() {
443 if defined.contains(label) {
444 continue;
445 }
446 let mut ref_start = *start;
447 if ref_start > 0 && result.as_bytes()[ref_start - 1] == b' ' {
448 ref_start -= 1;
449 }
450 let mut out = result[..ref_start].to_string();
451 out.push_str(&result[*end..]);
452 result = out;
453 }
454 result
455}
456
457pub fn fix_task_list(content: &str) -> String {
462 if is_inside_unclosed_code_block(content) {
463 return content.to_string();
464 }
465 let code_ranges = find_closed_code_block_ranges(content);
466 let lines: Vec<&str> = content.split('\n').collect();
467 let Some(&last_line) = lines.last() else {
468 return content.to_string();
469 };
470 let last_line_start: usize = lines[..lines.len() - 1].iter().map(|l| l.len() + 1).sum();
472 let last_line_end = last_line_start + last_line.len();
473 if is_range_overlapping_ranges(last_line_start, last_line_end, &code_ranges) {
474 return content.to_string();
475 }
476
477 let drop_last = is_quote_incomplete_task_list(last_line)
478 || (is_quote_standalone_dash(last_line) && !is_quote_task_list(last_line))
479 || is_incomplete_task_list(last_line)
480 || (is_standalone_dash(last_line) && !is_task_list(last_line))
481 || (is_dash_with_space(last_line) && !is_task_list(last_line));
482
483 if drop_last {
484 lines[..lines.len() - 1].join("\n")
485 } else {
486 content.to_string()
487 }
488}
489
490pub fn fix_link(content: &str) -> String {
495 if is_inside_unclosed_code_block(content) {
496 return content.to_string();
497 }
498 let lines: Vec<&str> = content.split('\n').collect();
499 let (start_line, offset) = last_paragraph_range(content, false);
500 let last_para = &content[offset..];
501 let without_code = strip_closed_code_blocks(last_para);
502
503 let last_non_empty = last_non_empty_line_index(&lines);
505 if last_non_empty >= 0 {
506 let last_line = lines[last_non_empty as usize];
507 let t = js_trim(last_line);
508 let bracket = if t.ends_with('[') {
509 Some("[")
510 } else if t.ends_with("![") {
511 Some("![")
512 } else {
513 None
514 };
515 if let Some(b) = bracket {
516 let pos = memchr::memmem::rfind(last_line.as_bytes(), b.as_bytes()).unwrap();
517 let before = last_line[..pos].trim_end_matches(is_ws).to_string();
518 let mut new_lines: Vec<&str> = lines.clone();
519 new_lines[last_non_empty as usize] = &before;
520 if last_non_empty as usize + 1 < new_lines.len()
522 && new_lines[last_non_empty as usize + 1].trim().is_empty()
523 {
524 new_lines.remove(last_non_empty as usize + 1);
525 }
526 return new_lines.join("\n");
527 }
528 }
529
530 if has_incomplete_bracket(&without_code) {
532 return format!("{}]()", content);
533 }
534 if ends_with_incomplete_link_text(&without_code) {
536 return format!("{}()", content);
537 }
538 if ends_with_incomplete_url(&without_code) {
540 return format!("{})", content);
541 }
542 let _ = start_line;
543 content.to_string()
544}
545
546fn has_incomplete_bracket(text: &str) -> bool {
548 memchr::memrchr(b'[', text.as_bytes())
549 .is_some_and(|p| !text[p + 1..].contains(']'))
550}
551
552fn ends_with_incomplete_link_text(text: &str) -> bool {
554 let t = js_trim(text);
555 if !t.ends_with(']') {
556 return false;
557 }
558 let Some(p) = memchr::memrchr(b'[', t.as_bytes()) else {
559 return false;
560 };
561 !t[p + 1..t.len() - 1].contains(']')
562}
563
564fn ends_with_incomplete_url(text: &str) -> bool {
566 let bytes = text.as_bytes();
567 let mut i = 0;
568 let mut found = false;
569 while i < bytes.len() {
570 if bytes[i] == b'[' || (bytes[i] == b'!' && bytes.get(i + 1) == Some(&b'[')) {
571 let label_start = if bytes[i] == b'!' { i + 1 } else { i };
572 if let Some(rel) = memchr::memchr(b']', &text.as_bytes()[label_start..]) {
573 let after = label_start + rel + 1;
574 if text[after..].starts_with('(') {
575 let rest = &text[after + 1..];
576 if !rest.contains(')') {
577 found = true;
578 }
579 }
580 }
581 }
582 i += 1;
583 }
584 found
585}
586
587fn column_count(row: &str) -> usize {
593 row.matches('|').count().saturating_sub(1)
594}
595
596fn generate_separator(columns: usize) -> String {
597 let mut s = String::from("|");
598 for _ in 0..columns {
599 s.push_str(" --- |");
600 }
601 s
602}
603
604pub fn fix_table(content: &str) -> String {
605 if is_inside_unclosed_code_block(content) {
606 return content.to_string();
607 }
608 let code_ranges = find_closed_code_block_ranges(content);
609
610 let last_para = last_paragraph(content, true);
611 let paragraph_lines: Vec<&str> = last_para
612 .split('\n')
613 .filter(|l| !l.trim().is_empty())
614 .collect();
615 if paragraph_lines.is_empty() {
616 return content.to_string();
617 }
618
619 let mut header_row_index = -1isize;
621 let mut header_row = "";
622 for (i, line) in paragraph_lines.iter().enumerate() {
623 let t = js_trim(line);
624 if is_table_row_line(t) || (t.starts_with('|') && t.chars().count() > 1) {
625 header_row_index = i as isize;
626 header_row = t;
627 break;
628 }
629 }
630 if header_row_index < 0 {
631 return content.to_string();
632 }
633 let header_row_index = header_row_index as usize;
634
635 let header_row_pos = content.rfind(header_row).unwrap_or(0);
637 let header_row_end = header_row_pos + header_row.len();
638 if is_range_overlapping_ranges(header_row_pos, header_row_end, &code_ranges) {
639 return content.to_string();
640 }
641
642 let is_header_complete = header_row.ends_with('|');
643 let completed_header = if is_header_complete {
644 header_row.to_string()
645 } else {
646 format!("{} |", js_trim(header_row))
647 };
648 let header_columns = column_count(&completed_header);
649
650 let before = &content[..header_row_pos];
651 let after = &content[header_row_pos + header_row.len()..];
652
653 if header_row_index == paragraph_lines.len() - 1 {
655 let new_content = if is_header_complete {
656 content.to_string()
657 } else {
658 format!("{}{}{}", before, completed_header, after)
659 };
660 let sep = generate_separator(header_columns);
661 if new_content.ends_with('\n') {
662 return format!("{}{}", new_content, sep);
663 }
664 return format!("{}\n{}", new_content, sep);
665 }
666
667 let next_line = js_trim(paragraph_lines[header_row_index + 1]);
669 if is_table_separator_line(next_line) && column_count(next_line) == header_columns {
670 if !is_header_complete {
671 return format!("{}{}{}", before, completed_header, after);
672 }
673 return content.to_string();
674 }
675
676 let after_lines: Vec<&str> = after.split('\n').collect();
678 let next_line_in_content = after_lines.get(1).copied().unwrap_or("");
679 let new_header = if is_header_complete {
680 header_row
681 } else {
682 &completed_header
683 };
684 let sep = generate_separator(header_columns);
685
686 if next_line_in_content.starts_with('|') && next_line_in_content.contains('-') {
687 let remaining: Vec<&str> = after_lines[2..].to_vec();
688 let mut out = format!("{}{}\n{}", before, new_header, sep);
689 if !remaining.is_empty() {
690 out.push('\n');
691 out.push_str(&remaining.join("\n"));
692 }
693 return out;
694 }
695
696 let remaining: Vec<&str> = after_lines[1..].to_vec();
697 let mut out = format!("{}{}\n{}", before, new_header, sep);
698 if !remaining.is_empty() {
699 out.push('\n');
700 out.push_str(&remaining.join("\n"));
701 }
702 out
703}
704
705pub fn fix_math(content: &str) -> String {
710 if is_inside_unclosed_code_block(content) {
711 return content.to_string();
712 }
713 let lines: Vec<&str> = content.split('\n').collect();
714 let mut in_code = false;
715 let mut delimiters: Vec<usize> = Vec::new();
716 for (i, line) in lines.iter().enumerate() {
717 if js_trim(line).starts_with("```") {
718 in_code = !in_code;
719 continue;
720 }
721 if in_code {
722 continue;
723 }
724 if js_trim(line) == "$$" {
725 delimiters.push(i);
726 }
727 }
728 if delimiters.len() % 2 == 1 {
729 let last = *delimiters.last().unwrap();
730 let has_content = lines[last + 1..].iter().any(|l| {
731 let t = js_trim(l);
732 !t.is_empty() && t != "$$"
733 });
734 if has_content {
735 if content.ends_with('\n') {
736 return format!("{}$$", content);
737 }
738 return format!("{}\n$$", content);
739 }
740 return lines[..last].join("\n");
741 }
742 content.to_string()
743}
744
745fn find_last_dollar_pair(text: &str) -> isize {
747 let bytes = text.as_bytes();
748 let mut in_code = false;
749 let mut in_inline = false;
750 let mut last = -1isize;
751 let mut i = 0;
752 while i + 1 < bytes.len() {
753 if crate::scan::is_triple_backtick_at(text.as_bytes(), i) {
754 in_code = !in_code;
755 in_inline = false;
756 i += 3;
757 continue;
758 }
759 if !in_code && bytes[i] == b'`' {
760 if !is_backtick_part_of_triple(text, i) {
761 in_inline = !in_inline;
762 }
763 i += 1;
764 continue;
765 }
766 if !in_code && !in_inline && bytes[i] == b'$' && bytes[i + 1] == b'$' {
767 last = i as isize;
768 i += 2;
769 continue;
770 }
771 i += 1;
772 }
773 last
774}
775
776pub fn fix_inline_math(content: &str) -> String {
777 if content == "$" {
778 return String::new();
779 }
780 if is_inside_unclosed_code_block(content) {
781 return content.to_string();
782 }
783 let (start_line, offset) = last_paragraph_range(content, false);
784 let last_para = &content[offset..];
785
786 let without_code = strip_closed_code_blocks(last_para);
787 let without_inline = strip_inline_code(&without_code);
788 let count = count_of(&without_inline, "$$");
789
790 if count % 2 == 1 {
791 let last_dollar = find_last_dollar_pair(last_para);
792 if last_dollar < 0 {
793 return content.to_string();
794 }
795 let last_dollar = last_dollar as usize;
796 let after_last = &last_para[last_dollar + 2..];
797 if after_last.starts_with('\n') || after_last.contains('\n') {
798 return content.to_string();
799 }
800 if js_trim(after_last) == "$" {
801 return content.to_string();
802 }
803 let mut after_last = after_last.to_string();
804 let mut should_remove_trailing = false;
805 if after_last.ends_with('$') && !after_last.ends_with("$$") {
806 should_remove_trailing = true;
807 after_last.pop();
808 }
809 if !after_last.trim().is_empty() {
810 if should_remove_trailing {
811 let actual = offset + last_dollar;
812 let before_math = &content[..actual + 2];
813 let after_math = &last_para[last_dollar + 2..last_para.len() - 1];
814 return format!("{}{}$$", before_math, after_math);
815 }
816 return format!("{}$$", content);
817 }
818 let actual = offset + last_dollar;
819 let out = content[..actual].trim_end_matches(is_ws).to_string();
820 return out;
821 }
822 let _ = start_line;
823 content.to_string()
824}
825
826pub fn strip_inline_code(text: &str) -> String {
828 let ranges = find_inline_code_ranges(text, &find_closed_code_block_ranges(text));
829 if ranges.is_empty() {
830 return text.to_string();
831 }
832 let mut out = String::with_capacity(text.len());
833 let mut cursor = 0;
834 for &(start, end) in &ranges {
835 out.push_str(&text[cursor..start]);
836 cursor = end;
837 }
838 out.push_str(&text[cursor..]);
839 out
840}
841
842#[cfg(test)]
843mod tests {
844 use super::*;
845
846 #[test]
847 fn code_inline_completion() {
848 assert_eq!(fix_code("`"), "");
849 assert_eq!(fix_code("``"), "");
850 assert_eq!(fix_code("```"), "");
851 assert_eq!(fix_code("Text `"), "Text");
852 assert_eq!(fix_code("Text ``"), "Text");
853 assert_eq!(fix_code("Text ```"), "Text");
854 assert_eq!(fix_code("Hello `world"), "Hello `world`");
855 assert_eq!(fix_code("Hello `world`"), "Hello `world`");
856 assert_eq!(fix_code("Hello\n\n`"), "Hello");
857 assert_eq!(fix_code("`a` and `b"), "`a` and `b`");
858 assert_eq!(
859 fix_code("Hello `world\nand more code"),
860 "Hello `world\nand more code`"
861 );
862 assert_eq!(fix_code("Text ````"), "Text");
863 }
864
865 #[test]
866 fn code_block_completion() {
867 assert_eq!(
868 fix_code("```javascript\nconst x = 1"),
869 "```javascript\nconst x = 1\n```"
870 );
871 assert_eq!(
872 fix_code("```javascript\nconst x = 1`"),
873 "```javascript\nconst x = 1\n```"
874 );
875 assert_eq!(
876 fix_code("```javascript\nconst x = 1``"),
877 "```javascript\nconst x = 1\n```"
878 );
879 assert_eq!(
880 fix_code("```python\nprint(\"hello\")\n"),
881 "```python\nprint(\"hello\")\n```"
882 );
883 assert_eq!(fix_code("```js\ncode\n```"), "```js\ncode\n```");
884 assert_eq!(
885 fix_code("```javascript\nfunction test() {\n\n return true;\n}"),
886 "```javascript\nfunction test() {\n\n return true;\n}\n```"
887 );
888 assert_eq!(fix_code("```javascript"), "```javascript\n```");
889 }
890
891 #[test]
892 fn html_fragments() {
893 assert_eq!(fix_html("Hello <div"), "Hello");
894 assert_eq!(fix_html("Hello <div>\ncontent"), "Hello <div>\ncontent");
895 assert_eq!(fix_html("Hello <br"), "Hello");
896 assert_eq!(fix_html("Hello <"), "Hello");
897 assert_eq!(fix_html("Hello </di"), "Hello");
898 }
899
900 #[test]
901 fn footnote_removal() {
902 assert_eq!(fix_footnote("Text [^1] and [^2]"), "Text and");
903 assert_eq!(fix_footnote("Text [^1]"), "Text");
904 assert_eq!(
905 fix_footnote("Text [^1]\n\n[^1]: def"),
906 "Text [^1]\n\n[^1]: def"
907 );
908 assert_eq!(
909 fix_footnote("```\n[^1]\n```\n\nText [^1]"),
910 "```\n[^1]\n```\n\nText"
911 );
912 assert_eq!(fix_footnote("Text [^1"), "Text");
913 assert_eq!(
914 fix_footnote("Text [^1]\nand more text"),
915 "Text\nand more text"
916 );
917 assert_eq!(fix_footnote("Text `[^1]` and [^1]"), "Text `[^1]` and");
918 }
919
920 #[test]
921 fn task_list_cleanup() {
922 assert_eq!(fix_task_list("- [ ] Task 1\n-"), "- [ ] Task 1");
923 assert_eq!(
924 fix_task_list("- [ ] Task 1\n- [x] Task 2\n-"),
925 "- [ ] Task 1\n- [x] Task 2"
926 );
927 assert_eq!(fix_task_list("- [ ] Task 1\n - ["), "- [ ] Task 1");
928 assert_eq!(
929 fix_task_list("> **Note**: quote\n\n> -"),
930 "> **Note**: quote\n"
931 );
932 assert_eq!(fix_task_list("- item\n-"), "- item");
933 assert_eq!(fix_task_list("-"), "");
934 assert_eq!(fix_task_list("- ["), "");
935 assert_eq!(fix_task_list("- [ ] Task 1\n- "), "- [ ] Task 1");
936 assert_eq!(fix_task_list("> - ["), "");
937 assert_eq!(fix_task_list("> -"), "");
938 }
939
940 #[test]
941 fn link_completion() {
942 assert_eq!(fix_link("[Google"), "[Google]()");
943 assert_eq!(fix_link("[Google]"), "[Google]()");
944 assert_eq!(fix_link("Text [ content"), "Text [ content]()");
945 assert_eq!(fix_link("[Google]("), "[Google]()");
946 assert_eq!(
947 fix_link("[Google](https://www.goo"),
948 "[Google](https://www.goo)"
949 );
950 assert_eq!(
951 fix_link("[Google](https://www.google.com)"),
952 "[Google](https://www.google.com)"
953 );
954 assert_eq!(fix_link("Text ["), "Text");
955 assert_eq!(fix_link("Text [ "), "Text");
956 assert_eq!(fix_link("Text [\n"), "Text");
957 assert_eq!(fix_link("![alt"), "![alt]()");
958 assert_eq!(fix_link("![alt]"), "![alt]()");
959 assert_eq!(fix_link(", "![]()");
960 assert_eq!(
961 fix_link("[text](https://example.com/page*value"),
962 "[text](https://example.com/page*value)"
963 );
964 }
965
966 #[test]
967 fn table_fixes() {
968 assert_eq!(fix_table("| a | b |\n"), "| a | b |\n| --- | --- |");
969 assert_eq!(fix_table("| a | b |\n| ---"), "| a | b |\n| --- | --- |");
970 assert_eq!(
971 fix_table("| a | b |\n| --- | --- |"),
972 "| a | b |\n| --- | --- |"
973 );
974 assert_eq!(
975 fix_table("| a | b |\n| --- | --- |\n| 1 | 2 |"),
976 "| a | b |\n| --- | --- |\n| 1 | 2 |"
977 );
978 assert_eq!(fix_table("| a | b"), "| a | b |\n| --- | --- |");
979 }
980
981 #[test]
982 fn math_fixes() {
983 assert_eq!(fix_math("$$\nE = mc^2"), "$$\nE = mc^2\n$$");
984 assert_eq!(fix_math("$$\nE = mc^2\n$$"), "$$\nE = mc^2\n$$");
985 assert_eq!(fix_math("$$\n"), "");
986 assert_eq!(
987 fix_inline_math("The formula is $$x = 1"),
988 "The formula is $$x = 1$$"
989 );
990 assert_eq!(fix_inline_math("$"), "");
991 assert_eq!(fix_inline_math("Text $$"), "Text");
992 assert_eq!(fix_inline_math("$$"), "");
993 }
994}
995
996fn last_double_marker_pos(
1003 last_para: &str,
1004 inline_ranges: &[(usize, usize)],
1005 marker: &str,
1006) -> isize {
1007 let bytes = last_para.as_bytes();
1008 let mut in_code = false;
1009 let mut last: isize = -1;
1010 let mut i = 0;
1011 while i + 1 < bytes.len() {
1012 if crate::scan::is_triple_backtick_at(last_para.as_bytes(), i) {
1013 in_code = !in_code;
1014 i += 3;
1015 continue;
1016 }
1017 if is_position_in_ranges(i, inline_ranges) {
1018 i += 1;
1019 continue;
1020 }
1021 if in_code {
1022 i += 1;
1023 continue;
1024 }
1025 let is_marker = (marker == "**" && bytes[i] == b'*' && bytes[i + 1] == b'*')
1026 || (marker == "__" && bytes[i] == b'_' && bytes[i + 1] == b'_');
1027 if is_marker {
1028 last = i as isize;
1029 i += 2;
1030 continue;
1031 }
1032 i += 1;
1033 }
1034 last
1035}
1036
1037fn strong_counting_text(content: &str, options: &PreprocessOptions) -> (String, String) {
1039 let last_para = last_paragraph(content, true).to_string();
1040 let code_ranges = find_closed_code_block_ranges(&last_para);
1041 let inline_ranges = find_inline_code_ranges(&last_para, &code_ranges);
1042 let masked = mask_inline_code_markdown_markers(&last_para, &inline_ranges);
1043 let no_code = strip_closed_code_blocks(&masked);
1044 let no_urls = remove_urls_from_text(&no_code);
1045 let no_math = remove_math_blocks_from_text(&no_urls, options.single_dollar_text_math);
1046 let marker_counted = mask_invalid_underscore_markers(&no_math);
1047 (no_math, marker_counted)
1048}
1049
1050pub fn fix_strong(content: &str, options: &PreprocessOptions) -> String {
1051 if content == "*" || content == "_" {
1052 return String::new();
1053 }
1054 if is_inside_unclosed_code_block(content) {
1055 return content.to_string();
1056 }
1057
1058 let lines: Vec<&str> = content.split('\n').collect();
1059 let (para_line, offset) = last_paragraph_range(content, true);
1060 let last_para = &content[offset..];
1061 let code_ranges = find_closed_code_block_ranges(last_para);
1062 let inline_ranges = find_inline_code_ranges(last_para, &code_ranges);
1063
1064 let masked = mask_inline_code_markdown_markers(last_para, &inline_ranges);
1065 let no_code = strip_closed_code_blocks(&masked);
1066 let no_urls = remove_urls_from_text(&no_code);
1067 let no_math = remove_math_blocks_from_text(&no_urls, options.single_dollar_text_math);
1068 let marker_counted = mask_invalid_underscore_markers(&no_math);
1069
1070 let ends_with_single_asterisk = content.ends_with('*') && !content.ends_with("**");
1071 let ends_with_single_underscore = content.ends_with('_') && !content.ends_with("__");
1072
1073 let asterisk_count = count_of(&no_math, "**");
1074 let underscore_count = count_of(&marker_counted, "__");
1075
1076 let mut needs_asterisk_completion = false;
1077 let mut needs_underscore_completion = false;
1078 let mut needs_asterisk_removal = false;
1079 let mut needs_underscore_removal = false;
1080
1081 if asterisk_count % 2 == 1 {
1082 let last_star = last_double_marker_pos(last_para, &inline_ranges, "**");
1083 let absolute = offset + last_star as usize;
1084 if is_within_math_block(content, absolute, options.single_dollar_text_math)
1085 || is_within_link_or_image_url(content, absolute)
1086 || is_within_html_tag(content, absolute)
1087 {
1088 return content.to_string();
1089 }
1090 let pos = memchr::memmem::rfind(no_math.as_bytes(), b"**").unwrap_or(0);
1091 if !no_math[pos + 2..].trim().is_empty() {
1092 needs_asterisk_completion = true;
1093 } else {
1094 needs_asterisk_removal = true;
1095 }
1096 }
1097
1098 if underscore_count % 2 == 1 {
1099 let last_us = last_double_marker_pos_underscore(last_para, &inline_ranges);
1100 if last_us >= 0 {
1101 let absolute = offset + last_us as usize;
1102 if is_within_math_block(content, absolute, options.single_dollar_text_math)
1103 || is_within_link_or_image_url(content, absolute)
1104 || is_within_html_tag(content, absolute)
1105 {
1106 return content.to_string();
1107 }
1108 let pos = memchr::memmem::rfind(marker_counted.as_bytes(), b"__").unwrap_or(0);
1109 if !marker_counted[pos + 2..].trim().is_empty() {
1110 needs_underscore_completion = true;
1111 } else {
1112 needs_underscore_removal = true;
1113 }
1114 }
1115 }
1116
1117 let mut removed_trailing_single = false;
1118 let mut content = content.to_string();
1119
1120 if ends_with_single_asterisk && (needs_asterisk_completion || needs_asterisk_removal) {
1121 content.truncate(content.len() - 1);
1122 removed_trailing_single = true;
1123 let (no_math2, _) = strong_counting_text(&content, options);
1124 if count_of(&no_math2, "**") % 2 == 1 {
1125 let pos = memchr::memmem::rfind(no_math2.as_bytes(), b"**").unwrap_or(0);
1126 if !no_math2[pos + 2..].trim().is_empty() {
1127 needs_asterisk_completion = true;
1128 needs_asterisk_removal = false;
1129 } else {
1130 needs_asterisk_removal = true;
1131 needs_asterisk_completion = false;
1132 }
1133 }
1134 }
1135
1136 if ends_with_single_underscore && (needs_underscore_completion || needs_underscore_removal) {
1137 content.truncate(content.len() - 1);
1138 removed_trailing_single = true;
1139 let (_, marker_counted2) = strong_counting_text(&content, options);
1140 if count_of(&marker_counted2, "__") % 2 == 1 {
1141 let pos = memchr::memmem::rfind(marker_counted2.as_bytes(), b"__").unwrap_or(0);
1142 if !marker_counted2[pos + 2..].trim().is_empty() {
1143 needs_underscore_completion = true;
1144 needs_underscore_removal = false;
1145 } else {
1146 needs_underscore_removal = true;
1147 needs_underscore_completion = false;
1148 }
1149 }
1150 }
1151
1152 if needs_asterisk_removal {
1153 let mut result = content[..content.len().saturating_sub(2)]
1154 .trim_end()
1155 .to_string();
1156 result = remove_trailing_standalone_dash(&result);
1157 return result;
1158 }
1159
1160 if needs_underscore_removal {
1161 let (_, offset2) = last_paragraph_range(&content, false);
1162 let new_para = &content[offset2..];
1163 let pos = memchr::memmem::rfind(new_para.as_bytes(), b"__").unwrap_or(0);
1164 let absolute = offset2 + pos;
1165 let mut result = content[..absolute].trim_end().to_string();
1166 result = remove_trailing_standalone_dash(&result);
1167 return result;
1168 }
1169
1170 if needs_asterisk_completion && needs_underscore_completion {
1171 let first_star = memchr::memmem::find(no_math.as_bytes(), b"**").unwrap_or(usize::MAX);
1172 let first_us = memchr::memmem::find(marker_counted.as_bytes(), b"__").unwrap_or(usize::MAX);
1173 if first_star < first_us {
1174 return append_before_trailing_whitespace(&content, "__**");
1175 }
1176 return append_before_trailing_whitespace(&content, "**__");
1177 }
1178
1179 if needs_asterisk_completion {
1180 if !removed_trailing_single {
1181 let (no_math2, _) = strong_counting_text(&content, options);
1182 let without_double = no_math2.replace("**", "");
1183 if count_of(&without_double, "*") % 2 == 1 {
1184 return append_before_trailing_whitespace(&content, "***");
1185 }
1186 }
1187 return append_before_trailing_whitespace(&content, "**");
1188 }
1189
1190 if needs_underscore_completion {
1191 if !removed_trailing_single {
1192 let (_, marker_counted2) = strong_counting_text(&content, options);
1193 let without_double = marker_counted2.replace("__", "");
1194 if count_of(&without_double, "_") % 2 == 1 {
1195 return append_before_trailing_whitespace(&content, "___");
1196 }
1197 }
1198 return append_before_trailing_whitespace(&content, "__");
1199 }
1200
1201 let _ = (lines, para_line);
1202 content
1203}
1204
1205fn last_double_marker_pos_underscore(last_para: &str, inline_ranges: &[(usize, usize)]) -> isize {
1207 let bytes = last_para.as_bytes();
1208 let mut in_code = false;
1209 let mut last: isize = -1;
1210 let mut i = 0;
1211 while i + 1 < bytes.len() {
1212 if crate::scan::is_triple_backtick_at(last_para.as_bytes(), i) {
1213 in_code = !in_code;
1214 i += 3;
1215 continue;
1216 }
1217 if is_position_in_ranges(i, inline_ranges) {
1218 i += 1;
1219 continue;
1220 }
1221 if in_code {
1222 i += 1;
1223 continue;
1224 }
1225 if bytes[i] == b'_' && bytes[i + 1] == b'_' {
1226 if should_ignore_underscore_marker(last_para, i, 2) {
1227 i += 2;
1228 continue;
1229 }
1230 last = i as isize;
1231 i += 2;
1232 continue;
1233 }
1234 i += 1;
1235 }
1236 last
1237}
1238
1239pub fn fix_emphasis(content: &str) -> String {
1244 if is_inside_unclosed_code_block(content) {
1245 return content.to_string();
1246 }
1247 let (start_line, offset) = last_paragraph_range(content, false);
1248 let last_para = &content[offset..];
1249 let code_ranges = find_closed_code_block_ranges(last_para);
1250 let inline_ranges = find_inline_code_ranges(last_para, &code_ranges);
1251
1252 let masked = mask_inline_code_markdown_markers(last_para, &inline_ranges);
1253 let no_code = strip_closed_code_blocks(&masked);
1254 let no_urls = remove_urls_from_text(&no_code);
1255 let marker_counted = mask_invalid_underscore_markers(&no_urls);
1256
1257 let without_double_star = marker_counted.replace("**", "");
1258 let asterisk_count = count_of(&without_double_star, "*");
1259 let without_double_us = marker_counted.replace("__", "");
1260 let underscore_count = count_of(&without_double_us, "_");
1261
1262 let mut needs_asterisk_completion = false;
1263 let mut needs_underscore_completion = false;
1264 let mut needs_asterisk_removal = false;
1265 let mut needs_underscore_removal = false;
1266
1267 if asterisk_count % 2 == 1 {
1268 let mut last_star: isize = -1;
1270 let bytes = last_para.as_bytes();
1271 let mut i = last_para.len();
1272 while i > 0 {
1273 i -= 1;
1274 if is_position_in_ranges(i, &code_ranges) || is_position_in_ranges(i, &inline_ranges) {
1275 continue;
1276 }
1277 if bytes[i] == b'*' {
1278 if i > 0 && bytes[i - 1] == b'*' {
1279 continue;
1280 }
1281 let absolute = offset + i;
1282 if !is_within_math_block(content, absolute, false)
1283 && !is_within_link_or_image_url(content, absolute)
1284 && !is_within_html_tag(content, absolute)
1285 {
1286 last_star = i as isize;
1287 break;
1288 }
1289 }
1290 }
1291 if last_star < 0 {
1292 return content.to_string();
1293 }
1294 let has_content_after = last_para[last_star as usize + 1..]
1295 .chars()
1296 .any(|c| !c.is_whitespace());
1297 if has_content_after {
1298 needs_asterisk_completion = true;
1299 } else {
1300 needs_asterisk_removal = true;
1301 }
1302 }
1303
1304 if underscore_count % 2 == 1 {
1305 let mut last_us: isize = -1;
1306 let bytes = last_para.as_bytes();
1307 let mut i = last_para.len();
1308 while i > 0 {
1309 i -= 1;
1310 if is_position_in_ranges(i, &code_ranges) || is_position_in_ranges(i, &inline_ranges) {
1311 continue;
1312 }
1313 if bytes[i] == b'_' {
1314 if i > 0 && bytes[i - 1] == b'_' {
1315 continue;
1316 }
1317 if should_ignore_underscore_marker(last_para, i, 1) {
1318 continue;
1319 }
1320 let absolute = offset + i;
1321 if !is_within_math_block(content, absolute, false)
1322 && !is_within_link_or_image_url(content, absolute)
1323 && !is_within_html_tag(content, absolute)
1324 {
1325 last_us = i as isize;
1326 break;
1327 }
1328 }
1329 }
1330 if last_us < 0 {
1331 return content.to_string();
1332 }
1333 let has_content_after = last_para[last_us as usize + 1..]
1334 .chars()
1335 .any(|c| !c.is_whitespace());
1336 if has_content_after {
1337 needs_underscore_completion = true;
1338 } else {
1339 needs_underscore_removal = true;
1340 }
1341 }
1342
1343 if needs_asterisk_removal {
1344 let mut result = content[..content.len() - 1].trim_end().to_string();
1345 result = remove_trailing_standalone_dash(&result);
1346 return result;
1347 }
1348
1349 if needs_underscore_removal {
1350 let mut last_us_abs: isize = -1;
1352 let bytes = last_para.as_bytes();
1353 let mut i = last_para.len();
1354 while i > 0 {
1355 i -= 1;
1356 if is_position_in_ranges(i, &code_ranges) || is_position_in_ranges(i, &inline_ranges) {
1357 continue;
1358 }
1359 if bytes[i] == b'_' && (i == 0 || bytes[i - 1] != b'_') {
1360 if should_ignore_underscore_marker(last_para, i, 1) {
1361 continue;
1362 }
1363 let absolute = (offset + i) as isize;
1364 if !is_within_math_block(content, absolute as usize, false)
1365 && !is_within_link_or_image_url(content, absolute as usize)
1366 && !is_within_html_tag(content, absolute as usize)
1367 {
1368 last_us_abs = absolute;
1369 break;
1370 }
1371 }
1372 }
1373 let mut result = content[..last_us_abs.max(0) as usize]
1374 .trim_end()
1375 .to_string();
1376 result = remove_trailing_standalone_dash(&result);
1377 return result;
1378 }
1379
1380 if needs_asterisk_completion && needs_underscore_completion {
1381 let first_star = memchr::memchr(b'*', without_double_star.as_bytes()).unwrap_or(usize::MAX);
1382 let first_us = memchr::memchr(b'_', without_double_us.as_bytes()).unwrap_or(usize::MAX);
1383 if first_star < first_us {
1384 return format!("{}_*", content);
1385 }
1386 return format!("{}*_", content);
1387 }
1388
1389 if needs_asterisk_completion {
1390 return format!("{}*", content);
1391 }
1392 if needs_underscore_completion {
1393 return format!("{}_", content);
1394 }
1395 let _ = start_line;
1396 content.to_string()
1397}
1398
1399pub fn fix_delete(content: &str) -> String {
1404 if is_inside_unclosed_code_block(content) {
1405 return content.to_string();
1406 }
1407 let (start_line, offset) = last_paragraph_range(content, false);
1408 let last_para = &content[offset..];
1409
1410 let no_code = strip_closed_code_blocks(last_para);
1411 let no_urls = remove_urls_from_text(&no_code);
1412 let count = count_of(&no_urls, "~~");
1413
1414 let ends_with_single_tilde = content.ends_with('~') && !content.ends_with("~~");
1415
1416 if ends_with_single_tilde {
1417 let without_last = &content[..content.len() - 1];
1418 let no_code2 = strip_closed_code_blocks(&without_last[offset..]);
1419 let no_urls2 = remove_urls_from_text(&no_code2);
1420 let count2 = count_of(&no_urls2, "~~");
1421 if count2 % 2 == 1 {
1422 let last_pos = memchr::memmem::rfind(no_urls2.as_bytes(), b"~~").unwrap_or(0);
1423 if (last_pos > 0 || no_urls2.starts_with("~~")) && !no_urls2[last_pos + 2..].is_empty()
1424 {
1425 return format!("{}~", content);
1426 }
1427 } else {
1428 return without_last.to_string();
1429 }
1430 }
1431
1432 if count % 2 == 1 {
1433 let mut actual_last: isize = -1;
1434 let bytes = last_para.as_bytes();
1435 let mut in_code = false;
1436 let mut i = 0;
1437 while i + 1 < bytes.len() {
1438 if crate::scan::is_triple_backtick_at(last_para.as_bytes(), i) {
1439 in_code = !in_code;
1440 i += 3;
1441 continue;
1442 }
1443 if in_code {
1444 i += 1;
1445 continue;
1446 }
1447 if bytes[i] == b'~' && bytes[i + 1] == b'~' {
1448 actual_last = i as isize;
1449 i += 2;
1450 continue;
1451 }
1452 i += 1;
1453 }
1454 if actual_last < 0 {
1455 return content.to_string();
1456 }
1457 let absolute = offset + actual_last as usize;
1458 if is_within_math_block(content, absolute, false)
1459 || is_within_link_or_image_url(content, absolute)
1460 || is_within_html_tag(content, absolute)
1461 {
1462 return content.to_string();
1463 }
1464 let after_last = no_urls[memchr::memmem::rfind(no_urls.as_bytes(), b"~~").unwrap_or(0) + 2..].to_string();
1465 if !after_last.trim().is_empty() {
1466 return format!("{}~~", content);
1467 }
1468 let before_tilde = &content[..content.len() - after_last.len() - 2];
1469 return before_tilde.trim_end().to_string();
1470 }
1471 let _ = start_line;
1472 content.to_string()
1473}