mod link_span;
mod parsing;
mod scanning;
pub(crate) use link_span::link_or_image_span;
use parsing::{
handle_backtick_fence,
is_trailing_punctuation,
looks_like_image_start,
parse_link_or_image,
};
#[cfg(test)]
pub(crate) use scanning::continuation_begins_with_closing_fence;
#[cfg(test)]
pub(crate) use scanning::has_unclosed_code_span;
use scanning::{bracket_follows_escaped_bang, collect_range, scan_code_suffix_end, scan_while};
pub(crate) use scanning::{
has_odd_backslash_escape_bytes,
opening_fence_run_len,
parse_open_code_span,
position_after_close,
scan_continuation_span_state,
};
#[derive(Debug, PartialEq)]
pub enum Token<'a> {
Fence(&'a str),
Code {
raw: &'a str,
fence: &'a str,
code: &'a str,
},
Text(&'a str),
Newline,
}
pub(super) fn segment_inline(text: &str) -> Vec<String> {
let mut tokens = Vec::new();
let bytes = text.as_bytes();
let mut i = 0;
while i < text.len() {
let Some(ch) = text[i..].chars().next() else {
break;
};
if ch.is_whitespace() {
let start = i;
i = scan_while(text, i, char::is_whitespace);
tokens.push(collect_range(text, start, i));
continue;
}
if ch == '`' {
if has_odd_backslash_escape_bytes(bytes, i) {
append_escaped_backtick(&mut tokens);
i += ch.len_utf8();
continue;
}
let (token, new_i) = handle_backtick_fence(text, i);
let (token, new_i) = extend_closed_code_token(text, i, token, new_i);
tokens.push(token);
i = new_i;
continue;
}
let looks_like_image = looks_like_image_start(text, i, ch);
let is_escaped = has_odd_backslash_escape_bytes(bytes, i);
if (ch == '[' || looks_like_image) && !is_escaped {
let (tok, mut new_i) = parse_link_or_image(text, i);
tokens.push(tok);
let punct_start = new_i;
new_i = scan_trailing_punctuation_end(text, new_i);
if new_i > punct_start {
tokens.push(collect_range(text, punct_start, new_i));
}
i = new_i;
continue;
}
let start = i;
i = scan_plain_text_end(text, bytes, i);
tokens.push(collect_range(text, start, i));
}
tokens
}
fn scan_trailing_punctuation_end(text: &str, mut index: usize) -> usize {
while index < text.len() {
let Some(current) = text[index..].chars().next() else {
break;
};
if starts_inline_citation(text, index) {
break;
}
if !is_trailing_punctuation(current) {
break;
}
index += current.len_utf8();
}
index
}
fn starts_inline_citation(text: &str, index: usize) -> bool {
text.get(index..).is_some_and(|tail| tail.starts_with("(["))
}
fn append_escaped_backtick(tokens: &mut Vec<String>) {
if let Some(last) = tokens.last_mut() {
last.push('`');
} else {
tokens.push(String::from("`"));
}
}
fn scan_plain_text_end(text: &str, bytes: &[u8], mut index: usize) -> usize {
if starts_inline_citation(text, index) && !has_odd_backslash_escape_bytes(bytes, index) {
return index + 1;
}
while index < text.len() {
let Some(current) = text[index..].chars().next() else {
break;
};
if current.is_whitespace() || current == '`' {
break;
}
let current_escaped = has_odd_backslash_escape_bytes(bytes, index);
if should_stop_plain_text(text, bytes, index, (current, current_escaped)) {
break;
}
index += current.len_utf8();
}
index
}
fn should_stop_plain_text(text: &str, bytes: &[u8], index: usize, current: (char, bool)) -> bool {
let (ch, is_escaped) = current;
if ch == '[' {
return !is_escaped
&& !bracket_follows_escaped_bang(bytes, index)
&& !bracket_follows_escaped_open_paren(bytes, index);
}
if ch == '(' {
return !is_escaped && starts_inline_citation(text, index);
}
looks_like_image_start(text, index, ch) && !is_escaped
}
fn bracket_follows_escaped_open_paren(bytes: &[u8], index: usize) -> bool {
index.checked_sub(1).is_some_and(|previous| {
bytes[previous] == b'(' && has_odd_backslash_escape_bytes(bytes, previous)
})
}
fn is_closed_inline_code_span(token: &str) -> bool {
let fence_len = token.chars().take_while(|&ch| ch == '`').count();
fence_len > 0 && token.len() > fence_len * 2 && token.ends_with(&"`".repeat(fence_len))
}
fn extend_closed_code_token(
text: &str,
start: usize,
token: String,
code_end: usize,
) -> (String, usize) {
if !is_closed_inline_code_span(&token) {
return (token, code_end);
}
let suffix_end = scan_code_suffix_end(text, code_end);
if suffix_end > code_end {
(collect_range(text, start, suffix_end), suffix_end)
} else {
(token, code_end)
}
}
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];
if let Some(raw_end) = position_after_close(rest, delim_len, delim_len) {
let candidate = raw_end - delim_len;
let token = &rest[..raw_end];
let suffix_end = if is_closed_inline_code_span(token) {
scan_code_suffix_end(rest, raw_end)
} else {
raw_end
};
let code = &rest[delim_len..candidate];
let raw = &rest[..suffix_end];
return Some((Token::Code { raw, fence, code }, suffix_end));
}
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 fence_tracker = super::FenceTracker::default();
while let Some(line) = lines.next() {
let fence = fence_tracker.observe_source_line(line);
if fence.is_fence_marker || fence.is_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)]
#[path = "mod_tests.rs"]
mod tests;