Repository: kivikakk/comrak (rust)

Issue:
`sourcepos` not correct for inline code
It seems that the `sourcepos` for inline code is slightly off. It doesn't seem to take into account the surrounding backticks.

<table>
<tr>
<th>Case</th>
<th>Markdown</th>
<th>Sourcemap</th>
</tr>
<tr>
<td>Bold</td>
<td>

```md
**bold**
```
</td>
<td>

```xml
<p dir="auto" data-sourcepos="1:1-1:8">
  <strong data-sourcepos="1:1-1:8">bold</strong>
</p>
```
</td>
</tr>
<tr>
<td>Italic</td>
<td>

```md
_italic_
```
</td>
<td>

```xml
<p dir="auto" data-sourcepos="1:1-1:8">
  <em data-sourcepos="1:1-1:8">italic</em>
</p>
```
</td>
</tr>
<tr>
<td>Code (current)</td>
<td>

```md
`code`
```
</td>
<td>

```xml
<p dir="auto" data-sourcepos="1:1-1:6">
  <code data-sourcepos="1:2-1:5">code</code>
</p>
```
</td>
</tr>
<tr>
<td>Code (expected)</td>
<td>

```md
`code`
```
</td>
<td>

```xml
<p dir="auto" data-sourcepos="1:1-1:6">
  <code data-sourcepos="1:1-1:6">code</code>
</p>
```
</td>
</tr>
<tr>
<td>Nested backticks (current)</td>
<td>

```md
`` `nested backticks` ``
```
</td>
<td>

```xml
<p dir="auto" data-sourcepos="1:1-1:24">
  <code data-sourcepos="1:3-1:22">`nested backticks`</code>
</p>
```
</td>
</tr>
<tr>
<td>Nested backticks (expected)</td>
<td>

```md
`` `nested backticks` ``
```
</td>
<td>

```xml
<p dir="auto" data-sourcepos="1:1-1:24">
  <code data-sourcepos="1:1-1:24">`nested backticks`</code>
</p>
```
</td>
</tr>
</table>

This assumes that the sourcepos of an inline such as strong or emphasis, or inline code, should include the surrounding syntax characters, the backticks in the case of inline code.


Discussion:

Patch:
diff --git a/flake.nix b/flake.nix
--- a/flake.nix
+++ b/flake.nix
@@ -114,6 +114,8 @@
       formatter = pkgs.alejandra;
 
       devShells.default = pkgs.mkShell {
+        name = "comrak";
+
         inputsFrom = builtins.attrValues self.checks.${system};
 
         nativeBuildInputs = [
diff --git a/src/parser/autolink.rs b/src/parser/autolink.rs
--- a/src/parser/autolink.rs
+++ b/src/parser/autolink.rs
@@ -1,18 +1,18 @@
 use crate::character_set::character_set;
 use crate::ctype::{isalnum, isalpha, isspace};
-use crate::nodes::{AstNode, NodeLink, NodeValue};
-use crate::parser::inlines::make_inline;
+use crate::nodes::{AstNode, NodeLink, NodeValue, Sourcepos};
+use crate::parser::{inlines::make_inline, Spx};
 use std::str;
 use typed_arena::Arena;
 use unicode_categories::UnicodeCategories;
 
-// TODO: this can probably be cleaned up a lot. It used to handle all three of
-// {url,www,email}_match, but now just the last of those.
-pub(crate) fn process_autolinks<'a>(
+pub(crate) fn process_email_autolinks<'a>(
     arena: &'a Arena<AstNode<'a>>,
     node: &'a AstNode<'a>,
     contents_str: &mut String,
     relaxed_autolinks: bool,
+    sourcepos: &mut Sourcepos,
+    spx: &mut Spx,
 ) {
     let contents = contents_str.as_bytes();
     let len = contents.len();
@@ -53,20 +53,177 @@ pub(crate) fn process_autolinks<'a>(
         if let Some((post, reverse, skip)) = post_org {
             i -= reverse;
             node.insert_after(post);
-            if i + skip < len {
+
+            let remain = if i + skip < len {
                 let remain = str::from_utf8(&contents[i + skip..]).unwrap();
                 assert!(!remain.is_empty());
-                post.insert_after(make_inline(
+                Some(remain.to_string())
+            } else {
+                None
+            };
+            let initial_end_col = sourcepos.end.column;
+
+            sourcepos.end.column = spx.consume(i);
+
+            let nsp_end_col = spx.consume(skip);
+
+            contents_str.truncate(i);
+
+            let nsp: Sourcepos = (
+                sourcepos.end.line,
+                sourcepos.end.column + 1,
+                sourcepos.end.line,
+                nsp_end_col,
+            )
+                .into();
+            post.data.borrow_mut().sourcepos = nsp;
+            // Inner text gets same sourcepos as link, since there's nothing but
+            // the text.
+            post.first_child().unwrap().data.borrow_mut().sourcepos = nsp;
+
+            if let Some(remain) = remain {
+                let mut asp: Sourcepos = (
+                    sourcepos.end.line,
+                    nsp.end.column + 1,
+                    sourcepos.end.line,
+                    initial_end_col,
+                )
+                    .into();
+                let after = make_inline(arena, NodeValue::Text(remain.to_string()), asp);
+                post.insert_after(after);
+
+                let after_ast = &mut after.data.borrow_mut();
+                process_email_autolinks(
                     arena,
-                    NodeValue::Text(remain.to_string()),
-                    (0, 1, 0, 1).into(),
-                ));
+                    after,
+                    match after_ast.value {
+                        NodeValue::Text(ref mut t) => t,
+                        _ => unreachable!(),
+                    },
+                    relaxed_autolinks,
+                    &mut asp,
+                    spx,
+                );
+                after_ast.sourcepos = asp;
             }
-            contents_str.truncate(i);
+
             return;
         }
     }
 }
+fn email_match<'a>(
+    arena: &'a Arena<AstNode<'a>>,
+    contents: &[u8],
+    i: usize,
+    relaxed_autolinks: bool,
+) -> Option<(&'a AstNode<'a>, usize, usize)> {
+    const EMAIL_OK_SET: [bool; 256] = character_set!(b".+-_");
+
+    let size = contents.len();
+
+    let mut auto_mailto = true;
+    let mut is_xmpp = false;
+    let mut rewind = 0;
+
+    while rewind < i {
+        let c = contents[i - rewind - 1];
+
+        if isalnum(c) || EMAIL_OK_SET[c as usize] {
+            rewind += 1;
+            continue;
+        }
+
+        if c == b':' {
+            if validate_protocol("mailto", contents, i - rewind - 1) {
+                auto_mailto = false;
+                rewind += 1;
+                continue;
+            }
+
+            if validate_protocol("xmpp", contents, i - rewind - 1) {
+                is_xmpp = true;
+                auto_mailto = false;
+                rewind += 1;
+                continue;
+            }
+        }
+
+        break;
+    }
+
+    if rewind == 0 {
+        return None;
+    }
+
+    let mut link_end = 1;
+    let mut np = 0;
+
+    while link_end < size - i {
+        let c = contents[i + link_end];
+
+        if isalnum(c) {
+            // empty
+        } else if c == b'@' {
+            return None;
+        } else if c == b'.' && link_end < size - i - 1 && isalnum(contents[i + link_end + 1]) {
+            np += 1;
+        } else if c == b'/' && is_xmpp {
+            // xmpp allows a `/` in the url
+        } else if c != b'-' && c != b'_' {
+            break;
+        }
+
+        link_end += 1;
+    }
+
+    if link_end < 2
+        || np == 0
+        || (!isalpha(contents[i + link_end - 1]) && contents[i + link_end - 1] != b'.')
+    {
+        return None;
+    }
+
+    link_end = autolink_delim(&contents[i..], link_end, relaxed_autolinks);
+    if link_end == 0 {
+        return None;
+    }
+
+    let mut url = if auto_mailto {
+        "mailto:".to_string()
+    } else {
+        "".to_string()
+    };
+    let text = str::from_utf8(&contents[i - rewind..link_end + i]).unwrap();
+    url.push_str(text);
+
+    let inl = make_inline(
+        arena,
+        NodeValue::Link(NodeLink {
+            url,
+            title: String::new(),
+        }),
+        (0, 1, 0, 1).into(),
+    );
+
+    inl.append(make_inline(
+        arena,
+        NodeValue::Text(text.to_string()),
+        (0, 1, 0, 1).into(),
+    ));
+    Some((inl, rewind, rewind + link_end))
+}
+
+fn validate_protocol(protocol: &str, contents: &[u8], cursor: usize) -> bool {
+    let size = contents.len();
+    let mut rewind = 0;
+
+    while rewind < cursor && isalpha(contents[cursor - rewind - 1]) {
+        rewind += 1;
+    }
+
+    size - cursor + rewind >= protocol.len()
+        && &contents[cursor - rewind..cursor] == protocol.as_bytes()
+}
 
 pub fn www_match<'a>(
     arena: &'a Arena<AstNode<'a>>,
@@ -292,117 +449,3 @@ pub fn url_match<'a>(
     ));
     Some((inl, rewind, rewind + link_end))
 }
-
-fn email_match<'a>(
-    arena: &'a Arena<AstNode<'a>>,
-    contents: &[u8],
-    i: usize,
-    relaxed_autolinks: bool,
-) -> Option<(&'a AstNode<'a>, usize, usize)> {
-    const EMAIL_OK_SET: [bool; 256] = character_set!(b".+-_");
-
-    let size = contents.len();
-
-    let mut auto_mailto = true;
-    let mut is_xmpp = false;
-    let mut rewind = 0;
-
-    while rewind < i {
-        let c = contents[i - rewind - 1];
-
-        if isalnum(c) || EMAIL_OK_SET[c as usize] {
-            rewind += 1;
-            continue;
-        }
-
-        if c == b':' {
-            if validate_protocol("mailto", contents, i - rewind - 1) {
-                auto_mailto = false;
-                rewind += 1;
-                continue;
-            }
-
-            if validate_protocol("xmpp", contents, i - rewind - 1) {
-                is_xmpp = true;
-                auto_mailto = false;
-                rewind += 1;
-                continue;
-            }
-        }
-
-        break;
-    }
-
-    if rewind == 0 {
-        return None;
-    }
-
-    let mut link_end = 1;
-    let mut np = 0;
-
-    while link_end < size - i {
-        let c = contents[i + link_end];
-
-        if isalnum(c) {
-            // empty
-        } else if c == b'@' {
-            return None;
-        } else if c == b'.' && link_end < size - i - 1 && isalnum(contents[i + link_end + 1]) {
-            np += 1;
-        } else if c == b'/' && is_xmpp {
-            // xmpp allows a `/` in the url
-        } else if c != b'-' && c != b'_' {
-            break;
-        }
-
-        link_end += 1;
-    }
-
-    if link_end < 2
-        || np == 0
-        || (!isalpha(contents[i + link_end - 1]) && contents[i + link_end - 1] != b'.')
-    {
-        return None;
-    }
-
-    link_end = autolink_delim(&contents[i..], link_end, relaxed_autolinks);
-    if link_end == 0 {
-        return None;
-    }
-
-    let mut url = if auto_mailto {
-        "mailto:".to_string()
-    } else {
-        "".to_string()
-    };
-    let text = str::from_utf8(&contents[i - rewind..link_end + i]).unwrap();
-    url.push_str(text);
-
-    let inl = make_inline(
-        arena,
-        NodeValue::Link(NodeLink {
-            url,
-            title: String::new(),
-        }),
-        (0, 1, 0, 1).into(),
-    );
-
-    inl.append(make_inline(
-        arena,
-        NodeValue::Text(text.to_string()),
-        (0, 1, 0, 1).into(),
-    ));
-    Some((inl, rewind, rewind + link_end))
-}
-
-fn validate_protocol(protocol: &str, contents: &[u8], cursor: usize) -> bool {
-    let size = contents.len();
-    let mut rewind = 0;
-
-    while rewind < cursor && isalpha(contents[cursor - rewind - 1]) {
-        rewind += 1;
-    }
-
-    size - cursor + rewind >= protocol.len()
-        && &contents[cursor - rewind..cursor] == protocol.as_bytes()
-}
diff --git a/src/parser/inlines.rs b/src/parser/inlines.rs
--- a/src/parser/inlines.rs
+++ b/src/parser/inlines.rs
@@ -99,6 +99,21 @@ pub struct Delimiter<'a: 'd, 'd> {
     next: Cell<Option<&'d Delimiter<'a, 'd>>>,
 }
 
+impl<'a: 'd, 'd> std::fmt::Debug for Delimiter<'a, 'd> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(
+            f,
+            "[pos {}, len {}, delim_char {:?}, open? {} close? {} -- {}]",
+            self.position,
+            self.length,
+            self.delim_char,
+            self.can_open,
+            self.can_close,
+            self.inl.data.borrow().sourcepos
+        )
+    }
+}
+
 struct Bracket<'a> {
     inl_text: &'a AstNode<'a>,
     position: usize,
@@ -191,10 +206,10 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
         let new_inl: Option<&'a AstNode<'a>> = match c {
             '\0' => return false,
             '\r' | '\n' => Some(self.handle_newline()),
-            '`' => Some(self.handle_backticks()),
+            '`' => Some(self.handle_backticks(&node_ast.line_offsets)),
             '\\' => Some(self.handle_backslash()),
             '&' => Some(self.handle_entity()),
-            '<' => Some(self.handle_pointy_brace()),
+            '<' => Some(self.handle_pointy_brace(&node_ast.line_offsets)),
             ':' => {
                 let mut res = None;
 
@@ -288,19 +303,19 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
             '^' if self.options.extension.superscript && !self.within_brackets => {
                 Some(self.handle_delim(b'^'))
             }
-            '$' => Some(self.handle_dollars()),
+            '$' => Some(self.handle_dollars(&node_ast.line_offsets)),
             '|' if self.options.extension.spoiler => Some(self.handle_delim(b'|')),
             _ => {
-                let endpos = self.find_special_char();
+                let mut endpos = self.find_special_char();
                 let mut contents = self.input[self.pos..endpos].to_vec();
-                let startpos = self.pos;
+                let mut startpos = self.pos;
                 self.pos = endpos;
 
                 if self
                     .peek_char()
                     .map_or(false, |&c| strings::is_line_end_char(c))
                 {
-                    strings::rtrim(&mut contents);
+                    endpos -= strings::rtrim(&mut contents);
                 }
 
                 // if we've just produced a LineBreak, then we should consume any leading
@@ -308,7 +323,9 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
                 if node.last_child().map_or(false, |n| {
                     matches!(n.data.borrow().value, NodeValue::LineBreak)
                 }) {
-                    strings::ltrim(&mut contents);
+                    // TODO: test this more explicitly.
+                    let n = strings::ltrim(&mut contents);
+                    startpos += n;
                 }
 
                 Some(self.make_inline(
@@ -566,7 +583,7 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
     }
 
     #[inline]
-    pub fn eof(&self) -> bool {
+    fn eof(&self) -> bool {
         self.pos >= self.input.len()
     }
 
@@ -586,7 +603,7 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
         }
     }
 
-    pub fn find_special_char(&self) -> usize {
+    fn find_special_char(&self) -> usize {
         for n in self.pos..self.input.len() {
             if self.special_chars[self.input[n] as usize] {
                 if self.input[n] == b'^' && self.within_brackets {
@@ -603,11 +620,13 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
         self.input.len()
     }
 
-    fn adjust_node_newlines(&mut self, node: &'a AstNode<'a>, matchlen: usize, extra: usize) {
-        if !self.options.render.sourcepos {
-            return;
-        }
-
+    fn adjust_node_newlines(
+        &mut self,
+        node: &'a AstNode<'a>,
+        matchlen: usize,
+        extra: usize,
+        parent_line_offsets: &[usize],
+    ) {
         let (newlines, since_newline) =
             count_newlines(&self.input[self.pos - matchlen - extra..self.pos - extra]);
 
@@ -615,12 +634,14 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
             self.line += newlines;
             let node_ast = &mut node.data.borrow_mut();
             node_ast.sourcepos.end.line += newlines;
-            node_ast.sourcepos.end.column = since_newline;
+            let adjusted_line = self.line - node_ast.sourcepos.start.line;
+            node_ast.sourcepos.end.column =
+                parent_line_offsets[adjusted_line] + since_newline + extra;
             self.column_offset = -(self.pos as isize) + since_newline as isize + extra as isize;
         }
     }
 
-    pub fn handle_newline(&mut self) -> &'a AstNode<'a> {
+    fn handle_newline(&mut self) -> &'a AstNode<'a> {
         let nlpos = self.pos;
         if self.input[self.pos] == b'\r' {
             self.pos += 1;
@@ -629,7 +650,7 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
             self.pos += 1;
         }
         let inl = if nlpos > 1 && self.input[nlpos - 1] == b' ' && self.input[nlpos - 2] == b' ' {
-            self.make_inline(NodeValue::LineBreak, nlpos, self.pos - 1)
+            self.make_inline(NodeValue::LineBreak, nlpos - 2, self.pos - 1)
         } else {
             self.make_inline(NodeValue::SoftBreak, nlpos, self.pos - 1)
         };
@@ -639,7 +660,7 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
         inl
     }
 
-    pub fn take_while(&mut self, c: u8) -> usize {
+    fn take_while(&mut self, c: u8) -> usize {
         let start_pos = self.pos;
         while self.peek_char() == Some(&c) {
             self.pos += 1;
@@ -647,7 +668,7 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
         self.pos - start_pos
     }
 
-    pub fn take_while_with_limit(&mut self, c: u8, limit: usize) -> usize {
+    fn take_while_with_limit(&mut self, c: u8, limit: usize) -> usize {
         let start_pos = self.pos;
         let mut count = 0;
         while count < limit && self.peek_char() == Some(&c) {
@@ -657,7 +678,7 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
         self.pos - start_pos
     }
 
-    pub fn scan_to_closing_backtick(&mut self, openticklength: usize) -> Option<usize> {
+    fn scan_to_closing_backtick(&mut self, openticklength: usize) -> Option<usize> {
         if openticklength > MAXBACKTICKS {
             return None;
         }
@@ -684,33 +705,41 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
         }
     }
 
-    pub fn handle_backticks(&mut self) -> &'a AstNode<'a> {
-        let openticks = self.take_while(b'`');
+    fn handle_backticks(&mut self, parent_line_offsets: &[usize]) -> &'a AstNode<'a> {
         let startpos = self.pos;
+        let openticks = self.take_while(b'`');
         let endpos = self.scan_to_closing_backtick(openticks);
 
         match endpos {
             None => {
-                self.pos = startpos;
-                self.make_inline(NodeValue::Text("`".repeat(openticks)), self.pos, self.pos)
+                self.pos = startpos + openticks;
+                self.make_inline(
+                    NodeValue::Text("`".repeat(openticks)),
+                    startpos,
+                    self.pos - 1,
+                )
             }
             Some(endpos) => {
-                let buf = &self.input[startpos..endpos - openticks];
+                let buf = &self.input[startpos + openticks..endpos - openticks];
                 let buf = strings::normalize_code(buf);
                 let code = NodeCode {
                     num_backticks: openticks,
                     literal: String::from_utf8(buf).unwrap(),
                 };
-                let node =
-                    self.make_inline(NodeValue::Code(code), startpos, endpos - openticks - 1);
-                self.adjust_node_newlines(node, endpos - startpos, openticks);
+                let node = self.make_inline(NodeValue::Code(code), startpos, endpos - 1);
+                self.adjust_node_newlines(
+                    node,
+                    endpos - startpos - openticks,
+                    openticks,
+                    parent_line_offsets,
+                );
                 node
             }
         }
     }
 
-    pub fn scan_to_closing_dollar(&mut self, opendollarlength: usize) -> Option<usize> {
-        if !(self.options.extension.math_dollars) || opendollarlength > MAX_MATH_DOLLARS {
+    fn scan_to_closing_dollar(&mut self, opendollarlength: usize) -> Option<usize> {
+        if !self.options.extension.math_dollars || opendollarlength > MAX_MATH_DOLLARS {
             return None;
         }
 
@@ -728,17 +757,15 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
                 return None;
             }
 
+            let c = self.input[self.pos - 1];
+
             // space not allowed before ending $
-            if opendollarlength == 1 {
-                let c = self.input[self.pos - 1];
-                if isspace(c) {
-                    return None;
-                }
+            if opendollarlength == 1 && isspace(c) {
+                return None;
             }
 
             // dollar signs must also be backslash-escaped if they occur within math
-            let c = self.input[self.pos - 1];
-            if opendollarlength == 1 && c == (b'\\') {
+            if opendollarlength == 1 && c == b'\\' {
                 self.pos += 1;
                 continue;
             }
@@ -756,10 +783,8 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
         }
     }
 
-    pub fn scan_to_closing_code_dollar(&mut self) -> Option<usize> {
-        if !self.options.extension.math_code {
-            return None;
-        }
+    fn scan_to_closing_code_dollar(&mut self) -> Option<usize> {
+        assert!(self.options.extension.math_code);
 
         loop {
             while self.peek_char().map_or(false, |&c| c != b'$') {
@@ -771,91 +796,70 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
             }
 
             let c = self.input[self.pos - 1];
+            self.pos += 1;
             if c == b'`' {
-                self.pos += 1;
                 return Some(self.pos);
-            } else {
-                self.pos += 1;
             }
         }
     }
 
     // Heuristics used from https://pandoc.org/MANUAL.html#extension-tex_math_dollars
-    pub fn handle_dollars(&mut self) -> &'a AstNode<'a> {
-        if self.options.extension.math_dollars || self.options.extension.math_code {
-            let opendollars = self.take_while(b'$');
-            let mut code_math = false;
-
-            // check for code math
-            if opendollars == 1
-                && self.options.extension.math_code
-                && self.peek_char().map_or(false, |&c| c == b'`')
-            {
-                code_math = true;
-                self.pos += 1;
-            }
+    fn handle_dollars(&mut self, parent_line_offsets: &[usize]) -> &'a AstNode<'a> {
+        if !(self.options.extension.math_dollars || self.options.extension.math_code) {
+            self.pos += 1;
+            return self.make_inline(NodeValue::Text("$".to_string()), self.pos - 1, self.pos - 1);
+        }
+        let startpos = self.pos;
+        let opendollars = self.take_while(b'$');
+        let mut code_math = false;
 
-            let startpos = self.pos;
-            let endpos: Option<usize> = if code_math {
-                self.scan_to_closing_code_dollar()
+        // check for code math
+        if opendollars == 1
+            && self.options.extension.math_code
+            && self.peek_char().map_or(false, |&c| c == b'`')
+        {
+            code_math = true;
+            self.pos += 1;
+        }
+        let fence_length = if code_math { 2 } else { opendollars };
+
+        let endpos: Option<usize> = if code_math {
+            self.scan_to_closing_code_dollar()
+        } else {
+            self.scan_to_closing_dollar(opendollars)
+        }
+        .filter(|endpos| endpos - startpos >= fence_length * 2 + 1);
+
+        if let Some(endpos) = endpos {
+            let buf = &self.input[startpos + fence_length..endpos - fence_length];
+            let buf: Vec<u8> = if code_math || opendollars == 1 {
+                strings::normalize_code(buf)
             } else {
-                self.scan_to_closing_dollar(opendollars)
+                buf.to_vec()
             };
-
-            let fence_length = if code_math { 2 } else { opendollars };
-            let endpos: Option<usize> = match endpos {
-                Some(epos) => {
-                    if epos - startpos + fence_length < fence_length * 2 + 1 {
-                        None
-                    } else {
-                        endpos
-                    }
-                }
-                None => endpos,
+            let math = NodeMath {
+                dollar_math: !code_math,
+                display_math: opendollars == 2,
+                literal: String::from_utf8(buf).unwrap(),
             };
-
-            match endpos {
-                None => {
-                    if code_math {
-                        self.pos = startpos - 1;
-                        self.make_inline(
-                            NodeValue::Text("$".to_string()),
-                            self.pos - 1,
-                            self.pos - 1,
-                        )
-                    } else {
-                        self.pos = startpos;
-                        self.make_inline(
-                            NodeValue::Text("$".repeat(opendollars)),
-                            self.pos,
-                            self.pos,
-                        )
-                    }
-                }
-                Some(endpos) => {
-                    let buf = &self.input[startpos..endpos - fence_length];
-                    let buf: Vec<u8> = if code_math || opendollars == 1 {
-                        strings::normalize_code(buf)
-                    } else {
-                        buf.to_vec()
-                    };
-                    let math = NodeMath {
-                        dollar_math: !code_math,
-                        display_math: opendollars == 2,
-                        literal: String::from_utf8(buf).unwrap(),
-                    };
-                    let node = self.make_inline(
-                        NodeValue::Math(math),
-                        startpos,
-                        endpos - fence_length - 1,
-                    );
-                    self.adjust_node_newlines(node, endpos - startpos, fence_length);
-                    node
-                }
-            }
-        } else {
-            self.pos += 1;
+            let node = self.make_inline(NodeValue::Math(math), startpos, endpos - 1);
+            self.adjust_node_newlines(
+                node,
+                endpos - startpos - fence_length,
+                fence_length,
+                parent_line_offsets,
+            );
+            node
+        } else if code_math {
+            self.pos = startpos + 1;
             self.make_inline(NodeValue::Text("$".to_string()), self.pos - 1, self.pos - 1)
+        } else {
+            self.pos = startpos + fence_length;
+            self.make_inline(
+                NodeValue::Text("$".repeat(opendollars)),
+                self.pos - fence_length,
+                self.pos - 1,
+            )
         }
     }
 
@@ -868,7 +872,7 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
         skipped
     }
 
-    pub fn handle_delim(&mut self, c: u8) -> &'a AstNode<'a> {
+    fn handle_delim(&mut self, c: u8) -> &'a AstNode<'a> {
         let (numdelims, can_open, can_close) = self.scan_delims(c);
 
         let contents = if c == b'\'' && self.options.parse.smart {
@@ -897,7 +901,7 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
         inl
     }
 
-    pub fn handle_hyphen(&mut self) -> &'a AstNode<'a> {
+    fn handle_hyphen(&mut self) -> &'a AstNode<'a> {
         let start = self.pos;
         self.pos += 1;
 
@@ -930,7 +934,7 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
         self.make_inline(NodeValue::Text(buf), start, self.pos - 1)
     }
 
-    pub fn handle_period(&mut self) -> &'a AstNode<'a> {
+    fn handle_period(&mut self) -> &'a AstNode<'a> {
         self.pos += 1;
         if self.options.parse.smart && self.peek_char().map_or(false, |&c| c == b'.') {
             self.pos += 1;
@@ -949,7 +953,7 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
         }
     }
 
-    pub fn scan_delims(&mut self, c: u8) -> (usize, bool, bool) {
+    fn scan_delims(&mut self, c: u8) -> (usize, bool, bool) {
         let before_char = if self.pos == 0 {
             '\n'
         } else {
@@ -1043,7 +1047,7 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
         }
     }
 
-    pub fn push_delimiter(&mut self, c: u8, can_open: bool, can_close: bool, inl: &'a AstNode<'a>) {
+    fn push_delimiter(&mut self, c: u8, can_open: bool, can_close: bool, inl: &'a AstNode<'a>) {
         let d = self.delimiter_arena.alloc(Delimiter {
             prev: Cell::new(self.last_delimiter),
             next: Cell::new(None),
@@ -1065,7 +1069,7 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
     //
     // As a side-effect, handle long "***" and "___" nodes by truncating them in
     // place to be re-matched by `process_emphasis`.
-    pub fn insert_emph(
+    fn insert_emph(
         &mut self,
         opener: &'d Delimiter<'a, 'd>,
         closer: &'d Delimiter<'a, 'd>,
@@ -1147,22 +1151,14 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
             self.pos,
             self.pos,
         );
-        {
-            // if we have `___` or `***` then we need to adjust the sourcepos colums by 1
-            let triple_adjustment = if opener_num_chars > 0 && use_delims == 2 {
-                1
-            } else {
-                0
-            };
 
-            emph.data.borrow_mut().sourcepos = (
-                opener.inl.data.borrow().sourcepos.start.line,
-                opener.inl.data.borrow().sourcepos.start.column + triple_adjustment,
-                closer.inl.data.borrow().sourcepos.end.line,
-                closer.inl.data.borrow().sourcepos.end.column - triple_adjustment,
-            )
-                .into();
-        }
+        emph.data.borrow_mut().sourcepos = (
+            opener.inl.data.borrow().sourcepos.start.line,
+            opener.inl.data.borrow().sourcepos.start.column + opener_num_chars,
+            closer.inl.data.borrow().sourcepos.end.line,
+            closer.inl.data.borrow().sourcepos.end.column - closer_num_chars,
+        )
+            .into();
 
         // Drop all the interior AST nodes into the emphasis node
         // and then insert the emphasis node
@@ -1178,11 +1174,13 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
         }
         opener.inl.insert_after(emph);
 
-        // Drop the delimiters and return the next closer to process
-
+        // Drop completely "used up" delimiters, adjust sourcepos of those not,
+        // and return the next closest one for processing.
         if opener_num_chars == 0 {
             opener.inl.detach();
             self.remove_delimiter(opener);
+        } else {
+            opener.inl.data.borrow_mut().sourcepos.end.column -= use_delims;
         }
 
         if closer_num_chars == 0 {
@@ -1190,11 +1188,12 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
             self.remove_delimiter(closer);
             closer.next.get()
         } else {
+            closer.inl.data.borrow_mut().sourcepos.start.column += use_delims;
             Some(closer)
         }
     }
 
-    pub fn handle_backslash(&mut self) -> &'a AstNode<'a> {
+    fn handle_backslash(&mut self) -> &'a AstNode<'a> {
         let startpos = self.pos;
         self.pos += 1;
 
@@ -1216,7 +1215,11 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
                 inline_text
             }
         } else if !self.eof() && self.skip_line_end() {
-            self.make_inline(NodeValue::LineBreak, startpos, self.pos - 1)
+            let inl = self.make_inline(NodeValue::LineBreak, startpos, self.pos - 1);
+            self.line += 1;
+            self.column_offset = -(self.pos as isize);
+            self.skip_spaces();
+            inl
         } else {
             self.make_inline(
                 NodeValue::Text("\\".to_string()),
@@ -1237,7 +1240,7 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
         self.pos > old_pos || self.eof()
     }
 
-    pub fn handle_entity(&mut self) -> &'a AstNode<'a> {
+    fn handle_entity(&mut self) -> &'a AstNode<'a> {
         self.pos += 1;
 
         match entity::unescape(&self.input[self.pos..]) {
@@ -1254,7 +1257,7 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
     }
 
     #[cfg(feature = "shortcodes")]
-    pub fn handle_shortcodes_colon(&mut self) -> Option<&'a AstNode<'a>> {
+    fn handle_shortcodes_colon(&mut self) -> Option<&'a AstNode<'a>> {
         let matchlen = scanners::shortcode(&self.input[self.pos + 1..])?;
 
         let shortcode = unsafe {
@@ -1271,11 +1274,7 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
         ))
     }
 
-    pub fn handle_autolink_with<F>(
-        &mut self,
-        node: &'a AstNode<'a>,
-        f: F,
-    ) -> Option<&'a AstNode<'a>>
+    fn handle_autolink_with<F>(&mut self, node: &'a AstNode<'a>, f: F) -> Option<&'a AstNode<'a>>
     where
         F: Fn(
             &'a Arena<AstNode<'a>>,
@@ -1287,18 +1286,19 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
         if !self.options.parse.relaxed_autolinks && self.within_brackets {
             return None;
         }
-        let (post, mut reverse, skip) = f(
+        let startpos = self.pos;
+        let (post, need_reverse, skip) = f(
             self.arena,
             self.input,
             self.pos,
             self.options.parse.relaxed_autolinks,
         )?;
 
-        self.pos += skip - reverse;
+        self.pos += skip - need_reverse;
 
-        // We need to "rewind" by `reverse` chars, which should be in one or
-        // more Text nodes beforehand. Typically the chars will *all* be in a
-        // single Text node, containing whatever text came before the ":" that
+        // We need to "rewind" by `need_reverse` chars, which should be in one
+        // or more Text nodes beforehand. Typically the chars will *all* be in
+        // a single Text node, containing whatever text came before the ":" that
         // triggered this method, eg. "See our website at http" ("://blah.com").
         //
         // relaxed_autolinks allows some slightly pathological cases. First,
@@ -1306,11 +1306,14 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
         // a scheme including the letter "w", which will split Text inlines due
         // to them being their own trigger (for handle_autolink_w), meaning
         // "wa://…" will need to traverse two Texts to complete the rewind.
+        let mut reverse = need_reverse;
         while reverse > 0 {
-            match node.last_child().unwrap().data.borrow_mut().value {
+            let mut last_child = node.last_child().unwrap().data.borrow_mut();
+            match last_child.value {
                 NodeValue::Text(ref mut prev) => {
                     if reverse < prev.len() {
                         prev.truncate(prev.len() - reverse);
+                        last_child.sourcepos.end.column -= reverse;
                         reverse = 0;
                     } else {
                         reverse -= prev.len();
@@ -1321,18 +1324,40 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
             }
         }
 
+        {
+            let sp = &mut post.data.borrow_mut().sourcepos;
+            // See [`make_inline`].
+            sp.start = (
+                self.line,
+                (startpos as isize - need_reverse as isize
+                    + 1
+                    + self.column_offset
+                    + self.line_offset as isize) as usize,
+            )
+                .into();
+            sp.end = (
+                self.line,
+                (self.pos as isize + self.column_offset + self.line_offset as isize) as usize,
+            )
+                .into();
+
+            // Inner text node gets the same sp, since there are no surrounding
+            // characters for autolinks of these kind.
+            post.first_child().unwrap().data.borrow_mut().sourcepos = *sp;
+        }
+
         Some(post)
     }
 
-    pub fn handle_autolink_colon(&mut self, node: &'a AstNode<'a>) -> Option<&'a AstNode<'a>> {
+    fn handle_autolink_colon(&mut self, node: &'a AstNode<'a>) -> Option<&'a AstNode<'a>> {
         self.handle_autolink_with(node, autolink::url_match)
     }
 
-    pub fn handle_autolink_w(&mut self, node: &'a AstNode<'a>) -> Option<&'a AstNode<'a>> {
+    fn handle_autolink_w(&mut self, node: &'a AstNode<'a>) -> Option<&'a AstNode<'a>> {
         self.handle_autolink_with(node, autolink::www_match)
     }
 
-    pub fn handle_pointy_brace(&mut self) -> &'a AstNode<'a> {
+    fn handle_pointy_brace(&mut self, parent_line_offsets: &[usize]) -> &'a AstNode<'a> {
         self.pos += 1;
 
         if let Some(matchlen) = scanners::autolink_uri(&self.input[self.pos..]) {
@@ -1426,14 +1451,14 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
                 self.pos - matchlen - 1,
                 self.pos - 1,
             );
-            self.adjust_node_newlines(inl, matchlen, 1);
+            self.adjust_node_newlines(inl, matchlen, 1, parent_line_offsets);
             return inl;
         }
 
         self.make_inline(NodeValue::Text("<".to_string()), self.pos - 1, self.pos - 1)
     }
 
-    pub fn push_bracket(&mut self, image: bool, inl_text: &'a AstNode<'a>) {
+    fn push_bracket(&mut self, image: bool, inl_text: &'a AstNode<'a>) {
         let len = self.brackets.len();
         if len > 0 {
             self.brackets[len - 1].bracket_after = true;
@@ -1449,7 +1474,7 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
         }
     }
 
-    pub fn handle_close_bracket(&mut self) -> Option<&'a AstNode<'a>> {
+    fn handle_close_bracket(&mut self) -> Option<&'a AstNode<'a>> {
         self.pos += 1;
         let initial_pos = self.pos;
 
@@ -1670,7 +1695,7 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
         Some(self.make_inline(NodeValue::Text("]".to_string()), self.pos - 1, self.pos - 1))
     }
 
-    pub fn close_bracket_match(&mut self, is_image: bool, url: String, title: String) {
+    fn close_bracket_match(&mut self, is_image: bool, url: String, title: String) {
         let brackets_len = self.brackets.len();
 
         let nl = NodeLink { url, title };
@@ -1751,7 +1776,7 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
     // Handles wikilink syntax
     //   [[link text|url]]
     //   [[url|link text]]
-    pub fn handle_wikilink(&mut self) -> Option<&'a AstNode<'a>> {
+    fn handle_wikilink(&mut self) -> Option<&'a AstNode<'a>> {
         let startpos = self.pos;
         let component = self.wikilink_url_link_label()?;
         let url_clean = strings::clean_url(component.url);
@@ -1973,8 +1998,8 @@ impl<'a, 'r, 'o, 'd, 'i, 'c> Subject<'a, 'r, 'o, 'd, 'i, 'c> {
                 url: String::from_utf8(strings::clean_autolink(url, kind)).unwrap(),
                 title: String::new(),
             }),
-            start_column + 1,
-            end_column + 1,
+            start_column,
+            end_column,
         );
         inl.append(self.make_inline(
             NodeValue::Text(String::from_utf8(entity::unescape_html(url)).unwrap()),
diff --git a/src/parser/mod.rs b/src/parser/mod.rs
--- a/src/parser/mod.rs
+++ b/src/parser/mod.rs
@@ -21,7 +21,7 @@ use crate::scanners::{self, SetextChar};
 use crate::strings::{self, split_off_front_matter, Case};
 use std::cell::RefCell;
 use std::cmp::min;
-use std::collections::HashMap;
+use std::collections::{HashMap, VecDeque};
 use std::fmt::{self, Debug, Formatter};
 use std::mem;
 use std::panic::RefUnwindSafe;
@@ -1851,6 +1851,7 @@ where
         *container = self.add_child(container, NodeValue::ThematicBreak, self.first_nonspace + 1);
 
         let adv = line.len() - 1 - self.offset;
+        container.data.borrow_mut().sourcepos.end = (self.line_number, adv).into();
         self.advance_offset(line, adv, false);
 
         true
@@ -2708,6 +2709,11 @@ where
             _ => false,
         } {
             ast.sourcepos.end = (self.line_number, self.curline_end_col).into();
+        } else if match ast.value {
+            NodeValue::ThematicBreak => true,
+            _ => false,
+        } {
+            // sourcepos.end set during opening.
         } else {
             ast.sourcepos.end = (self.line_number - 1, self.last_line_length).into();
         }
@@ -2946,50 +2952,52 @@ where
 
             while let Some(n) = nch {
                 let mut this_bracket = false;
+                let mut emptied = false;
                 let n_ast = &mut n.data.borrow_mut();
                 let mut sourcepos = n_ast.sourcepos;
 
-                loop {
-                    match n_ast.value {
-                        // Join adjacent text nodes together
-                        NodeValue::Text(ref mut root) => {
-                            let ns = match n.next_sibling() {
-                                Some(ns) => ns,
-                                _ => {
-                                    // Post-process once we are finished joining text nodes
-                                    self.postprocess_text_node(n, root, &mut sourcepos);
-                                    break;
-                                }
-                            };
-
+                match n_ast.value {
+                    NodeValue::Text(ref mut root) => {
+                        // Join adjacent text nodes together, then post-process.
+                        // Record the original list of sourcepos and bytecounts
+                        // for the post-processing step.
+                        let mut spxv = VecDeque::new();
+                        spxv.push_back((sourcepos, root.len()));
+                        while let Some(ns) = n.next_sibling() {
                             match ns.data.borrow().value {
                                 NodeValue::Text(ref adj) => {
                                     root.push_str(adj);
-                                    sourcepos.end.column = ns.data.borrow().sourcepos.end.column;
+                                    let sp = ns.data.borrow().sourcepos;
+                                    spxv.push_back((sp, adj.len()));
+                                    sourcepos.end.column = sp.end.column;
                                     ns.detach();
                                 }
-                                _ => {
-                                    // Post-process once we are finished joining text nodes
-                                    self.postprocess_text_node(n, root, &mut sourcepos);
-                                    break;
-                                }
+                                _ => break,
                             }
                         }
-                        NodeValue::Link(..) | NodeValue::Image(..) | NodeValue::WikiLink(..) => {
-                            this_bracket = true;
-                            break;
-                        }
-                        _ => break,
+
+                        self.postprocess_text_node(n, root, &mut sourcepos, spxv);
+                        emptied = root.len() == 0;
+                    }
+                    NodeValue::Link(..) | NodeValue::Image(..) | NodeValue::WikiLink(..) => {
+                        // Don't recurse into links (no links-within-links) or
+                        // images (title part).
+                        this_bracket = true;
                     }
+                    _ => {}
                 }
 
                 n_ast.sourcepos = sourcepos;
 
-                if !this_bracket {
+                if !this_bracket && !emptied {
                     children.push(n);
                 }
 
                 nch = n.next_sibling();
+
+                if emptied {
+                    n.detach();
+                }
             }
 
             // Push children onto work stack in reverse order so they are
@@ -3003,17 +3011,21 @@ where
         node: &'a AstNode<'a>,
         text: &mut String,
         sourcepos: &mut Sourcepos,
+        spxv: VecDeque<(Sourcepos, usize)>,
     ) {
+        let mut spx = Spx(spxv);
         if self.options.extension.tasklist {
-            self.process_tasklist(node, text, sourcepos);
+            self.process_tasklist(node, text, sourcepos, &mut spx);
         }
 
         if self.options.extension.autolink {
-            autolink::process_autolinks(
+            autolink::process_email_autolinks(
                 self.arena,
                 node,
                 text,
                 self.options.parse.relaxed_autolinks,
+                sourcepos,
+                &mut spx,
             );
         }
     }
@@ -3023,6 +3035,7 @@ where
         node: &'a AstNode<'a>,
         text: &mut String,
         sourcepos: &mut Sourcepos,
+        spx: &mut Spx,
     ) {
         let (end, symbol) = match scanners::tasklist(text.as_bytes()) {
             Some(p) => p,
@@ -3054,13 +3067,25 @@ where
             return;
         }
 
-        text.drain(..end);
-
         // These are sound only because the exact text that we've matched and
         // the count thereof (i.e. "end") will precisely map to characters in
         // the source document.
-        sourcepos.start.column += end;
-        parent.data.borrow_mut().sourcepos.start.column += end;
+        text.drain(..end);
+
+        let adjust = spx.consume(end) + 1;
+        assert_eq!(
+            sourcepos.start.column,
+            parent.data.borrow().sourcepos.start.column
+        );
+
+        // See tests::fuzz::echaw9. The paragraph doesn't exist in the source,
+        // so we remove it.
+        if sourcepos.end.column < adjust {
+            parent.detach();
+        } else {
+            sourcepos.start.column = adjust;
+            parent.data.borrow_mut().sourcepos.start.column = adjust;
+        }
 
         grandparent.data.borrow_mut().value =
             NodeValue::TaskItem(if symbol == ' ' { None } else { Some(symbol) });
@@ -3312,3 +3337,52 @@ pub enum ListStyleType {
     /// The `*` character
     Star = 42,
 }
+
+pub(crate) struct Spx(VecDeque<(Sourcepos, usize)>);
+
+impl Spx {
+    // Sourcepos end column `e` of a node determined by advancing through `spx`
+    // until `i` bytes of input are seen.
+    //
+    // For each element `(sp, x)` in `spx`:
+    // - if remaining `i` is greater than the byte count `x`,
+    //     set `i -= x` and continue.
+    // - if remaining `i` is equal to the byte count `x`,
+    //     set `e = sp.end.column` and finish.
+    // - if remaining `i` is less than the byte count `x`,
+    //     assert `sp.end.column - sp.start.column + 1 == x || i == 0` (1),
+    //     set `e = sp.start.column + i - 1` and finish.
+    //
+    // (1) If `x` doesn't equal the range covered between the start and end column,
+    //     there's no way to determine sourcepos within the range. This is a bug if
+    //     it happens; it suggests we've matched an email autolink with some smart
+    //     punctuation in it, or worse.
+    //
+    //     The one exception is if `i == 0`. Given nothing to consume, we can
+    //     happily restore what we popped, returning `sp.start.column - 1` for the
+    //     end column of the original node.
+    pub(crate) fn consume(&mut self, mut rem: usize) -> usize {
+        while let Some((sp, x)) = self.0.pop_front() {
+            if rem > x {
+                rem -= x;
+            } else if rem == x {
+                return sp.end.column;
+            } else {
+                // rem < x
+                assert!((sp.end.column - sp.start.column + 1 == x) || rem == 0);
+                self.0.push_front((
+                    (
+                        sp.start.line,
+                        sp.start.column + rem,
+                        sp.end.line,
+                        sp.end.column,
+                    )
+                        .into(),
+                    x - rem,
+                ));
+                return sp.start.column + rem - 1;
+            }
+        }
+        unreachable!();
+    }
+}
diff --git a/src/strings.rs b/src/strings.rs
--- a/src/strings.rs
+++ b/src/strings.rs
@@ -144,17 +144,19 @@ pub fn chop_trailing_hashtags(line: &mut Vec<u8>) {
     }
 }
 
-pub fn rtrim(line: &mut Vec<u8>) {
+pub fn rtrim(line: &mut Vec<u8>) -> usize {
     let spaces = line.iter().rev().take_while(|&&b| isspace(b)).count();
     let new_len = line.len() - spaces;
     line.truncate(new_len);
+    spaces
 }
 
-pub fn ltrim(line: &mut Vec<u8>) {
+pub fn ltrim(line: &mut Vec<u8>) -> usize {
     let spaces = line.iter().take_while(|&&b| isspace(b)).count();
     shift_buf_left(line, spaces);
     let new_len = line.len() - spaces;
     line.truncate(new_len);
+    spaces
 }
 
 pub fn trim(line: &mut Vec<u8>) {
@@ -191,6 +193,9 @@ pub fn trim_slice(mut i: &[u8]) -> &[u8] {
 }
 
 fn shift_buf_left(buf: &mut [u8], n: usize) {
+    if n == 0 {
+        return;
+    }
     assert!(n <= buf.len());
     let keep = buf.len() - n;
     unsafe {


Test patch:
diff --git a/src/tests.rs b/src/tests.rs
--- a/src/tests.rs
+++ b/src/tests.rs
@@ -289,17 +289,16 @@ macro_rules! sourcepos {
 pub(crate) use sourcepos;
 
 macro_rules! ast {
-    (($name:tt $sp:tt)) => {
-        ast!(($name $sp []))
-    };
-    (($name:tt $sp:tt $content:tt)) => {
+    (($name:tt $sp:tt $( $content:tt )*)) => {
         AstMatchTree {
             name: stringify!($name).to_string(),
             sourcepos: sourcepos!($sp),
-            content: ast!($content),
+            matches: vec![ $( ast_content!($content), )* ],
         }
     };
+}
 
+macro_rules! ast_content {
     ($text:literal) => {AstMatchContent::Text($text.to_string())};
     ([ $( $children:tt )* ]) => {
         AstMatchContent::Children(vec![ $( ast!($children), )* ])
@@ -307,6 +306,7 @@ macro_rules! ast {
 }
 
 pub(crate) use ast;
+pub(crate) use ast_content;
 
 #[track_caller]
 fn assert_ast_match_i<F>(md: &str, amt: AstMatchTree, opts: F)
@@ -365,7 +365,7 @@ pub(crate) use assert_ast_match;
 struct AstMatchTree {
     name: String,
     sourcepos: Sourcepos,
-    content: AstMatchContent,
+    matches: Vec<AstMatchContent>,
 }
 
 enum AstMatchContent {
@@ -380,29 +380,71 @@ impl AstMatchTree {
         assert_eq!(self.name, ast.value.xml_node_name(), "node type matches");
         assert_eq!(self.sourcepos, ast.sourcepos, "sourcepos are equal");
 
-        match &self.content {
-            AstMatchContent::Text(text) => {
-                assert_eq!(
-                    0,
-                    node.children().count(),
-                    "text node should have no children"
-                );
-                assert_eq!(
-                    text,
-                    ast.value.text().unwrap(),
-                    "text node content should match"
-                );
-            }
-            AstMatchContent::Children(children) => {
-                assert_eq!(
-                    children.len(),
-                    node.children().count(),
-                    "children count should match"
-                );
-                for (e, a) in children.iter().zip(node.children()) {
-                    e.assert_match(a);
+        let mut asserted_text = false;
+        let mut asserted_children = false;
+
+        for m in &self.matches {
+            match m {
+                AstMatchContent::Text(text) => match ast.value {
+                    NodeValue::Math(ref nm) => {
+                        assert_eq!(text, &nm.literal, "Math literal should match");
+                        asserted_text = true;
+                    }
+                    NodeValue::CodeBlock(ref ncb) => {
+                        assert_eq!(text, &ncb.literal, "CodeBlock literal should match");
+                        asserted_text = true;
+                    }
+                    NodeValue::Text(ref nt) => {
+                        assert_eq!(text, nt, "Text content should match");
+                        asserted_text = true;
+                    }
+                    NodeValue::Link(ref nl) => {
+                        assert_eq!(text, &nl.url, "Link destination should match");
+                        asserted_text = true;
+                    }
+                    NodeValue::Image(ref ni) => {
+                        assert_eq!(text, &ni.url, "Image source should match");
+                        asserted_text = true;
+                    }
+                    NodeValue::FrontMatter(ref nfm) => {
+                        assert_eq!(text, nfm, "Front matter content should match");
+                        asserted_text = true;
+                    }
+                    _ => panic!(
+                        "no text content matcher for this node type: {:?}",
+                        ast.value
+                    ),
+                },
+                AstMatchContent::Children(children) => {
+                    assert_eq!(
+                        children.len(),
+                        node.children().count(),
+                        "children count should match"
+                    );
+                    for (e, a) in children.iter().zip(node.children()) {
+                        e.assert_match(a);
+                    }
+                    asserted_children = true;
                 }
             }
         }
+
+        assert!(
+            asserted_children || node.children().count() == 0,
+            "children were not asserted"
+        );
+        assert!(
+            asserted_text
+                || !matches!(
+                    ast.value,
+                    NodeValue::Math(_)
+                        | NodeValue::CodeBlock(_)
+                        | NodeValue::Text(_)
+                        | NodeValue::Link(_)
+                        | NodeValue::Image(_)
+                        | NodeValue::FrontMatter(_)
+                ),
+            "text wasn't asserted"
+        );
     }
 }
diff --git a/src/tests/autolink.rs b/src/tests/autolink.rs
--- a/src/tests/autolink.rs
+++ b/src/tests/autolink.rs
@@ -239,29 +239,6 @@ fn autolink_relaxed_links_schemes() {
 
 #[test]
 fn sourcepos_correctly_restores_context() {
-    // There's unsoundness in trying to maintain and adjust sourcepos
-    // when doing autolinks in the light of:
-    //
-    // a) Some source elements introducing a different number of characters
-    //    to the content text than they take in source, i.e. smart
-    //    punctuation.
-    //
-    // b) Text node consolidation happening before autolinking.
-    //
-    // (b) is obviously non-optional, but it means we end up with Text
-    // nodes with different byte counts than their sourcepos span lengths.
-    //
-    // One possible solution would be to actually accumulate multiple
-    // sourcepos spans per Text node, each also tracking the number of
-    // bytes of content text it's responsible for.  This would work well
-    // enough as long as we never had to adjust a sourcepos into a spot
-    // within a sourcepos span that had a target text width where it
-    // wasn't equal.  That probably wouldn't happen, though -- i.e. we're
-    // never autolinking into the middle of a rendered smart punctuation.
-    //
-    // For now the desired sourcepos is documented in comment.  What we
-    // have currently (after backing out the adjustments, having hit the
-    // above case) matches cmark-gfm.
     assert_ast_match!(
         [],
         "ab _cde_ f@g.ee h*ijklm* n",
@@ -289,11 +266,11 @@ fn sourcepos_correctly_restores_context() {
                 (emph (1:4-1:8) [
                     (text (1:5-1:7) "cde")
                 ])
-                (text (1:9-1:17) " ")             // (text (1:9-1:9) " ")
-                (link (XXX) [                     // (link (1:10-1:15) [
-                    (text (XXX) "f@g.ee")             // (text (1:10-1:15) "f@g.ee")
+                (text (1:9-1:9) " ")
+                (link (1:10-1:15) "mailto:f@g.ee" [
+                    (text (1:10-1:15) "f@g.ee")
                 ])
-                (text (XXX) " h")                 // (text (1:16-1:17) " h")
+                (text (1:16-1:17) " h")
                 (emph (1:18-1:24) [
                     (text (1:19-1:23) "ijklm")
                 ])
@@ -395,3 +372,77 @@ fn autolink_fuzz_we() {
         no_roundtrip,
     );
 }
+
+#[test]
+fn autolink_sourcepos() {
+    assert_ast_match!(
+        [extension.autolink],
+        "a  www.com  x\n"
+        "\n"
+        "b  https://www.com  y\n"
+        "\n"
+        "c  foo@www.com  z\n"
+        ,
+        (document (1:1-5:17) [
+            (paragraph (1:1-1:13) [
+                (text (1:1-1:3) "a  ")
+                (link (1:4-1:10) "http://www.com" [
+                    (text (1:4-1:10) "www.com")
+                ])
+                (text (1:11-1:13) "  x")
+            ])
+            (paragraph (3:1-3:21) [
+                (text (3:1-3:3) "b  ")
+                (link (3:4-3:18) "https://www.com" [
+                    (text (3:4-3:18) "https://www.com")
+                ])
+                (text (3:19-3:21) "  y")
+            ])
+            (paragraph (5:1-5:17) [
+                (text (5:1-5:3) "c  ")
+                (link (5:4-5:14) "mailto:foo@www.com" [
+                    (text (5:4-5:14) "foo@www.com")
+                ])
+                (text (5:15-5:17) "  z")
+            ])
+        ])
+    );
+}
+
+#[test]
+fn autolink_consecutive_email() {
+    assert_ast_match!(
+        [extension.autolink],
+        "scyther@pokemon.com/beedrill@pokemon.com",
+        (document (1:1-1:40) [
+            (paragraph (1:1-1:40) [
+                (link (1:1-1:19) "mailto:scyther@pokemon.com" [
+                    (text (1:1-1:19) "scyther@pokemon.com")
+                ])
+                (text (1:20-1:20) "/")
+                (link (1:21-1:40) "mailto:beedrill@pokemon.com" [
+                    (text (1:21-1:40) "beedrill@pokemon.com")
+                ])
+            ])
+        ])
+    );
+}
+
+#[test]
+fn autolink_consecutive_email_smart() {
+    assert_ast_match!(
+        [extension.autolink, parse.smart],
+        "scyther@pokemon.com--beedrill@pokemon.com",
+        (document (1:1-1:41) [
+            (paragraph (1:1-1:41) [
+                (link (1:1-1:19) "mailto:scyther@pokemon.com" [
+                    (text (1:1-1:19) "scyther@pokemon.com")
+                ])
+                (text (1:20-1:21) "–") // en-dash
+                (link (1:22-1:41) "mailto:beedrill@pokemon.com" [
+                    (text (1:22-1:41) "beedrill@pokemon.com")
+                ])
+            ])
+        ])
+    );
+}
diff --git a/src/tests/core.rs b/src/tests/core.rs
--- a/src/tests/core.rs
+++ b/src/tests/core.rs
@@ -523,7 +523,7 @@ fn link_sourcepos_baseline() {
         "[ABCD](/)\n",
         (document (1:1-1:9) [
             (paragraph (1:1-1:9) [
-                (link (1:1-1:9) [
+                (link (1:1-1:9) "/" [
                     (text (1:2-1:5) "ABCD")
                 ])
             ])
@@ -539,7 +539,7 @@ fn link_sourcepos_newline() {
         "[AB\nCD](/)\n",
         (document (1:1-2:6) [
             (paragraph (1:1-2:6) [
-                (link (1:1-2:6) [
+                (link (1:1-2:6) "/" [
                     (text (1:2-1:3) "AB")
                     (softbreak (1:4-1:4))
                     (text (2:1-2:2) "CD")
@@ -560,8 +560,8 @@ fn link_sourcepos_truffle() {
                     (paragraph (1:3-2:18) [
                         (text (1:3-1:3) "A")
                         (softbreak (1:4-1:4))
-                        (link (2:1-2:18) [
-                            (image (2:2-2:13) [
+                        (link (2:1-2:18) "/B" [
+                            (image (2:2-2:13) "/B.png" [
                                 (text (2:4-2:4) "B")
                             ])
                         ])
@@ -583,8 +583,8 @@ fn link_sourcepos_truffle_twist() {
                     (paragraph (1:3-2:20) [
                         (text (1:3-1:3) "A")
                         (softbreak (1:4-1:4))
-                        (link (2:3-2:20) [
-                            (image (2:4-2:15) [
+                        (link (2:3-2:20) "/B" [
+                            (image (2:4-2:15) "/B.png" [
                                 (text (2:6-2:6) "B")
                             ])
                         ])
@@ -606,8 +606,8 @@ fn link_sourcepos_truffle_bergamot() {
                     (paragraph (1:3-2:21) [
                         (text (1:3-1:3) "A")
                         (softbreak (1:4-1:4))
-                        (link (2:4-2:21) [
-                            (image (2:5-2:16) [
+                        (link (2:4-2:21) "/B" [
+                            (image (2:5-2:16) "/B.png" [
                                 (text (2:7-2:7) "B")
                             ])
                         ])
diff --git a/src/tests/front_matter.rs b/src/tests/front_matter.rs
--- a/src/tests/front_matter.rs
+++ b/src/tests/front_matter.rs
@@ -26,72 +26,64 @@ fn round_trip_wide_delimiter() {
     assert_eq!(&String::from_utf8(buf).unwrap(), input);
 }
 
-#[test]
-fn ast_wide_delimiter() {
-    let input = "\u{04fc}\nlayout: post\n\u{04fc}\nText\n";
-
-    assert_ast_match_i(
-        input,
-        ast!((document (1:1-4:4) [
-            (frontmatter (1:1-3:2) [])
-            (paragraph (4:1-4:4) [
-                (text (4:1-4:4) [])
-            ])
-        ])),
-        |opts| opts.extension.front_matter_delimiter = Some("\u{04fc}".to_owned()),
-    );
-}
-
 #[test]
 fn ast() {
-    let input = "q\nlayout: post\nq\nText\n";
-
-    assert_ast_match_i(
-        input,
-        ast!((document (1:1-4:4) [
-            (frontmatter (1:1-3:1) [])
+    assert_ast_match!(
+        [extension.front_matter_delimiter = Some("q".to_owned())],
+        "q\nlayout: post\nq\nText\n",
+        (document (1:1-4:4) [
+            (frontmatter (1:1-3:1) "q\nlayout: post\nq\n")
             (paragraph (4:1-4:4) [
-                (text (4:1-4:4) [])
+                (text (4:1-4:4) "Text")
             ])
-        ])),
-        |opts| opts.extension.front_matter_delimiter = Some("q".to_owned()),
+        ])
     );
 }
 
 #[test]
 fn ast_blank_line() {
-    let input = r#"---
+    assert_ast_match!(
+        [extension.front_matter_delimiter = Some("---".to_owned())],
+        r#"---
 a: b
 ---
 
 hello world
-"#;
-
-    assert_ast_match_i(
-        input,
-        ast!((document (1:1-5:11) [
-            (frontmatter (1:1-3:3) [])
+"#,
+        (document (1:1-5:11) [
+            (frontmatter (1:1-3:3) "---\na: b\n---\n\n")
             (paragraph (5:1-5:11) [
-                (text (5:1-5:11) [])
+                (text (5:1-5:11) "hello world")
             ])
-        ])),
-        |opts| opts.extension.front_matter_delimiter = Some("---".to_owned()),
+        ])
     );
 }
 
 #[test]
 fn ast_carriage_return() {
-    let input = "q\r\nlayout: post\r\nq\r\nText\r\n";
+    assert_ast_match!(
+        [extension.front_matter_delimiter = Some("q".to_owned())],
+        "q\r\nlayout: post\r\nq\r\nText\r\n",
+        (document (1:1-4:4) [
+            (frontmatter (1:1-3:1) "q\r\nlayout: post\r\nq\r\n")
+            (paragraph (4:1-4:4) [
+                (text (4:1-4:4) "Text")
+            ])
+        ])
+    );
+}
 
-    assert_ast_match_i(
-        input,
-        ast!((document (1:1-4:4) [
-            (frontmatter (1:1-3:1) [])
+#[test]
+fn ast_wide_delimiter() {
+    assert_ast_match!(
+        [extension.front_matter_delimiter = Some("\u{04fc}".to_owned())],
+        "\u{04fc}\nlayout: post\n\u{04fc}\nText\n",
+        (document (1:1-4:4) [
+            (frontmatter (1:1-3:2) "\u{04fc}\nlayout: post\n\u{04fc}\n")
             (paragraph (4:1-4:4) [
-                (text (4:1-4:4) [])
+                (text (4:1-4:4) "Text")
             ])
-        ])),
-        |opts| opts.extension.front_matter_delimiter = Some("q".to_owned()),
+        ])
     );
 }
 
diff --git a/src/tests/fuzz.rs b/src/tests/fuzz.rs
--- a/src/tests/fuzz.rs
+++ b/src/tests/fuzz.rs
@@ -1,4 +1,4 @@
-use super::{html, html_opts};
+use super::*;
 
 #[test]
 fn pointy_brace_open() {
@@ -72,21 +72,286 @@ fn bracket_match() {
 
 #[test]
 fn trailing_hyphen() {
-    html_opts!(
-        [extension.autolink, parse.smart, render.sourcepos],
+    assert_ast_match!(
+        [extension.autolink, parse.smart],
         "3@.l-",
-        "<p data-sourcepos=\"1:1-1:5\">3@.l-</p>\n"
+        (document (1:1-1:5) [
+            (paragraph (1:1-1:5) [
+                (text (1:1-1:5) "3@.l-")
+            ])
+        ])
     );
 }
 
 #[test]
-fn trailing_hyphen_matches() {
-    html_opts!(
-        [extension.autolink, parse.smart, render.sourcepos],
-        "3@.l--",
-        "<p data-sourcepos=\"1:1-1:6\"><a href=\"mailto:3@.l\">3@.l</a>–</p>\n",
-        no_roundtrip // We serialise the link back to <3@.l>, which doesn't
-                     // parse as a classic autolink, but the email inside the
-                     // <...> does, meaning the </> get rendered!
+fn trailing_smart_endash_matches() {
+    assert_ast_match!(
+        [extension.autolink, parse.smart],
+        "--\n"
+        "--(3@.l--\n",
+        (document (1:1-2:9) [
+            (paragraph (1:1-2:9) [
+                (text (1:1-1:2) "–")  // en-dash
+                (softbreak (1:3-1:3))
+                (text (2:1-2:3) "–(")  // en-dash
+                (link (2:4-2:7) "mailto:3@.l" [
+                    (text (2:4-2:7) "3@.l")
+                ])
+                (text (2:8-2:9) "–")  // en-dash
+            ])
+        ])
+    );
+}
+
+#[test]
+fn trailing_endash_matches() {
+    assert_ast_match!(
+        [extension.autolink],
+        "–\n"
+        "–(3@.l–\n",
+        (document (1:1-2:11) [
+            (paragraph (1:1-2:11) [
+                (text (1:1-1:3) "–")  // en-dash
+                (softbreak (1:4-1:4))
+                (text (2:1-2:4) "–(")  // en-dash
+                (link (2:5-2:8) "mailto:3@.l" [
+                    (text (2:5-2:8) "3@.l")
+                ])
+                (text (2:9-2:11) "–")  // en-dash
+            ])
+        ])
+    );
+}
+
+#[test]
+fn no_empty_text_before_email() {
+    assert_ast_match!(
+        [extension.autolink],
+        "a@b.c\n",
+        (document (1:1-1:5) [
+            (paragraph (1:1-1:5) [
+                (link (1:1-1:5) "mailto:a@b.c" [
+                    (text (1:1-1:5) "a@b.c")
+                ])
+            ])
+        ])
+    );
+}
+
+#[test]
+fn smart_sourcepos() {
+    assert_ast_match!(
+        [parse.smart],
+        ": _--_ **---**\n\n"
+        // As above, but entered directly.
+        ": _–_ **—**\n",
+        (document (1:1-3:15) [
+            (paragraph (1:1-1:14) [
+                (text (1:1-1:2) ": ")
+                (emph (1:3-1:6) [
+                    (text (1:4-1:5) "–")  // en-dash
+                ])
+                (text (1:7-1:7) " ")
+                (strong (1:8-1:14) [
+                    (text (1:10-1:12) "—")  // em-dash
+                ])
+            ])
+            (paragraph (3:1-3:15) [
+                (text (3:1-3:2) ": ")
+                (emph (3:3-3:7) [
+                    (text (3:4-3:6) "–")  // en-dash; 3 bytes in input
+                ])
+                (text (3:8-3:8) " ")
+                (strong (3:9-3:15) [
+                    (text (3:11-3:13) "—")  // em-dash; (still) 3 bytes
+                ])
+            ])
+        ])
+    );
+}
+
+#[test]
+fn linebreak_sourcepos() {
+    assert_ast_match!(
+        [],
+        "a\\\n"
+        "b\n",
+        (document (1:1-2:1) [
+            (paragraph (1:1-2:1) [
+                (text (1:1-1:1) "a")
+                (linebreak (1:2-1:3))
+                (text (2:1-2:1) "b")
+            ])
+        ])
+    );
+}
+
+#[test]
+fn echaw() {
+    assert_ast_match!(
+        [extension.autolink],
+        "<U@.J<AA@.J",
+        (document (1:1-1:11) [
+            (paragraph (1:1-1:11) [
+                (text (1:1-1:1) "<")
+                (link (1:2-1:5) "mailto:U@.J" [
+                    (text (1:2-1:5) "U@.J")
+                ])
+                (text (1:6-1:6) "<")
+                (link (1:7-1:11) "mailto:AA@.J" [
+                    (text (1:7-1:11) "AA@.J")
+                ])
+            ])
+        ])
+    );
+}
+
+#[test]
+fn echaw2() {
+    assert_ast_match!(
+        [extension.autolink, parse.smart],
+        ":C@.t'C@.t",
+        (document (1:1-1:10) [
+            (paragraph (1:1-1:10) [
+                (text (1:1-1:1) ":")
+                (link (1:2-1:5) "mailto:C@.t" [
+                    (text (1:2-1:5) "C@.t")
+                ])
+                (text (1:6-1:6) "’")
+                (link (1:7-1:10) "mailto:C@.t" [
+                    (text (1:7-1:10) "C@.t")
+                ])
+            ])
+        ])
+    );
+}
+
+#[test]
+fn echaw3() {
+    assert_ast_match!(
+        [extension.autolink, parse.smart],
+        // XXX As an extra special case, NUL bytes are expanded to U+FFFD
+        // REPLACEMENT CHARACTER (UTF-8: EF BF BD) during the feed stage, so
+        // sourcepos sees three bytes (!). I might like to change this later.
+        "c@.r\0\t\r"
+        "z  \n"
+        " f@.x",
+        (document (1:1-3:5) [
+            (paragraph (1:1-3:5) [
+                (link (1:1-1:4) "mailto:c@.r" [
+                    (text (1:1-1:4) "c@.r")
+                ])
+                (text (1:5-1:7) "�")
+                // !! Spaces at EOL are trimmed.
+                // See parser::inlines::Subject::parse_inline's final case.
+                (softbreak (1:9-1:9))
+                (text (2:1-2:1) "z")
+                (linebreak (2:2-2:4))
+                (link (3:2-3:5) "mailto:f@.x" [
+                    (text (3:2-3:5) "f@.x")
+                ])
+            ])
+        ])
+    );
+}
+
+#[test]
+fn echaw4() {
+    // &UnderBar; resolves to a plain ASCII underscore "_".
+    assert_ast_match!(
+        [extension.autolink, parse.smart],
+        "-@&UnderBar;.e--",
+        (document (1:1-1:16) [
+            (paragraph (1:1-1:16) [
+                (link (1:1-1:14) "mailto:-@_.e" [
+                    (text (1:1-1:14) "-@_.e")
+                ])
+                (text (1:15-1:16) "–")  // en-dash
+            ])
+        ])
+    );
+}
+
+#[test]
+fn echaw5() {
+    assert_ast_match!(
+        [],
+        "_#___@e.u",
+        (document (1:1-1:9) [
+            (paragraph (1:1-1:9) [
+                (emph (1:1-1:3) [
+                    (text (1:2-1:2) "#")
+                ])
+                (text (1:4-1:9) "__@e.u")
+            ])
+        ])
+    );
+}
+
+#[test]
+fn echaw6() {
+    assert_ast_match!(
+        [extension.autolink],
+        "_#___@e.u",
+        (document (1:1-1:9) [
+            (paragraph (1:1-1:9) [
+                (emph (1:1-1:3) [
+                    (text (1:2-1:2) "#")
+                ])
+                (link (1:4-1:9) "mailto:__@e.u" [
+                    (text (1:4-1:9) "__@e.u")
+                ])
+            ])
+        ])
+    );
+}
+
+#[test]
+fn echaw7() {
+    assert_ast_match!(
+        [extension.autolink],
+        "&#65;i@i.a",
+        (document (1:1-1:10) [
+            (paragraph (1:1-1:10) [
+                (link (1:1-1:10) "mailto:Ai@i.a" [
+                    (text (1:1-1:10) "Ai@i.a")
+                ])
+            ])
+        ])
+    );
+}
+
+#[test]
+fn echaw8() {
+    // fuzz/artifacts/all_options/minimized-from-57c3eaf5e03b3fd7fa971b0db6143ee3c21a7452
+    assert_ast_match!(
+        [extension.autolink, extension.tasklist],
+        "- [x] &Xfr;-<A@.N",
+        (document (1:1-1:17) [
+            (list (1:1-1:17) [
+                (taskitem (1:1-1:17) [
+                    (paragraph (1:7-1:17) [
+                        (text (1:7-1:13) "𝔛-<")
+                        (link (1:14-1:17) "mailto:A@.N" [
+                            (text (1:14-1:17) "A@.N")
+                        ])
+                    ])
+                ])
+            ])
+        ]),
+    );
+}
+
+#[test]
+fn echaw9() {
+    // fuzz/artifacts/all_options/minimized-from-8a07a44ba1f971ec39d0c14d377c78c2535c6fd5
+    assert_ast_match!(
+        [extension.tasklist],
+        "-\t[ ]&NewLine;",
+        (document (1:1-1:14) [
+            (list (1:1-1:14) [
+                (taskitem (1:1-1:14))
+            ])
+        ]),
     );
 }
diff --git a/src/tests/math.rs b/src/tests/math.rs
--- a/src/tests/math.rs
+++ b/src/tests/math.rs
@@ -148,16 +148,16 @@ fn sourcepos() {
         "```\n",
         (document (1:1-9:3) [
             (paragraph (1:1-1:29) [
-                (math (1:2-1:4))
+                (math (1:1-1:5) "x^2")
                 (text (1:6-1:10) " and ")
-                (math (1:13-1:15))
+                (math (1:11-1:17) "y^2")
                 (text (1:18-1:22) " and ")
-                (math (1:25-1:27))
+                (math (1:23-1:29) "z^2")
             ])
             (paragraph (3:1-5:2) [
-                (math (3:3-5:0))
+                (math (3:1-5:2) "\na^2\n")
             ])
-            (code_block (7:1-9:3))
+            (code_block (7:1-9:3) "b^2\n")
         ])
     );
 }
diff --git a/src/tests/regressions.rs b/src/tests/regressions.rs
--- a/src/tests/regressions.rs
+++ b/src/tests/regressions.rs
@@ -133,3 +133,109 @@ fn sourcepos_para() {
 fn gemoji() {
     html_opts!([extension.shortcodes], ":x:", "<p>❌</p>\n");
 }
+
+#[test]
+fn sourcepos_lone_backtick() {
+    assert_ast_match!(
+        [],
+        "``\n",
+        (document (1:1-1:2) [
+            (paragraph (1:1-1:2) [
+                (text (1:1-1:2) "``")
+            ])
+        ])
+    );
+}
+
+#[ignore] // This one will require a bit of thinking.
+#[test]
+fn sourcepos_link_items() {
+    assert_ast_match!(
+        [],
+        "- ab\n"
+        "- cdef\n"
+        "\n"
+        "\n"
+        "g\n"
+        ,
+        (document (1:1-5:1) [
+            (list (1:1-2:6) [
+                (item (1:1-1:4) [
+                    (paragraph (1:3-1:4) [
+                        (text (1:3-1:4) "ab")
+                    ])
+                ])
+                (item (2:1-2:6) [
+                    (paragraph (2:3-2:6) [
+                        (text (2:3-2:6) "cdef")
+                    ])
+                ])
+            ])
+            (paragraph (5:1-5:1) [
+                (text (5:1-5:1) "g")
+            ])
+        ])
+    );
+}
+
+#[test]
+fn assorted_links() {
+    assert_ast_match!(
+        [extension.autolink],
+        r#"hello <https://example.com/fooo> world
+hello [foo](https://example.com) world
+hello [foo] world
+hello [bar][bar] world
+hello https://example.com/foo world
+hello www.example.com world
+hello foo@example.com world
+
+[foo]: https://example.com
+[bar]: https://example.com"#,
+        (document (1:1-10:26) [
+            (paragraph (1:1-7:27) [
+                (text (1:1-1:6) "hello ")
+                (link (1:7-1:32) "https://example.com/fooo" [
+                    (text (1:8-1:31) "https://example.com/fooo")
+                ])
+                (text (1:33-1:38) " world")
+                (softbreak (1:39-1:39))
+                (text (2:1-2:6) "hello ")
+                (link (2:7-2:32) "https://example.com" [
+                    (text (2:8-2:10) "foo")
+                ])
+                (text (2:33-2:38) " world")
+                (softbreak (2:39-2:39))
+                (text (3:1-3:6) "hello ")
+                (link (3:7-3:11) "https://example.com" [
+                    (text (3:8-3:10) "foo")
+                ])
+                (text (3:12-3:17) " world")
+                (softbreak (3:18-3:18))
+                (text (4:1-4:6) "hello ")
+                (link (4:7-4:16) "https://example.com" [
+                    (text (4:8-4:10) "bar")
+                ])
+                (text (4:17-4:22) " world")
+                (softbreak (4:23-4:23))
+                (text (5:1-5:6) "hello ")
+                (link (5:7-5:29) "https://example.com/foo" [
+                    (text (5:7-5:29) "https://example.com/foo")
+                ])
+                (text (5:30-5:35) " world")
+                (softbreak (5:36-5:36))
+                (text (6:1-6:6) "hello ")
+                (link (6:7-6:21) "http://www.example.com" [
+                    (text (6:7-6:21) "www.example.com")
+                ])
+                (text (6:22-6:27) " world")
+                (softbreak (6:28-6:28))
+                (text (7:1-7:6) "hello ")
+                (link (7:7-7:21) "mailto:foo@example.com" [
+                    (text (7:7-7:21) "foo@example.com")
+                ])
+                (text (7:22-7:27) " world")
+            ])
+        ])
+    );
+}
diff --git a/src/tests/sourcepos.rs b/src/tests/sourcepos.rs
--- a/src/tests/sourcepos.rs
+++ b/src/tests/sourcepos.rs
@@ -161,10 +161,10 @@ hello world
 );
 
 const THEMATIC_BREAK: TestCase = (
-    &[sourcepos!((3:1-3:3))],
+    &[sourcepos!((3:2-3:4))],
     r#"Hello
 
----
+ ---
 
 World"#,
 );
@@ -266,7 +266,10 @@ hello world
 );
 
 const SOFT_BREAK: TestCase = (&[sourcepos!((1:13-1:13))], "stuff before\nstuff after");
-const LINE_BREAK: TestCase = (&[sourcepos!((1:13-1:15))], "stuff before  \nstuff after");
+const LINE_BREAK: TestCase = (
+    &[sourcepos!((1:13-1:15)), sourcepos!((4:13-4:14))],
+    "stuff before  \nstuff after\n\nstuff before\\\nstuff after\n",
+);
 
 const CODE: TestCase = (&[sourcepos!((1:7-1:13))], "hello `world`");
 
@@ -302,12 +305,16 @@ const LINK: TestCase = (
         sourcepos!((3:7-3:11)),
         sourcepos!((4:7-4:16)),
         sourcepos!((5:7-5:29)),
+        sourcepos!((6:7-6:21)),
+        sourcepos!((7:7-7:21)),
     ],
     r#"hello <https://example.com/fooo> world
 hello [foo](https://example.com) world
 hello [foo] world
 hello [bar][bar] world
 hello https://example.com/foo world
+hello www.example.com world
+hello foo@example.com world
 
 [foo]: https://example.com
 [bar]: https://example.com"#,
@@ -387,8 +394,10 @@ const SPOILERED_TEXT: TestCase = (
 after"#,
 );
 
+// NOTE: I've adjusted this from its original asserted sourcepos (2:1-2:8) while
+// fixing emphasis sourcepos. I am not even sure what it is, really.
 const ESCAPED_TAG: TestCase = (
-    &[sourcepos!((2:1-2:8))],
+    &[sourcepos!((2:2-2:8))],
     r#"before
 ||hello|
 after"#,
@@ -418,12 +427,6 @@ fn node_values() -> HashMap<NodeValueDiscriminants, TestCase> {
                     | DescriptionItem // end is 4:0
                     | DescriptionTerm // end is 3:0
                     | DescriptionDetails // end is 4:0
-                    | HtmlInline // end is 1:31 but should be 3:14
-                    | LineBreak // start is 1:15 but should be 1:13
-                    | Code // is 1:8-1:12 but should be 1:7-1:13
-                    | ThematicBreak // end is 4:0
-                    | Link // inconsistent between link types
-                    | Math // is 3:2-3:6 but should be 3:1-3:7
                     | Raw // unparseable
             )
         })