Skip to main content

rsmarkdown_core/
fix.rs

1//! Streaming preprocessors ("fix" functions), ported one-to-one from
2//! `markmend/core/src/preprocess/*.ts`. Each one makes incomplete markdown
3//! syntax parseable *as it streams*, so the AST stays stable.
4
5use crate::pattern::*;
6use crate::preprocess::PreprocessOptions;
7use crate::scan::*;
8
9/// Find the last backtick run at the end (`/(`+)\s*$/`), returns (run_start, run_len) with
10/// any trailing whitespace included in `run_len`.
11fn trailing_backtick_run(content: &str) -> Option<(usize, usize)> {
12    let bytes = content.as_bytes();
13    let mut end = bytes.len();
14    // skip trailing whitespace
15    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
29// ---------------------------------------------------------------------------
30// fixCode
31// ---------------------------------------------------------------------------
32
33pub 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        // Count backticks in the last paragraph before this one
59        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            // This ` closes inline code — keep it
65            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
94/// `/```[\s\S]*?```/g` — remove closed code blocks from text.
95pub 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        // Find last standalone backtick (not part of ```)
133        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
164// ---------------------------------------------------------------------------
165// fixHtml
166// ---------------------------------------------------------------------------
167
168fn 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    // `^<!--[\s\S]*$` / `^<\?[\s\S]*$`
180    if rest.starts_with("<!--") || rest.starts_with("<?") {
181        return true;
182    }
183    // `^<![A-Z][^>]*$/i`
184    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    // `[\w-]*`
200    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        // `^<\/\s*[A-Z][\w-]*\s*$/i`
209        after_word.chars().all(is_ws)
210    } else {
211        // `^<\s*[A-Z][\w-]*(?:\s[^<>]*)?$/i`
212        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
253// ---------------------------------------------------------------------------
254// fixFootnote
255// ---------------------------------------------------------------------------
256
257/// `/\[\^[^\]\s]{1,200}\]/` reference (not a definition).
258pub 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        // label: `[^` + chars (no `]`/ws, 1..=200) + `]:`
280        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
307/// `/\[\^[^\]\s]{1,200}\](?!:)/` — footnote references not followed by `:`.
308pub 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            // `(?!:)`
335            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    // incomplete ref: `\[\^[^\]]*$`
362    if !last_para.contains("[^") {
363        return content.to_string();
364    }
365    // find last `[^` with no `]` after it
366    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
457// ---------------------------------------------------------------------------
458// fixTaskList
459// ---------------------------------------------------------------------------
460
461pub 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    // position of last line in content
471    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
490// ---------------------------------------------------------------------------
491// fixLink
492// ---------------------------------------------------------------------------
493
494pub 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    // 1. trailing standalone `[` / `![` on the last non-empty line
504    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            // drop next line if empty
521            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    // 2. `[text` or `![text` — no closing bracket
531    if has_incomplete_bracket(&without_code) {
532        return format!("{}]()", content);
533    }
534    // 3. `[text]` / `![alt]` — missing URL
535    if ends_with_incomplete_link_text(&without_code) {
536        return format!("{}()", content);
537    }
538    // 4. `[text](url` — unclosed URL
539    if ends_with_incomplete_url(&without_code) {
540        return format!("{})", content);
541    }
542    let _ = start_line;
543    content.to_string()
544}
545
546/// `/!?\[[^\]]*$/` — a `[` or `![` with no `]` afterwards (to end of string).
547fn has_incomplete_bracket(text: &str) -> bool {
548    memchr::memrchr(b'[', text.as_bytes())
549        .is_some_and(|p| !text[p + 1..].contains(']'))
550}
551
552/// `/!?\[[^\]]*\]\s*$/` — ends with `[text]` (optional trailing ws).
553fn 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
564/// `/!?\[[^\]]*\]\([^)]*$/` — ends with `[text](...` unclosed.
565fn 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
587// ---------------------------------------------------------------------------
588// fixTable
589// ---------------------------------------------------------------------------
590
591/// `/|/` count minus 1 = number of columns.
592fn 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    // find first potential header row
620    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    // header row must not sit inside a closed code block
636    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    // Case 1: header is last line of paragraph
654    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    // Case 2: next line is already a matching separator
668    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    // Case 3: incomplete separator or data row below — insert/replace separator
677    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
705// ---------------------------------------------------------------------------
706// fixMath / fixInlineMath
707// ---------------------------------------------------------------------------
708
709pub 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
745/// Find the last `$$` that is not inside a code block / inline code.
746fn 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
826/// /`[^`\n]+`/g — remove inline code spans.
827pub 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
996// ---------------------------------------------------------------------------
997// fixStrong
998// ---------------------------------------------------------------------------
999
1000/// Scan the original last paragraph for the last `**` / `__` marker position,
1001/// skipping fenced code blocks and inline code ranges.
1002fn 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
1037/// Sanitized last paragraph for marker counting (strong flavor).
1038fn 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
1205/// Underscore variant of the last-marker scan (respects intraword/escaped `__`).
1206fn 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
1239// ---------------------------------------------------------------------------
1240// fixEmphasis
1241// ---------------------------------------------------------------------------
1242
1243pub 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        // find last single `*` not part of `**`, not in code/url/math/html
1269        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        // find last single `_` position in original text
1351        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
1399// ---------------------------------------------------------------------------
1400// fixDelete
1401// ---------------------------------------------------------------------------
1402
1403pub 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}