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:
@@ -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
|
/// 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.
|
/// 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 {
|
fn url_len(rest: &str, whole: bool) -> usize {
|
||||||
let mut depth = 0;
|
let mut depth = 0usize;
|
||||||
let mut gt_seen = false;
|
|
||||||
let mut end = rest.len();
|
let mut end = rest.len();
|
||||||
for (i, c) in rest.char_indices() {
|
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;
|
end = i;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if !whole && (c == '<' || c == '>' || c == '|' || c == '`') {
|
if c == '(' {
|
||||||
end = i;
|
depth += 1;
|
||||||
break;
|
} else if c == ')' {
|
||||||
}
|
depth -= 1;
|
||||||
match c {
|
|
||||||
'(' => depth += 1,
|
|
||||||
')' if depth == 0 => {
|
|
||||||
end = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
')' => depth -= 1,
|
|
||||||
'<' if depth == 0 && !gt_seen => {
|
|
||||||
end = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
'>' => gt_seen = true,
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
while let Some(last) = rest
|
rest.get(..end)
|
||||||
.get(..end)
|
.unwrap_or_default()
|
||||||
.and_then(|prefix| prefix.chars().next_back())
|
.trim_end_matches(TRAILING)
|
||||||
{
|
.len()
|
||||||
if !TRAILING.contains(&last) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
end -= last.len_utf8();
|
|
||||||
}
|
|
||||||
end
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
/// `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.
|
/// marked reads an unclosed fence as code only at times.
|
||||||
fn fence_end(lines: &[&str], at: usize) -> Option<usize> {
|
fn fence_end(lines: &[&str], at: usize) -> Option<usize> {
|
||||||
let (indent, run) = opener(lines[at])?;
|
let (indent, run) = opener(lines.get(at)?)?;
|
||||||
let mut i = at + 1;
|
for (i, line) in lines.iter().enumerate().skip(at + 1) {
|
||||||
while let Some(line) = lines.get(i) {
|
|
||||||
if closes(line, run) {
|
if closes(line, run) {
|
||||||
return Some(i);
|
return Some(i);
|
||||||
}
|
}
|
||||||
if !content(line).is_empty()
|
let text = content(line);
|
||||||
&& content(line).chars().take_while(|&c| c == ' ').count() < indent
|
if !text.is_empty() && text.len() - text.trim_start_matches(' ').len() < indent {
|
||||||
{
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
i += 1;
|
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -263,22 +260,14 @@ fn opener(line: &str) -> Option<(usize, &str)> {
|
|||||||
/// come only backticks and `~`, then only spaces, tabs or no-break spaces.
|
/// come only backticks and `~`, then only spaces, tabs or no-break spaces.
|
||||||
fn closes(line: &str, run: &str) -> bool {
|
fn closes(line: &str, run: &str) -> bool {
|
||||||
let text = content(line);
|
let text = content(line);
|
||||||
let leading = text
|
let rest = text.trim_start_matches([' ', '\u{a0}']);
|
||||||
.chars()
|
if text.chars().count() - rest.chars().count() > 3 {
|
||||||
.take_while(|&c| c == ' ' || c == '\u{a0}')
|
|
||||||
.count();
|
|
||||||
if leading > 3 {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let Some(after) = text.get(leading..) else {
|
let Some(tail) = rest.strip_prefix(run) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
if !after.starts_with(run) {
|
tail.trim_end_matches([' ', '\t', '\u{a0}'])
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let rest = after.get(run.len()..).unwrap_or("");
|
|
||||||
let after_ticks = rest.trim_start_matches(['`', '~']);
|
|
||||||
after_ticks
|
|
||||||
.chars()
|
.chars()
|
||||||
.all(|c| c == ' ' || c == '\t' || c == '\u{a0}')
|
.all(|c| c == '`' || c == '~')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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< now";
|
||||||
|
assert_eq!(
|
||||||
|
disarm(text, false).text,
|
||||||
|
"see `https://a.example/x<` now"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
disarm(text, true).text,
|
||||||
|
"see `https://a.example/x`< `` 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\n\u{a0}```\nafter";
|
||||||
|
assert_eq!(disarm(text, false).text, text);
|
||||||
|
assert_eq!(disarm(text, true).text, text);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user