@@ -567,88 +567,98 @@ impl TableUtils {
567567 count
568568 }
569569
570- /// Mask pipes inside inline code blocks with a placeholder character.
570+ /// Locate the inline code spans in `chars` as half-open char ranges covering
571+ /// the opening delimiter, the content and the closing delimiter.
571572 ///
572573 /// Backticks preceded by an odd number of backslashes are escaped (literal text)
573574 /// and do not open or close code spans. An even number of backslashes means the
574- /// backslashes themselves are escaped, so the backtick is a real delimiter.
575- pub fn mask_pipes_in_inline_code ( text : & str ) -> String {
576- let mut result = String :: new ( ) ;
577- let chars: Vec < char > = text. chars ( ) . collect ( ) ;
575+ /// backslashes themselves are escaped, so the backtick is a real delimiter. A run
576+ /// of backticks with no matching closing run of the same length is literal text,
577+ /// and scanning resumes just after it.
578+ ///
579+ /// This is the single definition of a code span for table parsing: both pipe
580+ /// masking and wikilink detection read it, so they cannot disagree about where
581+ /// code starts and ends.
582+ fn inline_code_spans ( chars : & [ char ] ) -> Vec < ( usize , usize ) > {
583+ let mut spans = Vec :: new ( ) ;
578584 let mut i = 0 ;
579585
580586 while i < chars. len ( ) {
581- if chars[ i] == '`' {
582- // A backtick preceded by an odd number of backslashes is escaped
583- let preceding = Self :: count_preceding_backslashes ( & chars, i) ;
584- if preceding % 2 != 0 {
585- // Escaped backtick -- treat as literal text, not a code span opener
586- result. push ( chars[ i] ) ;
587- i += 1 ;
588- continue ;
589- }
590-
591- // Count consecutive backticks at start
592- let start = i;
593- let mut backtick_count = 0 ;
594- while i < chars. len ( ) && chars[ i] == '`' {
595- backtick_count += 1 ;
596- i += 1 ;
597- }
598-
599- // Look for matching closing backticks
600- let mut found_closing = false ;
601- let mut j = i;
602-
603- while j < chars. len ( ) {
604- if chars[ j] == '`' {
605- // Per CommonMark spec, backslash escapes do NOT work inside code
606- // spans -- all characters including backslashes are literal. So we
607- // do NOT check count_preceding_backslashes here (only for the
608- // opening backtick above).
609-
610- // Count potential closing backticks
611- let close_start = j;
612- let mut close_count = 0 ;
613- while j < chars. len ( ) && chars[ j] == '`' {
614- close_count += 1 ;
615- j += 1 ;
616- }
617-
618- if close_count == backtick_count {
619- // Found matching closing backticks
620- found_closing = true ;
587+ if chars[ i] != '`' {
588+ i += 1 ;
589+ continue ;
590+ }
621591
622- // Valid inline code - add with pipes masked
623- result. extend ( chars[ start..i] . iter ( ) ) ;
592+ // A backtick preceded by an odd number of backslashes is escaped
593+ if Self :: count_preceding_backslashes ( chars, i) % 2 != 0 {
594+ i += 1 ;
595+ continue ;
596+ }
624597
625- for & ch in chars . iter ( ) . take ( close_start ) . skip ( i ) {
626- if ch == '|' {
627- result . push ( '_' ) ; // Mask pipe with underscore
628- } else {
629- result . push ( ch ) ;
630- }
631- }
598+ // Count consecutive backticks at start
599+ let start = i ;
600+ let mut backtick_count = 0 ;
601+ while i < chars . len ( ) && chars [ i ] == '`' {
602+ backtick_count += 1 ;
603+ i += 1 ;
604+ }
632605
633- result. extend ( chars[ close_start..j] . iter ( ) ) ;
634- i = j;
635- break ;
636- }
637- // If not matching, continue searching (j is already past these backticks)
638- } else {
606+ // Look for a closing run of exactly the same length. Per CommonMark,
607+ // backslash escapes do NOT work inside code spans -- all characters
608+ // including backslashes are literal -- so no escape check applies here.
609+ let mut j = i;
610+ while j < chars. len ( ) {
611+ if chars[ j] == '`' {
612+ let mut close_count = 0 ;
613+ while j < chars. len ( ) && chars[ j] == '`' {
614+ close_count += 1 ;
639615 j += 1 ;
640616 }
641- }
642617
643- if !found_closing {
644- // No matching closing found, treat as regular text
645- result. extend ( chars[ start..i] . iter ( ) ) ;
618+ if close_count == backtick_count {
619+ spans. push ( ( start, j) ) ;
620+ i = j;
621+ break ;
622+ }
623+ // Run of a different length: keep searching (j is already past it)
624+ } else {
625+ j += 1 ;
646626 }
647- } else {
648- result. push ( chars[ i] ) ;
649- i += 1 ;
650627 }
628+ // With no matching closing run the opener is literal text; `i` already
629+ // sits just past it, so a later backtick can still open a span.
630+ }
631+
632+ spans
633+ }
634+
635+ /// Mask pipes inside inline code blocks with a placeholder character.
636+ ///
637+ /// The mask is the same byte width as what it replaces, so offsets into the
638+ /// masked string still address the original text.
639+ pub fn mask_pipes_in_inline_code ( text : & str ) -> String {
640+ if !text. contains ( '`' ) {
641+ return text. to_string ( ) ;
642+ }
643+
644+ let chars: Vec < char > = text. chars ( ) . collect ( ) ;
645+ let spans = Self :: inline_code_spans ( & chars) ;
646+ if spans. is_empty ( ) {
647+ return text. to_string ( ) ;
648+ }
649+
650+ let mut result = String :: with_capacity ( text. len ( ) ) ;
651+ let mut cursor = 0 ;
652+ for ( start, end) in spans {
653+ result. extend ( chars[ cursor..start] . iter ( ) ) ;
654+ // The delimiters are backticks, so masking every pipe across the whole
655+ // span leaves them untouched and only rewrites the content.
656+ for & ch in & chars[ start..end] {
657+ result. push ( if ch == '|' { '_' } else { ch } ) ;
658+ }
659+ cursor = end;
651660 }
661+ result. extend ( chars[ cursor..] . iter ( ) ) ;
652662
653663 result
654664 }
@@ -658,41 +668,56 @@ impl TableUtils {
658668 /// In Obsidian, `[[Target|Label]]` renders `Label` as a link to `Target`, so
659669 /// the pipe separates the two halves of one link rather than two table cells.
660670 /// Only a pipe between `[[` and a closing `]]` on the same line is masked.
671+ ///
672+ /// Two shapes are deliberately left as prose, because reading them as a link
673+ /// would merge cells in a table that is already well formed and so would report
674+ /// a column-count mismatch against a document that has none:
675+ ///
676+ /// - Brackets inside an inline code span. Code binds tighter than links, so
677+ /// ``` `[[` | mid | `]]` ``` is three cells of prose, not one cell with a link.
678+ /// - A blank link target. `[[ | ]]` and `[[|Label]]` name no note, so the
679+ /// brackets are prose that happens to straddle a cell divider.
680+ ///
681+ /// The mask is the same byte width as what it replaces, so offsets into the
682+ /// masked string still address the original text.
661683 pub fn mask_pipes_in_wikilinks ( text : & str ) -> String {
684+ // Without both halves of an opener and a pipe to hide there is nothing to do,
685+ // and this runs over every line of an Obsidian document.
686+ if !text. contains ( "[[" ) || !text. contains ( '|' ) {
687+ return text. to_string ( ) ;
688+ }
689+
662690 let chars: Vec < char > = text. chars ( ) . collect ( ) ;
663- let mut result = String :: new ( ) ;
691+ let code_spans = Self :: inline_code_spans ( & chars) ;
692+ let code_span_at = |pos : usize | code_spans. iter ( ) . find ( |& & ( s, e) | pos >= s && pos < e) . copied ( ) ;
693+
694+ let mut result = String :: with_capacity ( text. len ( ) ) ;
664695 let mut i = 0 ;
665696
666697 while i < chars. len ( ) {
667- if chars[ i] == '[' && i + 1 < chars. len ( ) && chars[ i + 1 ] == '[' {
668- // Look for the closing "]]" that ends this wikilink
669- let mut j = i + 2 ;
670- let mut close = None ;
671- while j + 1 < chars. len ( ) {
672- if chars[ j] == ']' && chars[ j + 1 ] == ']' {
673- close = Some ( j) ;
674- break ;
675- }
676- // A wikilink does not span a nested "[["
677- if chars[ j] == '[' && chars[ j + 1 ] == '[' {
678- break ;
679- }
680- j += 1 ;
681- }
698+ // Copy a code span through untouched; nothing inside it is link syntax.
699+ if let Some ( ( _, end) ) = code_span_at ( i) {
700+ result. extend ( chars[ i..end] . iter ( ) ) ;
701+ i = end;
702+ continue ;
703+ }
682704
683- if let Some ( close) = close {
684- result. push_str ( "[[" ) ;
685- for & ch in chars. iter ( ) . take ( close) . skip ( i + 2 ) {
686- if ch == '|' {
687- result. push ( '_' ) ; // Mask pipe with underscore
688- } else {
689- result. push ( ch) ;
690- }
705+ if chars[ i] == '['
706+ && i + 1 < chars. len ( )
707+ && chars[ i + 1 ] == '['
708+ && let Some ( close) = Self :: wikilink_close ( & chars, & code_spans, i)
709+ {
710+ result. push_str ( "[[" ) ;
711+ for & ch in & chars[ i + 2 ..close] {
712+ if ch == '|' {
713+ result. push ( '_' ) ; // Mask pipe with underscore
714+ } else {
715+ result. push ( ch) ;
691716 }
692- result. push_str ( "]]" ) ;
693- i = close + 2 ;
694- continue ;
695717 }
718+ result. push_str ( "]]" ) ;
719+ i = close + 2 ;
720+ continue ;
696721 }
697722
698723 result. push ( chars[ i] ) ;
@@ -702,6 +727,48 @@ impl TableUtils {
702727 result
703728 }
704729
730+ /// Find the `]]` closing the wikilink opened by the `[[` at `open`, or `None`
731+ /// when the brackets do not delimit one.
732+ ///
733+ /// `code_spans` are the ranges from [`Self::inline_code_spans`]; brackets and
734+ /// pipes inside one are content, so the scan steps over them whole.
735+ fn wikilink_close ( chars : & [ char ] , code_spans : & [ ( usize , usize ) ] , open : usize ) -> Option < usize > {
736+ let mut j = open + 2 ;
737+ let mut first_pipe = None ;
738+
739+ while j + 1 < chars. len ( ) {
740+ if let Some ( & ( _, end) ) = code_spans. iter ( ) . find ( |& & ( s, _) | s == j) {
741+ j = end;
742+ continue ;
743+ }
744+
745+ if chars[ j] == ']' && chars[ j + 1 ] == ']' {
746+ // The half before the pipe names the note the link points at, so a
747+ // blank one means these brackets are prose that happens to straddle
748+ // a cell divider rather than a link holding one.
749+ if let Some ( pipe) = first_pipe
750+ && chars[ open + 2 ..pipe] . iter ( ) . all ( |c| c. is_whitespace ( ) )
751+ {
752+ return None ;
753+ }
754+ return Some ( j) ;
755+ }
756+
757+ // A wikilink does not span a nested "[["
758+ if chars[ j] == '[' && chars[ j + 1 ] == '[' {
759+ return None ;
760+ }
761+
762+ if chars[ j] == '|' && first_pipe. is_none ( ) {
763+ first_pipe = Some ( j) ;
764+ }
765+
766+ j += 1 ;
767+ }
768+
769+ None
770+ }
771+
705772 /// Mask escaped pipes for accurate table cell parsing
706773 ///
707774 /// In GFM tables, escape handling happens BEFORE cell boundary detection:
@@ -1620,6 +1687,94 @@ But no delimiter row
16201687 }
16211688 }
16221689
1690+ #[ test]
1691+ fn test_wikilink_brackets_in_a_code_span_stay_prose ( ) {
1692+ // Code binds tighter than a link, so brackets quoted as code open nothing.
1693+ // Reading them as a link would merge four well-formed cells into fewer and
1694+ // report a column-count mismatch against a table that has none.
1695+ for row in [
1696+ "| `[[` | mid | `]]` |" ,
1697+ "| `[[Target` | mid | `Label]]` |" ,
1698+ "| a | `[[` | b | `]]` |" ,
1699+ // A quoted "[[" is not an opener even when a real "]]" follows it
1700+ // outside the span, so the pipe between them stays a delimiter.
1701+ "| `[[` and Target|Label]] |" ,
1702+ ] {
1703+ let obsidian = TableUtils :: split_table_row_with_flavor ( row, crate :: config:: MarkdownFlavor :: Obsidian ) ;
1704+ let standard = TableUtils :: split_table_row_with_flavor ( row, crate :: config:: MarkdownFlavor :: Standard ) ;
1705+ assert_eq ! (
1706+ obsidian, standard,
1707+ "Obsidian disagreed with GFM about {row:?}: {obsidian:?} vs {standard:?}"
1708+ ) ;
1709+ }
1710+
1711+ // A code span inside a genuine wikilink is content of the alias, so the
1712+ // link still closes at its own "]]" and the row is one cell.
1713+ let cells = TableUtils :: split_table_row_with_flavor (
1714+ "| [[Target|Label with `a|b` inside]] |" ,
1715+ crate :: config:: MarkdownFlavor :: Obsidian ,
1716+ ) ;
1717+ assert_eq ! (
1718+ cells. len( ) ,
1719+ 1 ,
1720+ "Wikilink holding a code span should be one cell, got {cells:?}"
1721+ ) ;
1722+
1723+ // A "]]" that only exists inside a code span does not close the link, so
1724+ // there is nothing to mask and the pipes stay delimiters.
1725+ let cells = TableUtils :: split_table_row_with_flavor (
1726+ "| [[Target | alias `]]` | tail |" ,
1727+ crate :: config:: MarkdownFlavor :: Obsidian ,
1728+ ) ;
1729+ assert_eq ! (
1730+ cells. len( ) ,
1731+ 3 ,
1732+ "A closer hidden in code should not close the link, got {cells:?}"
1733+ ) ;
1734+ }
1735+
1736+ #[ test]
1737+ fn test_wikilink_with_a_blank_target_stays_prose ( ) {
1738+ // The half before the pipe names the note, so a blank one means these
1739+ // brackets are prose that happens to straddle a cell divider.
1740+ for row in [
1741+ "| [[ | ]] |" ,
1742+ "| [[|Label]] |" ,
1743+ "| starts [[ | ends ]] here |" ,
1744+ "| [[\t |\t x]] |" ,
1745+ ] {
1746+ let obsidian = TableUtils :: split_table_row_with_flavor ( row, crate :: config:: MarkdownFlavor :: Obsidian ) ;
1747+ let standard = TableUtils :: split_table_row_with_flavor ( row, crate :: config:: MarkdownFlavor :: Standard ) ;
1748+ assert_eq ! (
1749+ obsidian, standard,
1750+ "Obsidian disagreed with GFM about {row:?}: {obsidian:?} vs {standard:?}"
1751+ ) ;
1752+ }
1753+
1754+ // Positive control: one non-whitespace character of target is a link.
1755+ let cells = TableUtils :: split_table_row_with_flavor ( "| [[x | y]] |" , crate :: config:: MarkdownFlavor :: Obsidian ) ;
1756+ assert_eq ! ( cells. len( ) , 1 , "A named target should be one cell, got {cells:?}" ) ;
1757+ }
1758+
1759+ #[ test]
1760+ fn test_inline_code_spans_agree_with_pipe_masking ( ) {
1761+ // Both maskers read one definition of a code span, so an unmatched run of
1762+ // backticks is literal text to both and scanning resumes just after it.
1763+ let text = "``x | y and `c|d`" ;
1764+ let chars: Vec < char > = text. chars ( ) . collect ( ) ;
1765+ let spans = TableUtils :: inline_code_spans ( & chars) ;
1766+ assert_eq ! ( spans. len( ) , 1 , "Only the matched pair is a code span, got {spans:?}" ) ;
1767+ let ( start, end) = spans[ 0 ] ;
1768+ assert_eq ! (
1769+ chars[ start..end] . iter( ) . collect:: <String >( ) ,
1770+ "`c|d`" ,
1771+ "The span should start at the run that closes, not the unmatched opener"
1772+ ) ;
1773+
1774+ // The pipe outside every span survives; the one inside is masked.
1775+ assert_eq ! ( TableUtils :: mask_pipes_in_inline_code( text) , "``x | y and `c_d`" ) ;
1776+ }
1777+
16231778 // === extract_blockquote_prefix tests ===
16241779
16251780 #[ test]
0 commit comments