Skip to content

Commit 76c24a1

Browse files
committed
fix!: drasticly improve escaping !, #, and |
Raises spec tests from 578 to 580. It handles cases that look like these Link, not image: \![a](b) This header ends with hashes, not an ATX trailer ### ==================================================== | a \| b | a \| c | |--------|--------|
1 parent 6c8bc4d commit 76c24a1

File tree

7 files changed

+88
-20
lines changed

7 files changed

+88
-20
lines changed

src/lib.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ pub struct State<'a> {
7171
pub last_was_text_without_trailing_newline: bool,
7272
/// True if the last event was a paragraph start. Used to escape spaces at start of line (prevent spurrious indented code).
7373
pub last_was_paragraph_start: bool,
74+
/// True if the next event is a link, image, or footnote.
75+
pub next_is_link_like: bool,
7476
/// Currently open links
7577
pub link_stack: Vec<LinkCategory<'a>>,
7678
/// Currently open images
@@ -254,7 +256,15 @@ where
254256
F: fmt::Write,
255257
{
256258
let mut state = state.unwrap_or_default();
257-
for event in events {
259+
let mut events = events.peekable();
260+
while let Some(event) = events.next() {
261+
state.next_is_link_like = matches!(
262+
events.peek().map(Borrow::borrow),
263+
Some(
264+
Event::Start(Tag::Link { .. } | Tag::Image { .. } | Tag::FootnoteDefinition(..))
265+
| Event::FootnoteReference(..)
266+
)
267+
);
258268
cmark_resume_one_event(event, &mut formatter, &mut state, &options)?;
259269
}
260270
Ok(state)
@@ -776,7 +786,7 @@ where
776786
}
777787
state.last_was_text_without_trailing_newline = !text.ends_with('\n');
778788
print_text_without_trailing_newline(
779-
&escape_leading_special_characters(text, state.is_in_code_block(), options),
789+
&escape_special_characters(text, &state, options),
780790
formatter,
781791
&state.padding,
782792
)

src/source_range.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ where
3939
for (event, range) in event_and_ranges {
4040
let update_event_end_index = !matches!(*event.borrow(), Event::Start(_));
4141
let prevent_escape_leading_special_characters = match (&range, event.borrow()) {
42+
// Headers and tables can have special characters that aren't at the start
43+
// of the line, because headers end with `#` and tables have pipes in the middle.
44+
_ if state.current_heading.is_some() || !state.table_alignments.is_empty() => false,
4245
// IMPORTANT: Any changes that allow anything other than `Text`
4346
// breaks the assumption below.
4447
(Some(range), Event::Text(_)) => {

src/text_modifications.rs

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,21 +21,25 @@ where
2121
Ok(())
2222
}
2323

24-
pub fn escape_leading_special_characters<'a>(
25-
t: &'a str,
26-
is_in_code_block: bool,
27-
options: &Options<'a>,
28-
) -> Cow<'a, str> {
29-
if is_in_code_block || t.is_empty() {
24+
pub fn escape_special_characters<'a>(t: &'a str, state: &State<'a>, options: &Options<'a>) -> Cow<'a, str> {
25+
if state.is_in_code_block() || t.is_empty() {
3026
return Cow::Borrowed(t);
3127
}
3228

3329
let first = t.chars().next().expect("at least one char");
34-
if options.special_characters().contains(first) {
30+
let first_special = options.special_characters().contains(first);
31+
let ends_with_special =
32+
(state.next_is_link_like && t.ends_with("!")) || (state.current_heading.is_some() && t.ends_with("#"));
33+
let table_contains_pipe = !state.table_alignments.is_empty() && t.contains("|");
34+
if first_special || ends_with_special || table_contains_pipe {
3535
let mut s = String::with_capacity(t.len() + 1);
36-
s.push('\\');
37-
s.push(first);
38-
s.push_str(&t[1..]);
36+
for (i, c) in t.char_indices() {
37+
if (i == 0 && first_special) || (i == t.len() - 1 && ends_with_special) || (c == '|' && table_contains_pipe)
38+
{
39+
s.push('\\');
40+
}
41+
s.push(c);
42+
}
3943
Cow::Owned(s)
4044
} else {
4145
Cow::Borrowed(t)

tests/display.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ mod start {
4141
Tag::*,
4242
};
4343

44-
use super::s;
44+
use super::{es, s};
4545

4646
#[test]
4747
fn paragraph() {
@@ -178,6 +178,18 @@ mod start {
178178
fn table_cell() {
179179
assert_eq!(s(Start(TableCell)), "|");
180180
}
181+
#[test]
182+
fn table_pipe() {
183+
assert_eq!(
184+
es([
185+
Start(Table(vec![Left, Center, Right, Alignment::None])),
186+
Start(TableHead),
187+
Start(TableCell),
188+
Text("a|b".into()),
189+
]),
190+
r"|a\|b"
191+
);
192+
}
181193

182194
#[test]
183195
fn definition_list_definition() {

tests/fmt.rs

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use pulldown_cmark::{Alignment, CodeBlockKind, Event, LinkType, Options, Parser, Tag, TagEnd};
1+
use pulldown_cmark::{utils::TextMergeStream, Alignment, CodeBlockKind, Event, LinkType, Options, Parser, Tag, TagEnd};
22
use pulldown_cmark_to_cmark::{cmark, cmark_resume, cmark_resume_with_options, Options as CmarkToCmarkOptions, State};
33

44
mod source_range_fmt;
@@ -60,8 +60,8 @@ fn assert_events_eq(s: &str) {
6060
let mut buf = String::new();
6161
cmark(before_events, &mut buf).unwrap();
6262

63-
let before_events = Parser::new_ext(s, Options::all());
64-
let after_events = Parser::new_ext(&buf, Options::all());
63+
let before_events = TextMergeStream::new(Parser::new_ext(s, Options::all()));
64+
let after_events = TextMergeStream::new(Parser::new_ext(&buf, Options::all()));
6565
println!("{buf}");
6666
assert_eq!(before_events.collect::<Vec<_>>(), after_events.collect::<Vec<_>>());
6767
}
@@ -1010,6 +1010,36 @@ mod table {
10101010
let p = Parser::new_ext(&generated_markdown, Options::all());
10111011
let generated_events: Vec<_> = p.into_iter().collect();
10121012

1013+
assert_eq!(original_events, generated_events);
1014+
}
1015+
#[test]
1016+
fn table_with_pipe_in_column() {
1017+
use pulldown_cmark::{Options, Parser};
1018+
1019+
let original_table_markdown = indoc!(
1020+
r"
1021+
| \| | a\|b |
1022+
|----|------|
1023+
| \| | a\|b |"
1024+
);
1025+
let p = Parser::new_ext(original_table_markdown, Options::all());
1026+
let original_events: Vec<_> = p.into_iter().collect();
1027+
1028+
let (generated_markdown, _) = fmte(&original_events);
1029+
1030+
assert_eq!(
1031+
generated_markdown,
1032+
indoc!(
1033+
r"
1034+
|\||a\|b|
1035+
|-|---|
1036+
|\||a\|b|"
1037+
)
1038+
);
1039+
1040+
let p = Parser::new_ext(&generated_markdown, Options::all());
1041+
let generated_events: Vec<_> = p.into_iter().collect();
1042+
10131043
assert_eq!(original_events, generated_events);
10141044
}
10151045
}
@@ -1452,6 +1482,12 @@ mod heading {
14521482
assert_events_eq_both("# Heading { #id .class1 key1=val1 .class2 }");
14531483
assert_events_eq_both("# Heading { #id .class1 .class2 key1=val1 key2 }");
14541484
}
1485+
#[test]
1486+
fn heading_with_hashes_at_end() {
1487+
assert_events_eq_both("Heading #\n====");
1488+
assert_events_eq_both("Heading \\#\n====");
1489+
assert_events_eq_both("# Heading \\#");
1490+
}
14551491
}
14561492

14571493
mod frontmatter {

tests/source_range_fmt.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copied from `fmt.rs`.
22

3-
use pulldown_cmark::{Options, Parser};
3+
use pulldown_cmark::{utils::TextMergeStream, Options, Parser};
44
use pulldown_cmark_to_cmark::{
55
cmark_resume_with_source_range_and_options, cmark_with_source_range, Options as CmarkToCmarkOptions, State,
66
};
@@ -50,7 +50,7 @@ pub fn assert_events_eq(s: &str) {
5050
)
5151
.unwrap();
5252

53-
let before_events = Parser::new_ext(s, Options::all());
54-
let after_events = Parser::new_ext(&buf, Options::all());
53+
let before_events = TextMergeStream::new(Parser::new_ext(s, Options::all()));
54+
let after_events = TextMergeStream::new(Parser::new_ext(&buf, Options::all()));
5555
assert_eq!(before_events.collect::<Vec<_>>(), after_events.collect::<Vec<_>>());
5656
}

tests/spec.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,12 @@ fn collect_test_case<'a>(events: &mut impl Iterator<Item = Event<'a>>) -> Option
3838

3939
fn test_roundtrip(original: &str, expected: &str) -> bool {
4040
let opts = Options::empty();
41-
let event_list = TextMergeStream::new(Parser::new_ext(original, opts)).collect::<Vec<_>>();
41+
let event_list = Parser::new_ext(original, opts).collect::<Vec<_>>();
4242
let mut regen_str = String::new();
4343
cmark(event_list.iter().cloned(), &mut regen_str).expect("Regeneration failure");
44+
// text events should be merged before comparing two event lists for equivalence.
45+
// you don't need to merge them before feeding them into `cmark`.
46+
let event_list: Vec<Event<'_>> = TextMergeStream::new(event_list.into_iter()).collect();
4447
let event_list_2 = TextMergeStream::new(Parser::new_ext(&regen_str, opts)).collect::<Vec<_>>();
4548
let event_count = event_list.len();
4649
let event_count_2 = event_list_2.len();

0 commit comments

Comments
 (0)