///| Inline-level markdown parser
///| Parses emphasis, strong, code, links, images, etc.
///|
/// Check if inlines array contains a link (used for nested link detection)
fn contains_link(inlines : Array[Inline]) -> Bool {
for inline in inlines {
match inline {
Inline::Link(..) | Inline::RefLink(..) => return true
Inline::Emphasis(children~, ..)
| Inline::Strong(children~, ..)
| Inline::Strikethrough(children~, ..) =>
if contains_link(children) {
return true
}
_ => ()
}
}
false
}
///| Trim trailing spaces from the last Text element in inlines array
///|
/// Optimized to avoid allocation when no trimming is needed
fn trim_trailing_space_from_last_text(inlines : Array[Inline]) -> Unit {
guard inlines.length() > 0 else { return }
guard inlines[inlines.length() - 1] is Inline::Text(content~, span~) else {
return
}
let len = content.length()
guard len > 0 else { return }
// Quick check: does it end with space?
guard content.unsafe_get(len - 1) == ' ' else { return }
// Find last non-space character
let mut end = len - 1
while end > 0 && content.unsafe_get(end - 1) == ' ' {
end = end - 1
}
// Now trim
let _ = inlines.pop()
if end > 0 {
inlines.push(
Inline::Text(content=content.unsafe_substring(start=0, end~), span~),
)
}
}
///| Parse inline content from text
///| When strict=true, uses delimiter stack algorithm for full CommonMark compliance
///|
/// When strict=false (default), uses fast single-pass parser
pub fn parse_inlines(
text : String,
strict? : Bool = false,
wikilinks? : Bool = false,
) -> Array[Inline] {
if strict {
// In strict mode, always use delimiter stack for full CommonMark compliance
parse_inlines_with_delimiter_stack(text, wikilinks~)
} else {
parse_inlines_fast(text, wikilinks~)
}
}
///|
/// Fast path: simple inline parsing without delimiter stack
fn parse_inlines_fast(
text : String,
wikilinks? : Bool = false,
) -> Array[Inline] {
let scanner = Scanner::new(text)
let parser = InlineParser::new(scanner, wikilinks~)
parser.parse()
}
///|
/// Inline parser state
priv struct InlineParser {
scanner : Scanner
wikilinks : Bool
}
///|
fn InlineParser::new(
scanner : Scanner,
wikilinks? : Bool = false,
) -> InlineParser {
{ scanner, wikilinks }
}
///|
/// Parse all inline content
fn InlineParser::parse(self : InlineParser) -> Array[Inline] {
let inlines : Array[Inline] = []
while !self.scanner.is_eof() {
match self.parse_inline() {
Some(inline) => {
// For soft/hard breaks, trim trailing whitespace from previous text
match inline {
Inline::SoftBreak(..) | Inline::HardBreak(..) =>
trim_trailing_space_from_last_text(inlines)
_ => ()
}
inlines.push(inline)
}
None => {
// Consume single character as text
let start = self.scanner.pos
match self.scanner.consume() {
Some(c) => {
// Merge with previous text if possible
let text = String::make(1, c)
match inlines.last() {
Some(Inline::Text(content~, span~)) => {
// Merge with previous text
let _ = inlines.pop()
inlines.push(
Inline::Text(
content=content + text,
span=Span::new(span.from, self.scanner.pos),
),
)
}
_ =>
inlines.push(
Inline::Text(
content=text,
span=Span::new(start, self.scanner.pos),
),
)
}
}
None => break
}
}
}
}
inlines
}
///|
/// Parse a single inline element
fn InlineParser::parse_inline(self : InlineParser) -> Inline? {
let start = self.scanner.pos
match self.scanner.peek() {
// Backslash escape
Some('\\') => self.try_parse_escape(start)
// Code span
Some('`') => self.try_parse_code_span(start)
// Emphasis/Strong with asterisk
Some('*') => self.try_parse_emphasis(start, '*')
// Emphasis/Strong with underscore
Some('_') => self.try_parse_emphasis(start, '_')
// Strikethrough
Some('~') => self.try_parse_strikethrough(start)
// WikiLink, Footnote reference, Link, or Image
Some('[') =>
if self.wikilinks && char_is(self.scanner.peek_at(1), '[') {
match self.try_parse_wikilink(start) {
Some(wikilink) => Some(wikilink)
None => self.try_parse_link(start)
}
} else if char_is(self.scanner.peek_at(1), '^') {
// Check for footnote reference [^label]
match self.try_parse_footnote_reference(start) {
Some(fn_ref) => Some(fn_ref)
None => self.try_parse_link(start)
}
} else {
self.try_parse_link(start)
}
Some('!') => self.try_parse_image(start)
// Raw inline HTML or autolink
Some('<') =>
match self.try_parse_html_comment(start) {
Some(comment) => Some(comment)
None => self.try_parse_autolink(start)
}
// Hard break (two spaces at end of line)
Some(' ') => self.try_parse_hard_break(start)
// Soft break (newline)
Some('\n') => {
self.scanner.advance(1)
// Skip leading whitespace after newline (CommonMark spec)
while char_is(self.scanner.peek(), ' ') {
self.scanner.advance(1)
}
Some(Inline::SoftBreak(span=Span::new(start, self.scanner.pos)))
}
_ => None
}
}
///|
/// Try to parse backslash escape
fn InlineParser::try_parse_escape(self : InlineParser, start : Int) -> Inline? {
self.scanner.advance(1) // Skip backslash
match self.scanner.peek() {
Some('\n') => {
// Backslash + newline = hard break
self.scanner.advance(1)
// Skip leading whitespace after newline (CommonMark spec)
while char_is(self.scanner.peek(), ' ') {
self.scanner.advance(1)
}
Some(
Inline::HardBreak(
style=HardBreakStyle::Backslash,
span=Span::new(start, self.scanner.pos),
),
)
}
Some(c) if is_punctuation(c) => {
// Escaped punctuation becomes literal text
self.scanner.advance(1)
Some(
Inline::Text(
content=String::make(1, c),
span=Span::new(start, self.scanner.pos),
),
)
}
_ =>
// Not a valid escape, return backslash as text
Some(Inline::Text(content="\\", span=Span::new(start, self.scanner.pos)))
}
}
///|
/// Try to parse code span
fn InlineParser::try_parse_code_span(
self : InlineParser,
start : Int,
) -> Inline? {
// Count opening backticks
let backtick_count = self.scanner.count_char('`')
if backtick_count == 0 {
return None
}
self.scanner.advance(backtick_count)
// Find closing backticks
let content_buf = StringBuilder::new()
while !self.scanner.is_eof() {
let closing_count = self.scanner.count_char('`')
if closing_count == backtick_count {
// Found matching closing backticks
let content = content_buf.to_string()
self.scanner.advance(closing_count)
// Trim single leading/trailing space if present and content has them
// But NOT if content is entirely spaces
let trimmed = if content.length() >= 2 {
let first = content.get_char(0)
let last = content.get_char(content.length() - 1)
if first == Some(' ') && last == Some(' ') && !is_all_spaces(content) {
content.unsafe_substring(start=1, end=content.length() - 1)
} else {
content
}
} else {
content
}
return Some(
Inline::Code(
content=trimmed,
backtick_count~,
span=Span::new(start, self.scanner.pos),
),
)
} else if closing_count > 0 {
// Wrong number of backticks, include them in content
for i = 0; i < closing_count; i = i + 1 {
content_buf.write_char('`')
}
self.scanner.advance(closing_count)
} else {
// Regular character
match self.scanner.consume() {
Some(c) => content_buf.write_char(c)
None => break
}
}
}
// No closing backticks found, restore position
self.scanner.restore(start)
None
}
///|
/// Try to parse strikethrough
fn InlineParser::try_parse_strikethrough(
self : InlineParser,
start : Int,
) -> Inline? {
// Need exactly two tildes
if self.scanner.count_char('~') < 2 {
return None
}
self.scanner.advance(2)
// Parse content until closing ~~
let content_buf = StringBuilder::new()
let content_start = self.scanner.pos
while !self.scanner.is_eof() {
if self.scanner.count_char('~') >= 2 {
// Found closing
let content = content_buf.to_string()
if content.is_empty() {
self.scanner.restore(start)
return None
}
self.scanner.advance(2)
let children = [
Inline::Text(
content~,
span=Span::new(content_start, self.scanner.pos - 2),
),
]
return Some(
Inline::Strikethrough(
children~,
span=Span::new(start, self.scanner.pos),
),
)
}
match self.scanner.consume() {
Some('\n') => break // Newline ends strikethrough
Some(c) => content_buf.write_char(c)
None => break
}
}
self.scanner.restore(start)
None
}
///|
/// Try to parse an inline HTML comment
fn InlineParser::try_parse_html_comment(
self : InlineParser,
start : Int,
) -> Inline? {
if !self.scanner.matches("") {
self.scanner.advance(3)
return Some(
Inline::HtmlInline(
html=self.scanner.substring(start, self.scanner.pos),
span=Span::new(start, self.scanner.pos),
),
)
}
self.scanner.advance(1)
}
self.scanner.restore(start)
None
}
///|
/// Try to parse autolink
fn InlineParser::try_parse_autolink(
self : InlineParser,
start : Int,
) -> Inline? {
self.scanner.advance(1) // Skip <
let url_buf = StringBuilder::new()
let mut is_email = false
while !self.scanner.is_eof() {
match self.scanner.peek() {
Some('>') => {
self.scanner.advance(1)
let url = url_buf.to_string()
// Check if it's an email
if url.contains("@") && !url.has_prefix("http") {
is_email = true
}
return Some(
Inline::Autolink(
url~,
is_email~,
span=Span::new(start, self.scanner.pos),
),
)
}
Some(' ') | Some('\t') | Some('\n') => {
// Not a valid autolink
self.scanner.restore(start)
return None
}
Some(c) => {
url_buf.write_char(c)
self.scanner.advance(1)
}
None => break
}
}
self.scanner.restore(start)
None
}
///|
/// Try to parse hard break (two or more spaces followed by newline)
fn InlineParser::try_parse_hard_break(
self : InlineParser,
start : Int,
) -> Inline? {
let space_count = self.scanner.count_char(' ')
if space_count < 2 {
return None
}
self.scanner.advance(space_count)
if char_is(self.scanner.peek(), '\n') {
self.scanner.advance(1)
// Skip leading whitespace after newline (CommonMark spec)
while char_is(self.scanner.peek(), ' ') {
self.scanner.advance(1)
}
return Some(
Inline::HardBreak(
style=HardBreakStyle::TwoSpaces,
span=Span::new(start, self.scanner.pos),
),
)
}
// Not followed by newline, restore and return None
self.scanner.restore(start)
None
}
///|
/// Check if a string is entirely space characters
fn is_all_spaces(s : String) -> Bool {
for c in s {
if c != ' ' {
return false
}
}
true
}