Skip to content

Commit 722fad6

Browse files
committed
fix(md013): stop exempting a complete link followed by a parenthesized aside
The spaces-in-destination fallback matched any line that starts with a link or image and ends with one balanced parenthesized group, so a common list entry such as `- [ripgrep](https://github.com/BurntSushi/ripgrep) (a line-oriented search tool)` became exempt from the line-length check and `rumdl fmt` stopped wrapping it. Two conditions separate a destination from prose. A destination holding a space does not parse as an inline link at all: the link text degrades to an empty-url shortcut, which is the state the fallback exists for, while a complete link carries its own url. And CommonMark forbids whitespace between `]` and `(`, so a group separated by a space cannot be a destination. Follow-up to #781.
1 parent cb65237 commit 722fad6

2 files changed

Lines changed: 84 additions & 7 deletions

File tree

src/rules/md013_line_length/helpers.rs

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -540,12 +540,15 @@ fn is_link_with_optional_emphasis(ctx: &LintContext, s: &str, s_offset: usize) -
540540
return true;
541541
}
542542

543-
// Fallback for links with spaces in destination (not parsed by pulldown-cmark as a single link)
543+
// Fallback for links with spaces in destination (not parsed by pulldown-cmark as a single
544+
// link). A destination holding a space leaves the link text parsed as an empty-url
545+
// shortcut, and that is what marks the trailing group as the destination rather than
546+
// prose following a complete link.
544547
if let Ok(idx) = ctx.links.binary_search_by_key(&s_start, |l| l.byte_offset) {
545548
let l = &ctx.links[idx];
546-
if l.byte_end < s_end {
549+
if l.byte_end < s_end && l.link_type == LinkType::Shortcut && l.url.is_empty() {
547550
let remaining = &s[l.byte_end - s_start..];
548-
if is_single_parenthesized_group(remaining) {
551+
if is_destination_group(remaining) {
549552
return true;
550553
}
551554
}
@@ -554,9 +557,9 @@ fn is_link_with_optional_emphasis(ctx: &LintContext, s: &str, s_offset: usize) -
554557
// Fallback for images with spaces in destination
555558
if let Ok(idx) = ctx.images.binary_search_by_key(&s_start, |i| i.byte_offset) {
556559
let i = &ctx.images[idx];
557-
if i.byte_end < s_end {
560+
if i.byte_end < s_end && i.link_type == LinkType::Shortcut && i.url.is_empty() {
558561
let remaining = &s[i.byte_end - s_start..];
559-
if is_single_parenthesized_group(remaining) {
562+
if is_destination_group(remaining) {
560563
return true;
561564
}
562565
}
@@ -647,8 +650,11 @@ fn is_link_with_optional_emphasis(ctx: &LintContext, s: &str, s_offset: usize) -
647650
false
648651
}
649652

650-
fn is_single_parenthesized_group(s: &str) -> bool {
651-
let s = s.trim();
653+
/// Whether `s` is the destination of the link text immediately before it: a single balanced
654+
/// parenthesized group opening on the very next byte. CommonMark forbids whitespace between
655+
/// `]` and `(`, so only the trailing side is trimmed.
656+
fn is_destination_group(s: &str) -> bool {
657+
let s = s.trim_end();
652658
if !s.starts_with('(') || !s.ends_with(')') {
653659
return false;
654660
}
@@ -1078,6 +1084,40 @@ mod tests {
10781084
assert!(!check_standalone("[link](url) extra text"));
10791085
}
10801086

1087+
#[test]
1088+
fn test_link_followed_by_parenthetical_is_not_standalone() {
1089+
// A complete link followed by a parenthesized aside is prose, not a destination.
1090+
// The whole line can be wrapped, so it is not exempt.
1091+
assert!(!check_standalone(
1092+
"- [ripgrep](https://github.com/BurntSushi/ripgrep) (a line-oriented search tool)"
1093+
));
1094+
assert!(!check_standalone(
1095+
"[the docs](https://example.com/d) (updated for 2026, including the new guide)"
1096+
));
1097+
assert!(!check_standalone(
1098+
"![screenshot](img/s.png) (captured on a retina display with the sidebar hidden)"
1099+
));
1100+
// An unresolved shortcut whose aside is separated by a space: the space rules out a
1101+
// destination, since CommonMark forbids one between `]` and `(`.
1102+
assert!(!check_standalone(
1103+
"[NOTE] (this applies only when the feature flag is enabled)"
1104+
));
1105+
// Attached, so no space rules it out. Here the link parsed with its own destination,
1106+
// which is what says the trailing group belongs to the prose.
1107+
assert!(!check_standalone(
1108+
"[the docs](https://example.com/d)(a parenthetical stuck onto the link)"
1109+
));
1110+
1111+
// The exemptions the fallback exists for still hold: a destination holding a space
1112+
// leaves the link text parsed as an empty-url shortcut.
1113+
assert!(check_standalone(
1114+
"![Placeholder Screenshot](images/1_<release number>/screenshot-main-window.png)"
1115+
));
1116+
assert!(check_standalone(
1117+
"* [Front Matter Defaults]({{ '/assets/img/very-long-image-name.png' | relative_url }})"
1118+
));
1119+
}
1120+
10811121
// --- is_html_only_line tests ---
10821122

10831123
#[test]

src/rules/md013_line_length/tests.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9678,3 +9678,40 @@ fn test_md013_link_with_nested_code_span_exemption() {
96789678
"Link with nested code and brackets should be exempt"
96799679
);
96809680
}
9681+
9682+
#[test]
9683+
fn test_md013_link_followed_by_parenthetical_is_not_exempt() {
9684+
let config = MD013Config {
9685+
line_length: crate::types::LineLength::from_const(80),
9686+
stern: true,
9687+
ignore_link_urls: false,
9688+
..Default::default()
9689+
};
9690+
let rule = MD013LineLength::from_config_struct(config);
9691+
9692+
// A complete link or image followed by a parenthesized aside is prose. The line can be
9693+
// wrapped, so the standalone exemption must not cover it.
9694+
let reported = [
9695+
"- [ripgrep](https://github.com/BurntSushi/ripgrep) (a line-oriented search tool that recursively searches)\n",
9696+
"[the docs](https://example.com/d) (updated for 2026, including the new configuration guide)\n",
9697+
"[NOTE] (this applies only when the feature flag is enabled and the server runs in cluster mode)\n",
9698+
"![screenshot](img/s.png) (captured on a retina display at 2x scaling with the sidebar hidden)\n",
9699+
"[the docs](https://example.com/d)(a parenthetical stuck right onto the end of the link here)\n",
9700+
];
9701+
for content in reported {
9702+
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
9703+
let result = rule.check(&ctx).unwrap();
9704+
assert_eq!(result.len(), 1, "line should be reported: {content:?}");
9705+
}
9706+
9707+
// A destination that genuinely holds a space still exempts the line.
9708+
let exempt = [
9709+
"![Placeholder Screenshot Here](images/1_<release number>/screenshot-main-window.png)\n",
9710+
"* [Front Matter Defaults]({{ '/assets/img/very-long-image-name.png' | relative_url }})\n",
9711+
];
9712+
for content in exempt {
9713+
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
9714+
let result = rule.check(&ctx).unwrap();
9715+
assert!(result.is_empty(), "line should stay exempt: {content:?}");
9716+
}
9717+
}

0 commit comments

Comments
 (0)