Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 57 additions & 2 deletions src/parser/markdown.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,21 @@ fn next_nonblank_indent(lines: &[Line<'_>], start: usize) -> Option<usize> {
.map(|l| line_indent(l.text))
}

/// Next non-blank is a definition marker (`: ` / `~ `), including after
/// quote markers. pulldown keeps the DL open across one blank then
/// another `<dd>`.
fn next_nonblank_is_dl_marker(lines: &[Line<'_>], start: usize) -> bool {
let Some(line) = lines[start..].iter().find(|l| !l.text.trim().is_empty()) else {
return false;
};
if md_definition_list_marker_len(line.text).is_some() {
return true;
}
let depth = quote_marker_depth(line.text);
strip_quote_markers(line.text, depth)
.is_some_and(|inner| md_definition_list_marker_len(inner).is_some())
}

fn starts_html_comment(line: &str) -> bool {
// CM 4.6 type 2: at most three spaces. Four-space `<!--` is indented
// code (or lazy title text), not an HTML block.
Expand Down Expand Up @@ -2064,7 +2079,8 @@ impl FormatParser for MarkdownParser {
&& list_hang.is_some_and(|hang| {
next_nonblank_indent(&lines, i + 1).is_some_and(|ind| ind >= hang)
});
if stay_in_item {
let stay_in_dl = in_definition_list && next_nonblank_is_dl_marker(&lines, i + 1);
if stay_in_item || stay_in_dl {
flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions);
if let Some(span) = list_term.take() {
if !span.is_empty() {
Expand Down Expand Up @@ -2344,8 +2360,43 @@ impl FormatParser for MarkdownParser {
);
let opener = i;
let end = footnote_def_end(&lines, opener);
for row in &lines[opener + 1..=end] {
let mut j = opener + 1;
while j <= end {
let row = &lines[j];
let hang = line_indent(row.text);
let inner = row.text.get(hang..).unwrap_or("");
if FENCED_CODE_RE.is_match(inner.trim_start()) {
flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions);
let fence = FENCED_CODE_RE
.captures(inner.trim_start())
.and_then(|c| c.get(1))
.map(|m| m.as_str())
.unwrap_or("```");
let close = lines[j + 1..=end]
.iter()
.enumerate()
.find_map(|(off, line)| {
let kh = line_indent(line.text);
let ki = line.text.get(kh..).unwrap_or("");
ki.trim_start().starts_with(fence).then_some(j + 1 + off)
})
.unwrap_or(end);
if hang > 0 {
regions.push(SpannedRegion::structure(
input,
ByteSpan::new(row.start, row.start + hang),
));
}
regions.push(SpannedRegion::code(
input,
None,
ByteSpan::new(row.start + hang, row.end),
ByteSpan::new(row.start + hang, lines[close].end),
ByteSpan::new(lines[close].end, lines[close].end),
));
j = close + 1;
continue;
}
if hang > 0 {
let hang_span = ByteSpan::new(row.start, row.start + hang);
regions.push(SpannedRegion::structure(input, hang_span));
Expand All @@ -2363,6 +2414,7 @@ impl FormatParser for MarkdownParser {
input,
&mut regions,
);
j += 1;
}
i = end + 1;
// pulldown pops the footnote on a column-0 line even
Expand Down Expand Up @@ -2652,6 +2704,9 @@ impl FormatParser for MarkdownParser {
let marker_len = line_text.len() - text.len();
if text.trim().is_empty() {
regions.push(SpannedRegion::structure(input, line.span()));
if was_in_definition_list {
in_definition_list = true;
}
i += 1;
continue;
}
Expand Down
101 changes: 98 additions & 3 deletions src/sentence/unicode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1578,6 +1578,8 @@ pub(crate) fn leftover_keyval_cs_name(tail: &str) -> Option<&'static str> {
"tcbinputrecords",
"tcbsidebyside",
"tcbsubskin",
"tcbmakedocSubKeys",
"tcbmakedocSubKey",
"tcbmakeprefixed",
"tcbhyperref",
"tcbhypernode",
Expand Down Expand Up @@ -1791,7 +1793,9 @@ fn leftover_keyval_kind(name: &str) -> VerbKind {
| "tcbsetmacrotowidthofnode"
| "tcbsettoheightofnode"
| "tcbsetmacrotoheightofnode"
| "tcbsetfromto" => VerbKind::Listinginput,
| "tcbsetfromto"
| "tcbmakedocSubKeys"
| "tcbmakedocSubKey" => VerbKind::Listinginput,
"RecustomVerbatimEnvironment"
| "CustomVerbatimEnvironment"
| "DefineVerbatimEnvironment"
Expand Down Expand Up @@ -3034,7 +3038,8 @@ impl UnicodeSentenceSplitter {
self.extra_pattern.as_ref(),
);
let merged = merge_quoted_punct_splits(merged);
merge_splits_inside_delimiters(merged)
let merged = merge_splits_inside_delimiters(merged);
split_double_bang_then_letter(merged)
}
}

Expand Down Expand Up @@ -3169,7 +3174,10 @@ fn push_segment_preserving_space(dest: &mut String, piece: &str) {
let need_space = dest.chars().last().is_some_and(|c| !c.is_whitespace())
&& next.is_some_and(|c| {
!c.is_whitespace() && (c.is_alphanumeric() || matches!(c, '"' | '\'' | '`' | '('))
});
})
// `!!a` is one UAX fragment; inventing `!! a` then splitting
// `!! a` on the next pass is a wrap/SemBr cycle.
&& !dest.ends_with("!!");
if need_space {
dest.push(' ');
}
Expand Down Expand Up @@ -3284,6 +3292,84 @@ fn merge_quoted_punct_splits(segments: Vec<String>) -> Vec<String> {
/// UAX SB11 breaks after ATerm+Sp *before* a closer, so `(. aA. )A` is
/// `(. aA. ` + `)A`. Glue only the closer; a following capital is a new
/// sentence (GitHub #266). The tight form `(. aA.)A` already splits.
///
/// `!!a` is one UAX fragment. Split so first and second format_text
/// passes agree (`!!` ends the sentence; the letter starts the next).
/// Do not split when `!!` sits inside a wrapper this piece closes
/// (`"!!a"`, `'!!A'A`). DelimState's apostrophe heuristic treats the
/// closer in `'!!A'A` as `A'A`, so this walk toggles `'` without it.
/// An unclosed opener (`[=!!a`) still splits.
fn split_double_bang_then_letter(segments: Vec<String>) -> Vec<String> {
let mut out = Vec::new();
for seg in segments {
let chars: Vec<(usize, char)> = seg.char_indices().collect();
let mut start = 0;
let mut idx = 0;
while idx + 1 < chars.len() {
if chars[idx].1 == '!' && chars[idx + 1].1 == '!' {
if let Some(&(off, ch)) = chars.get(idx + 2) {
if ch.is_alphabetic() && !wrap_closes_after_bang(&seg[start..], off - start) {
out.push(seg[start..off].to_string());
start = off;
idx += 2;
continue;
}
}
}
idx += 1;
}
if start < seg.len() {
out.push(seg[start..].to_string());
}
}
out
}

/// True when `!!` at `bang_at` is inside `"…"` / `'…'` / ` ``…'' ` /
/// `(…)` / `[…]` / `{…}` and that wrapper closes later in `piece`.
fn wrap_closes_after_bang(piece: &str, bang_at: usize) -> bool {
let chars: Vec<(usize, char)> = piece.char_indices().collect();
let mut dq = false;
let mut sq = false;
let mut latex = 0i32;
let mut paren = 0i32;
let mut bracket = 0i32;
let mut brace = 0i32;
let mut i = 0;
while i < chars.len() {
let ch = chars[i].1;
let next = chars.get(i + 1).map(|c| c.1);
if ch == '`' && next == Some('`') {
latex += 1;
i += 2;
} else if ch == '\'' && next == Some('\'') {
latex = (latex - 1).max(0);
i += 2;
} else {
match ch {
'"' => dq = !dq,
'\'' => sq = !sq,
'(' => paren += 1,
')' => paren -= 1,
'[' => bracket += 1,
']' => bracket -= 1,
'{' => brace += 1,
'}' => brace -= 1,
_ => {}
}
i += 1;
}
let consumed_to = chars.get(i).map_or(piece.len(), |c| c.0);
if consumed_to == bang_at {
let inside = dq || sq || latex > 0 || paren > 0 || bracket > 0 || brace > 0;
if !inside {
return false;
}
}
}
!dq && !sq && latex <= 0 && paren <= 0 && bracket <= 0 && brace <= 0
}

fn merge_splits_inside_delimiters(segments: Vec<String>) -> Vec<String> {
let mut result: Vec<String> = Vec::with_capacity(segments.len());
let mut state = DelimState::default();
Expand Down Expand Up @@ -3572,6 +3658,15 @@ mod tests {
UnicodeSentenceSplitter::new().split(text)
}

#[test]
fn single_quote_bang_capital_stays_one_sentence() {
assert_eq!(
split("'!!A'A"),
vec!["'!!A'A".to_string()],
"UAX/refine must keep balanced '!!A' as one sentence"
);
}

#[test]
fn simple_sentences() {
assert_eq!(
Expand Down
30 changes: 30 additions & 0 deletions tests/md_compact_dl_terms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,36 @@ fn leftover_loose_second_dl_term_after_blank() {
assert_eq!(format_text(&out, &md_cfg()).unwrap(), out);
}

#[test]
fn leftover_loose_extra_definition_after_blank() {
let input = concat!(
"Term\n",
": First definition sentence. Second sentence.\n",
"\n",
": Third definition sentence. Fourth sentence.\n",
"\n",
"After the list. Next.\n",
);
let out = format_text(input, &md_cfg()).unwrap();
assert!(
!out.contains("Third definition sentence. Fourth sentence."),
"second dd body must still split, got:\n{out}"
);
assert!(
out.contains("Fourth sentence."),
"fourth sentence must stay, got:\n{out}"
);
assert!(
!out.lines().any(|l| l == "Fourth sentence."),
"fourth sentence must stay hung, got:\n{out}"
);
assert!(
out.contains("After the list.\nNext."),
"following prose must still split, got:\n{out}"
);
assert_eq!(format_text(&out, &md_cfg()).unwrap(), out);
}

#[test]
fn leftover_list_item_second_compact_dl_term_after_definition() {
let input = concat!(
Expand Down
34 changes: 34 additions & 0 deletions tests/md_link_ref_footnotes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,40 @@ fn ticket_fixture() -> &'static str {
)
}

#[test]
fn leftover_footnote_fence_continuation_is_code() {
let input = concat!(
"[^1]: First sentence. Second sentence.\n",
"\n",
" ```\n",
" code. yes\n",
" ```\n",
"\n",
"After the note. Next.\n",
);
let regions = MarkdownParser.parse(input);
assert!(
regions.iter().any(|r| matches!(
r,
Region::Code { body, .. } if body.contains("code. yes")
)),
"indented fence in a footnote must be Code, got {regions:?}"
);
let out = format_text(input, &md_cfg()).unwrap();
assert!(
out.contains("code. yes"),
"fence body must stay one line, got:\n{out}"
);
assert!(
!out.contains("code.\nyes"),
"must not SemBr-split inside the fence, got:\n{out}"
);
assert!(
out.contains("After the note.\nNext."),
"following prose must still split, got:\n{out}"
);
}

#[test]
fn leftover_footnote_col0_after_continuation_is_new_paragraph() {
let input = concat!(
Expand Down
72 changes: 72 additions & 0 deletions tests/sentence_delim_props.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,78 @@ fn plaintext_cfg() -> FormatConfig {

/// PR #52 CI seed: period before backticks, then quote-backtick.
/// Markup closer-split must not invent a newline inside a DelimState span.
#[test]
fn plaintext_setext_looking_bang_letter_is_idempotent() {
let input = ".=A[=!!a";
let out = format_plain(input);
assert_eq!(
format_plain(&out),
out,
"idempotence\n in={input:?}\n out={out:?}"
);
}

/// Quoted `!!a` is one DelimState span. Splitting `!!` then the letter
/// parks a newline inside the quotes and fails proptest_balanced_wrappers.
#[test]
fn plaintext_single_quoted_bang_capital_is_span_safe() {
let input = "'!!A'A";
let out = format_plain(input);
assert_eq!(
format_plain(&out),
out,
"idempotence\n in={input:?}\n out={out:?}"
);
assert!(
newlines_respect_delimiter_spans(&out),
"span newline\n in={input:?}\n out={out:?}"
);
assert!(
!out.contains("!!\n"),
"must not split !!A inside quotes\n in={input:?}\n out={out:?}"
);
}

/// Quoted `!!a` is one DelimState span. Splitting `!!` then the letter
/// parks a newline inside the quotes and fails proptest_balanced_wrappers.
#[test]
fn plaintext_latex_quoted_bang_letter_is_span_safe() {
let input = "``!!a''";
let out = format_plain(input);
assert_eq!(
format_plain(&out),
out,
"idempotence\n in={input:?}\n out={out:?}"
);
assert!(
newlines_respect_delimiter_spans(&out),
"span newline\n in={input:?}\n out={out:?}"
);
assert!(
!out.contains("!!\n"),
"must not split !!a inside latex quotes\n in={input:?}\n out={out:?}"
);
}

#[test]
fn plaintext_quoted_bang_letter_is_span_safe() {
let input = "\"!!a\"";
let out = format_plain(input);
assert_eq!(
format_plain(&out),
out,
"idempotence\n in={input:?}\n out={out:?}"
);
assert!(
newlines_respect_delimiter_spans(&out),
"span newline\n in={input:?}\n out={out:?}"
);
assert!(
!out.contains("!!\n"),
"must not split !!a inside quotes\n in={input:?}\n out={out:?}"
);
}

#[test]
fn plaintext_period_before_backticks_quote_is_span_safe() {
let input = ".`` \"`";
Expand Down
Loading