base-markdown 0.1.0

CommonMark compliant markdown parser in Rust with ASTs and extensions
Documentation
// Released under the MIT License.
// Copyright, 2026, by Samuel Williams.

//! Definition metadata scanned over source fragments, without grammar tokens.
use crate::byte_scan::AsciiSet;
use crate::stream::Text;
use crate::value_builder::{decode_string_with_options, ValueBuilder};
use crate::ParseOptions;
use core::ops::Range;

static LABEL_MARKERS: AsciiSet = AsciiSet::new(b"[]\\\r\n \t");
static DOUBLE_TITLE_MARKERS: AsciiSet = AsciiSet::new(b"\"\\\r\n");
static SINGLE_TITLE_MARKERS: AsciiSet = AsciiSet::new(b"'\\\r\n");
static PAREN_TITLE_MARKERS: AsciiSet = AsciiSet::new(b"()\\\r\n");

pub(super) struct Metadata {
    pub label: Text<'static>,
    pub destination: Text<'static>,
    pub title: Option<Text<'static>>,
    pub end: usize,
}

/// Lookahead retains physical offsets across stripped container prefixes.
#[derive(Clone)]
struct Cursor<'a, I> {
    source: &'a str,
    remaining: I,
    range: Range<usize>,
    at: usize,
    consumed: usize,
}

impl<'a, I: Iterator<Item = Range<usize>> + Clone> Cursor<'a, I> {
    fn new(source: &'a str, mut remaining: I) -> Self {
        let range = remaining.next().unwrap_or(0..0);
        let at = range.start;
        Self {
            source,
            remaining,
            range,
            at,
            consumed: at,
        }
    }
    fn byte(&self) -> Option<u8> {
        (self.at < self.range.end).then(|| self.source.as_bytes()[self.at])
    }
    fn bump(&mut self) {
        self.advance(1);
    }
    fn advance(&mut self, count: usize) {
        self.at += count;
        self.consumed = self.at;
        while self.at == self.range.end {
            let Some(range) = self.remaining.next() else {
                break;
            };
            self.at = range.start;
            self.range = range;
        }
    }
    fn spaces(&mut self) {
        while matches!(self.byte(), Some(b' ' | b'\t')) {
            self.bump();
        }
    }
    fn newline(&mut self) -> bool {
        match self.byte() {
            Some(b'\r') => {
                self.bump();
                if self.byte() == Some(b'\n') {
                    self.bump();
                }
            }
            Some(b'\n') => self.bump(),
            _ => return false,
        }
        true
    }
    fn whitespace(&mut self) {
        self.spaces();
        if self.newline() {
            self.spaces();
        }
    }
    fn line_end(&self) -> Option<usize> {
        let bytes = self.source.as_bytes();
        let mut end = self.at;
        while end < self.range.end && matches!(bytes[end], b' ' | b'\t') {
            end += 1;
        }
        // A line-ending check must not request the following fragment: most
        // definitions finish here without inspecting its container prefixes.
        match bytes.get(end).filter(|_| end < self.range.end) {
            None => Some(end),
            Some(b'\n') => Some(end + 1),
            Some(b'\r') => Some(end + 1 + usize::from(bytes.get(end + 1) == Some(&b'\n'))),
            _ => None,
        }
    }
    fn value(&self, end: usize, options: Option<&ParseOptions>) -> Text<'static> {
        if end <= self.range.end {
            let range = self.at..end;
            return match options {
                Some(options) => decode_string_with_options(self.source, range, options),
                None => Text::Source(range),
            };
        }
        let mut result = ValueBuilder::new(self.source);
        let mut cursor = self.clone();
        let mut continuation = false;
        while cursor.at < end && cursor.at < cursor.range.end {
            let range = cursor.at..end.min(cursor.range.end);
            if let Some(options) = options {
                let mut start = range.start;
                // Each fragment is one physical line. Continuation indentation
                // is syntax padding, not part of a title's semantic value.
                if continuation {
                    while start < range.end && matches!(self.source.as_bytes()[start], b' ' | b'\t')
                    {
                        start += 1;
                    }
                }
                result.append(decode_string_with_options(
                    self.source,
                    start..range.end,
                    options,
                ));
            } else {
                result.append(Text::Source(range.clone()));
            }
            if range.end == end {
                break;
            }
            let range = cursor
                .remaining
                .next()
                .expect("value endpoint was scanned in these fragments");
            cursor.at = range.start;
            cursor.range = range;
            continuation = true;
        }
        result.finish()
    }
}

/// Content fragments stop at block interruptions. Failed next-line titles roll back to the destination-only checkpoint.
pub(super) fn scan(
    source: &str,
    fragments: impl Iterator<Item = Range<usize>> + Clone,
    options: &ParseOptions,
) -> Option<Metadata> {
    let mut cursor = Cursor::new(source, fragments);
    cursor.spaces();
    // Block dispatch only calls this scanner at an opening label bracket.
    cursor.bump();
    let label_start = cursor.clone();
    let mut content = false;
    let mut size = 0;
    let mut column = None;
    loop {
        if size > crate::util::constant::LINK_REFERENCE_SIZE_MAX {
            return None;
        }
        match cursor.byte()? {
            b'[' => return None,
            b']' => break,
            b'\\' => {
                content = true;
                cursor.bump();
                size += 1;
                if let Some(column) = &mut column {
                    *column += 1;
                }
                if matches!(cursor.byte(), Some(b'[' | b']' | b'\\')) {
                    cursor.bump();
                    size += 1;
                    if let Some(column) = &mut column {
                        *column += 1;
                    }
                }
            }
            b'\r' | b'\n' => {
                cursor.newline();
                column = None;
                cursor.spaces();
                if matches!(cursor.byte(), None | Some(b'\r' | b'\n')) {
                    return None;
                }
            }
            byte => {
                content |= !matches!(byte, b' ' | b'\t');
                let count = if matches!(byte, b' ' | b'\t') {
                    1
                } else {
                    let end = cursor
                        .range
                        .end
                        .min(cursor.at + crate::util::constant::LINK_REFERENCE_SIZE_MAX - size + 1);
                    LABEL_MARKERS.find(&source.as_bytes()[cursor.at..end])
                };
                let width = if byte == b'\t' {
                    let column = *column.get_or_insert_with(|| {
                        let bytes = source.as_bytes();
                        let start = memchr::memrchr2(b'\r', b'\n', &bytes[..cursor.at])
                            .map_or(0, |index| index + 1);
                        bytes[start..cursor.at].iter().fold(0, |column, byte| {
                            column + if *byte == b'\t' { 4 - column % 4 } else { 1 }
                        })
                    });
                    4 - column % 4
                } else {
                    count
                };
                size += width;
                if let Some(column) = &mut column {
                    *column += width;
                }
                cursor.advance(count);
            }
        }
    }
    if !content {
        return None;
    }
    let label_end = cursor.at;
    cursor.bump();
    if cursor.byte() != Some(b':') {
        return None;
    }
    cursor.bump();
    cursor.whitespace();
    let literal = cursor.byte() == Some(b'<');
    if literal {
        cursor.bump();
    }
    // Destinations cannot cross a physical line, so update fragment state once after scanning this contiguous source range.
    let destination_start = cursor.at;
    let bytes = &source.as_bytes()[destination_start..cursor.range.end];
    let mut length = 0;
    let mut depth = 0usize;
    loop {
        match bytes.get(length).copied() {
            Some(b'>') if literal => break,
            None | Some(b'\r' | b'\n' | b'<') if literal => return None,
            None | Some(b' ' | b'\t' | b'\r' | b'\n') if !literal => break,
            Some(b'\\') => {
                length += 1;
                if bytes.get(length).is_some_and(u8::is_ascii_punctuation) {
                    length += 1;
                }
            }
            Some(b'(') if !literal => {
                depth += 1;
                length += 1;
            }
            Some(b')') if !literal => {
                if depth == 0 {
                    return None;
                }
                depth -= 1;
                length += 1;
            }
            Some(1..=0x1f | 0x7f) if !literal => return None,
            Some(_) => length += 1,
            None => unreachable!("both destination forms handle end of input above"),
        }
    }
    let destination_end = destination_start + length;
    if depth != 0 || (!literal && length == 0) {
        return None;
    }
    cursor.advance(length + usize::from(literal));
    let checkpoint = cursor.line_end();
    let after_destination = cursor.consumed;
    cursor.whitespace();
    let mut title = None;
    let end = if cursor.consumed > after_destination
        && matches!(cursor.byte(), Some(b'"' | b'\'' | b'('))
    {
        let marker = match cursor.byte().unwrap() {
            b'(' => b')',
            byte => byte,
        };
        let title_markers = match marker {
            b'"' => &DOUBLE_TITLE_MARKERS,
            b'\'' => &SINGLE_TITLE_MARKERS,
            _ => &PAREN_TITLE_MARKERS,
        };
        cursor.bump();
        let title_start = cursor.clone();
        let mut valid = true;
        loop {
            match cursor.byte() {
                Some(byte) if byte == marker => break,
                None => {
                    valid = false;
                    break;
                }
                Some(b'(') if marker == b')' => {
                    valid = false;
                    break;
                }
                Some(b'\\') => {
                    cursor.bump();
                    if cursor
                        .byte()
                        .is_some_and(|byte| byte.is_ascii_punctuation())
                    {
                        cursor.bump();
                    }
                }
                Some(b'\r' | b'\n') => {
                    cursor.newline();
                    cursor.spaces();
                    if matches!(cursor.byte(), None | Some(b'\r' | b'\n')) {
                        valid = false;
                        break;
                    }
                }
                Some(_) => {
                    let count = title_markers.find(&source.as_bytes()[cursor.at..cursor.range.end]);
                    cursor.advance(count);
                }
            }
        }
        if valid {
            let title_end = cursor.at;
            cursor.bump();
            if let Some(end) = cursor.line_end() {
                if title_end > title_start.at {
                    title = Some(title_start.value(title_end, Some(options)));
                }
                end
            } else {
                checkpoint?
            }
        } else {
            checkpoint?
        }
    } else {
        checkpoint?
    };
    Some(Metadata {
        label: label_start.value(label_end, None),
        destination: decode_string_with_options(
            source,
            destination_start..destination_end,
            options,
        ),
        title,
        end,
    })
}