gatewayd: links: url_len and closes as their comments say

M5b review, findings 1 and 2. url_len ended a clean reply's URL at any
`<` outside parentheses, not at a `<` before white space, a bracket,
`<`, `>` or the end; closes used a count of characters as a byte
offset, so a closer indented with a no-break space did not close its
fence. Both bodies, and fence_end's (an index replaced by `get`), are
the reference's again. tests/links_review.rs pins both cases with the
reference's output.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 19:08:42 -07:00
co-authored by Claude Opus 5.5
parent 27a6354a80
commit 27aeab48e1
3 changed files with 55 additions and 50 deletions
+17 -30
View File
@@ -439,41 +439,28 @@ fn scheme_less(rest: &str) -> bool {
/// out: it may start an autolink); unless `whole`, up to any `<`, `>`, `|` or backtick too. Then
/// less the characters of `TRAILING` at its end. `whole` is the most marked may read as the URL.
fn url_len(rest: &str, whole: bool) -> usize {
let mut depth = 0;
let mut gt_seen = false;
let mut depth = 0usize;
let mut end = rest.len();
for (i, c) in rest.char_indices() {
if c.is_whitespace() {
let next = rest.get(i + 1..).and_then(|after| after.chars().next());
let open = c == '<' && next.is_none_or(|n| n.is_whitespace() || "()<>".contains(n));
let part = if whole {
!open
} else {
!matches!(c, '<' | '>' | '|' | '`')
};
if c.is_whitespace() || !part || (c == ')' && depth == 0) {
end = i;
break;
}
if !whole && (c == '<' || c == '>' || c == '|' || c == '`') {
end = i;
break;
}
match c {
'(' => depth += 1,
')' if depth == 0 => {
end = i;
break;
}
')' => depth -= 1,
'<' if depth == 0 && !gt_seen => {
end = i;
break;
}
'>' => gt_seen = true,
_ => {}
if c == '(' {
depth += 1;
} else if c == ')' {
depth -= 1;
}
}
while let Some(last) = rest
.get(..end)
.and_then(|prefix| prefix.chars().next_back())
{
if !TRAILING.contains(&last) {
break;
}
end -= last.len_utf8();
}
end
rest.get(..end)
.unwrap_or_default()
.trim_end_matches(TRAILING)
.len()
}
+9 -20
View File
@@ -217,18 +217,15 @@ fn nested_start(line: &str) -> bool {
/// `None` (it leaves a list item in CommonMark, and the block with it). No closer: `None`, since
/// marked reads an unclosed fence as code only at times.
fn fence_end(lines: &[&str], at: usize) -> Option<usize> {
let (indent, run) = opener(lines[at])?;
let mut i = at + 1;
while let Some(line) = lines.get(i) {
let (indent, run) = opener(lines.get(at)?)?;
for (i, line) in lines.iter().enumerate().skip(at + 1) {
if closes(line, run) {
return Some(i);
}
if !content(line).is_empty()
&& content(line).chars().take_while(|&c| c == ' ').count() < indent
{
let text = content(line);
if !text.is_empty() && text.len() - text.trim_start_matches(' ').len() < indent {
return None;
}
i += 1;
}
None
}
@@ -263,22 +260,14 @@ fn opener(line: &str) -> Option<(usize, &str)> {
/// come only backticks and `~`, then only spaces, tabs or no-break spaces.
fn closes(line: &str, run: &str) -> bool {
let text = content(line);
let leading = text
.chars()
.take_while(|&c| c == ' ' || c == '\u{a0}')
.count();
if leading > 3 {
let rest = text.trim_start_matches([' ', '\u{a0}']);
if text.chars().count() - rest.chars().count() > 3 {
return false;
}
let Some(after) = text.get(leading..) else {
let Some(tail) = rest.strip_prefix(run) else {
return false;
};
if !after.starts_with(run) {
return false;
}
let rest = after.get(run.len()..).unwrap_or("");
let after_ticks = rest.trim_start_matches(['`', '~']);
after_ticks
tail.trim_end_matches([' ', '\t', '\u{a0}'])
.chars()
.all(|c| c == ' ' || c == '\t' || c == '\u{a0}')
.all(|c| c == '`' || c == '~')
}
+29
View File
@@ -0,0 +1,29 @@
//! Two cases the M5b review found where `links::disarm` did not follow the comments of its
//! helpers: a `<` inside a clean reply's URL, and a fence closed by a line indented with a no-break
//! space (M5b review, implementer log). Each expected text is the reference's.
use gatewayd::links::disarm;
#[test]
fn a_url_runs_on_past_a_less_than_sign_that_starts_no_autolink() {
// `url_len`: only a `<` before white space, a bracket, `<`, `>` or the end stops the URL of a
// clean reply, so the image after it is wrapped with the URL, whole.
let text = "see https://a.example/x<![i](https://e.example/d) now";
assert_eq!(
disarm(text, false).text,
"see `https://a.example/x<![i](https://e.example/d)` now"
);
assert_eq!(
disarm(text, true).text,
"see `https://a.example/x`< `![i](https://e.example/d)` now"
);
}
#[test]
fn a_closer_indented_with_a_no_break_space_closes_the_fence() {
// `closes`: up to three spaces or no-break spaces before the closing run, counted in
// characters; the no-break space is two bytes.
let text = "```\n![i](https://e.example/d)\n\u{a0}```\nafter";
assert_eq!(disarm(text, false).text, text);
assert_eq!(disarm(text, true).text, text);
}