fn scan_while<F>(chars: &[char], mut i: usize, mut cond: F) -> usize
where
F: FnMut(char) -> bool,
{
while i < chars.len() && cond(chars[i]) {
i += 1;
}
i
}
fn collect_range(chars: &[char], start: usize, end: usize) -> String {
chars[start..end].iter().collect()
}
const BACKSLASH: char = '\\';
const BACKSLASH_BYTE: u8 = b'\\';
fn has_odd_backslash_escape(chars: &[char], mut idx: usize) -> bool {
let mut count = 0;
while idx > 0 {
idx -= 1;
if chars[idx] == BACKSLASH {
count += 1;
} else {
break;
}
}
count % 2 == 1
}
fn has_odd_backslash_escape_bytes(bytes: &[u8], mut idx: usize) -> bool {
let mut count = 0;
while idx > 0 {
idx -= 1;
if bytes[idx] == BACKSLASH_BYTE {
count += 1;
} else {
break;
}
}
count % 2 == 1
}
#[derive(Debug, PartialEq)]
pub enum Token<'a> {
Fence(&'a str),
Code {
raw: &'a str,
fence: &'a str,
code: &'a str,
},
Text(&'a str),
Newline,
}
fn parse_link_or_image(chars: &[char], mut i: usize) -> (String, usize) {
let start = i;
if chars[i] == '!' {
i += 1;
}
i += 1; i = scan_while(chars, i, |c| c != ']');
if i < chars.len() && chars[i] == ']' {
i += 1;
if i < chars.len() && chars[i] == '(' {
i += 1;
let mut depth = 1;
while i < chars.len() && depth > 0 {
match chars[i] {
'(' => depth += 1,
')' => depth -= 1,
_ => {}
}
i += 1;
}
return (collect_range(chars, start, i), i);
}
}
(collect_range(chars, start, start + 1), start + 1)
}
fn is_trailing_punctuation(c: char) -> bool {
matches!(
c,
'.' | ',' | ';' | ':' | '!' | '?' | '(' | ')' | ']' | '"' | '\''
)
}
pub(super) fn segment_inline(text: &str) -> Vec<String> {
let mut tokens = Vec::new();
let chars: Vec<char> = text.chars().collect();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c.is_whitespace() {
let start = i;
i = scan_while(&chars, i, char::is_whitespace);
tokens.push(collect_range(&chars, start, i));
} else if c == '`' {
if has_odd_backslash_escape(&chars, i) {
if let Some(last) = tokens.last_mut() {
last.push('`');
} else {
tokens.push(String::from("`"));
}
i += 1;
continue;
}
let start = i;
let fence_end = scan_while(&chars, i, |ch| ch == '`');
let fence_len = fence_end - start;
i = fence_end;
let mut end = i;
let mut closing = None;
while end < chars.len() {
let j = scan_while(&chars, end, |ch| ch == '`');
if j - end == fence_len && !has_odd_backslash_escape(&chars, end) {
closing = Some(j);
break;
}
end += 1;
}
if let Some(end_idx) = closing {
tokens.push(collect_range(&chars, start, end_idx));
i = end_idx;
} else {
tokens.push(collect_range(&chars, start, start + fence_len));
i = start + fence_len;
}
} else if c == '[' || (c == '!' && i + 1 < chars.len() && chars[i + 1] == '[') {
let (tok, mut new_i) = parse_link_or_image(&chars, i);
tokens.push(tok);
let punct_start = new_i;
new_i = scan_while(&chars, new_i, is_trailing_punctuation);
if new_i > punct_start {
tokens.push(collect_range(&chars, punct_start, new_i));
}
i = new_i;
} else {
let start = i;
i = scan_while(&chars, i, |ch| !ch.is_whitespace() && ch != '`');
tokens.push(collect_range(&chars, start, i));
}
}
tokens
}
fn next_token(line: &str, offset: usize) -> Option<(Token<'_>, usize)> {
if offset >= line.len() {
return None;
}
let rest = &line[offset..];
if rest.is_empty() {
return None;
}
let bytes = line.as_bytes();
let delim_len = rest.chars().take_while(|&c| c == '`').count();
if delim_len == 0 {
let mut search_offset = 0;
while let Some(pos) = rest[search_offset..].find('`') {
let candidate = search_offset + pos;
if has_odd_backslash_escape_bytes(bytes, offset + candidate) {
search_offset = candidate + 1;
continue;
}
if candidate == 0 {
break;
}
return Some((Token::Text(&rest[..candidate]), candidate));
}
return Some((Token::Text(rest), rest.len()));
}
if has_odd_backslash_escape_bytes(bytes, offset) {
if let Some(first) = rest.chars().next() {
let used = first.len_utf8();
return Some((Token::Text(&rest[..used]), used));
}
return None;
}
let fence = &rest[..delim_len];
let mut search_start = delim_len;
while let Some(pos) = rest[search_start..].find(fence) {
let candidate = search_start + pos;
if !has_odd_backslash_escape_bytes(bytes, offset + candidate) {
let raw_end = candidate + delim_len;
let code = &rest[delim_len..candidate];
let raw = &rest[..raw_end];
return Some((Token::Code { raw, fence, code }, raw_end));
}
search_start = candidate + 1;
}
Some((Token::Text(fence), delim_len))
}
fn tokenize_inline<'a, F>(line: &'a str, mut emit: F)
where
F: FnMut(Token<'a>),
{
let mut offset = 0;
while offset < line.len() {
if let Some((tok, used)) = next_token(line, offset) {
emit(tok);
if used == 0 {
break;
}
offset += used;
} else {
break;
}
}
}
fn push_newline_if_needed<I>(
tokens: &mut Vec<Token<'_>>,
lines: &mut std::iter::Peekable<I>,
had_trailing_newline: bool,
) where
I: Iterator,
{
if lines.peek().is_some() || (had_trailing_newline && lines.peek().is_none()) {
tokens.push(Token::Newline);
}
}
#[must_use]
pub fn tokenize_markdown(source: &str) -> Vec<Token<'_>> {
if source.is_empty() {
return Vec::new();
}
let mut tokens = Vec::new();
let had_trailing_newline = source.ends_with('\n');
let mut lines = source.lines().peekable();
let mut in_fence = false;
while let Some(line) = lines.next() {
if super::is_fence(line).is_some() {
tokens.push(Token::Fence(line));
push_newline_if_needed(&mut tokens, &mut lines, had_trailing_newline);
in_fence = !in_fence;
continue;
}
if in_fence {
tokens.push(Token::Fence(line));
push_newline_if_needed(&mut tokens, &mut lines, had_trailing_newline);
continue;
}
tokenize_inline(line, &mut |tok| tokens.push(tok));
push_newline_if_needed(&mut tokens, &mut lines, had_trailing_newline);
}
tokens
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn link_with_trailing_punctuation() {
let tokens = segment_inline("see [link](url).");
assert_eq!(tokens, vec", "."]);
}
#[test]
fn image_with_nested_parentheses() {
let tokens = segment_inline("c))");
assert_eq!(tokens, vecc))"]);
}
#[test]
fn inline_code_fences() {
let tokens = segment_inline("use ``cmd`` now");
assert_eq!(tokens, vec!["use", " ", "``cmd``", " ", "now"]);
}
#[test]
fn unmatched_backticks() {
let tokens = segment_inline("bad `code span");
assert_eq!(tokens, vec!["bad", " ", "`", "code", " ", "span"]);
}
#[test]
fn tokenize_marks_trailing_newline() {
let tokens = tokenize_markdown("foo\n");
assert_eq!(tokens, vec![Token::Text("foo"), Token::Newline]);
}
#[test]
fn tokenize_handles_crlf() {
let tokens = tokenize_markdown("foo\r\nbar");
assert_eq!(
tokens,
vec![Token::Text("foo"), Token::Newline, Token::Text("bar")]
);
}
#[test]
fn escaped_triple_backticks_are_text() {
let tokens = segment_inline(r"\`\`\`ignore");
assert_eq!(tokens, vec![r"\`", r"\`", r"\`", "ignore"]);
let tokens = tokenize_markdown(r"\`\`\`ignore");
assert_eq!(tokens, vec![Token::Text(r"\`\`\`ignore")]);
}
#[test]
fn escaped_inline_backtick_is_text() {
let tokens = segment_inline(r"foo\`bar");
assert_eq!(tokens, vec![r"foo\`", "bar"]);
let tokens = tokenize_markdown(r"foo\`bar");
assert_eq!(tokens, vec![Token::Text(r"foo\`bar")]);
}
#[test]
fn escaped_backtick_adjacent_to_multibyte_characters_is_text() {
let tokens = segment_inline(r"ß\`å");
assert_eq!(tokens, vec![r"ß\`", "å"]);
let tokens = tokenize_markdown(r"ß\`å");
assert_eq!(tokens, vec![Token::Text(r"ß\`å")]);
let tokens = segment_inline(r"前\`后");
assert_eq!(tokens, vec![r"前\`", "后"]);
let tokens = tokenize_markdown(r"前\`后");
assert_eq!(tokens, vec![Token::Text(r"前\`后")]);
}
}