Skip to content

Commit a8aed70

Browse files
committed
fix(tables): quoted brackets and a blank target are not a wikilink
Under the Obsidian flavor a pipe inside `[[Target|Label]]` belongs to the link, so it is masked before a table row is split into cells. Two shapes were read as links when they are prose, and masking them merged cells that were correct into fewer, reporting a column-count mismatch against a table that had none: - brackets quoted as code, as in `| `[[` | mid | `]]` |`. Code binds tighter than a link, so those brackets open nothing. - a blank link target, as in `| [[ | ]] |`. The half before the pipe names the note, so with nothing there the brackets are prose that happens to straddle a cell divider. Both now split exactly as GFM does. The masker and the wikilink scan read one definition of an inline code span, so they cannot disagree about where code begins: an unmatched run of backticks is literal text to both, and a `]]` that exists only inside a code span does not close a link.
1 parent 8e16303 commit a8aed70

1 file changed

Lines changed: 249 additions & 94 deletions

File tree

src/utils/table_utils.rs

Lines changed: 249 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -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|\tx]] |",
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

Comments
 (0)