Skip to content
← Back to Examples

Simple Markup Parser

RustRubydata formatsbeginner

Parse a subset of Markdown-like markup with headers, lists, and inline formatting (bold, italic, code).

Key Concepts

  • inline formatting
  • block elements
  • header levels
  • list detection
  • mixed content

Why Parse This Format?

Lightweight markup languages are essential for documentation, blogging, and content management. Understanding how to parse them enables building custom documentation tools.

Use Cases

  • Documentation generation
  • Blog and CMS content parsing
  • README file processing
  • Wiki markup conversion
  • Custom markup extensions

Input Format

Simple markup with ATX-style headers (#, ##, ###), unordered lists (- or *), and inline formatting (**bold**, *italic*, `code`).

# Header | - list item | **bold** | *italic* | `code`

Examples

# Main TitleVALID

Level 1 header

## SectionVALID

Level 2 header

- List itemVALID

Unordered list item

Text with **bold** and *italic*VALID

Inline formatting

Use `code` inlineVALID

Inline code

# Title\n\nParagraph text.\n\n- Item 1\n- Item 2VALID

Full document

Output Format

The parser returns a MarkupDocument containing MarkupElement enum values for each parsed element.

Structure

MarkupDocument

Struct with elements: Vec<MarkupElement>

MarkupElement

Enum: Header{level, text}, Paragraph(String), ListItem(String), Bold(String), Italic(String), Code(String)

Visual Overview

Simple Markup Parser diagram

How the Code Works

This section explains how the parsing rules map to the format semantics.

Inline Formatting

.rule(
    "bold",
    seq(vec![dynamic(str("**")), dynamic(re("[^*]+")), dynamic(str("**"))]),
)
.rule(
    "italic",
    seq(vec![dynamic(str("*")), dynamic(re("[^*]+")), dynamic(str("*"))]),
)
.rule(
    "code",
    seq(vec![dynamic(str("`")), dynamic(re("[^`]+")), dynamic(str("`"))]),
)

Inline elements use delimiter characters: Each format has opening and closing delimiters with content between.

Header Parsing

.rule("h1", seq(vec![dynamic(str("# ")), dynamic(re("[^\\n]+"))]))
.rule("h2", seq(vec![dynamic(str("## ")), dynamic(re("[^\\n]+"))]))
.rule("h3", seq(vec![dynamic(str("### ")), dynamic(re("[^\\n]+"))]))

Headers use `#` prefix for levels 1-3: The space after `#` is required; header text continues to end of line.

List Item Parsing

.rule(
    "list_item",
    seq(vec![
        dynamic(re("[ \t]*[-*][ \t]+")),  // marker with required space
        dynamic(re("[^\\n]+")),           // item text
    ]),
)

List items use `-` or `*` prefixes: Whitespace before the marker allows nested lists (visually).

Block Element Selection

.rule(
    "block",
    choice(vec![
        dynamic(seq(vec![dynamic(str("# ")), dynamic(re("[^\\n]+"))])),     // h1
        dynamic(seq(vec![dynamic(str("## ")), dynamic(re("[^\\n]+"))])),    // h2
        dynamic(seq(vec![dynamic(str("### ")), dynamic(re("[^\\n]+"))])),   // h3
        dynamic(seq(vec![dynamic(re("[ \t]*[-*][ \t]+")), dynamic(re("[^\\n]+"))])),  // list
        dynamic(re("[^\\n]+")),  // paragraph
    ]),
)

Blocks are parsed in priority order: Headers are tried first; if none match, the line is a paragraph.

Inline Formatting Processing

match c {
    '*' if chars.peek() == Some(&'*') => {
        chars.next();  // consume second *
        // Read until closing **
        let mut bold_text = String::new();
        while let Some(&next) = chars.peek() {
            if next == '*' {
                chars.next();
                if chars.peek() == Some(&'*') {
                    chars.next();
                    break;
                }
                bold_text.push('*');
            } else {
                bold_text.push(chars.next().unwrap());
            }
        }
        elements.push(MarkupElement::Bold(bold_text));
    }
    // ... italic and code similar
}

Inline elements are parsed character by character:

Inline Formatting Processing

match c {
    '*' if chars.peek() == Some(&'*') => {
        chars.next();  // consume second *
        // Read until closing **
        let mut bold_text = String::new();
        while let Some(&next) = chars.peek() {
            if next == '*' {
                chars.next();
                if chars.peek() == Some(&'*') {
                    chars.next();
                    break;
                }
                bold_text.push('*');
            } else {
                bold_text.push(chars.next().unwrap());
            }
        }
        elements.push(MarkupElement::Bold(bold_text));
    }
    // ... italic and code similar
}

Inline elements are parsed character by character:

Source Code

Rust
//! Simple Markup Parser Example
//!
//! This example demonstrates parsing a simple markup language (like Markdown subset).
//! Shows handling of inline formatting, headers, and lists.
//!
//! Run with: cargo run --example markup_parser --no-default-features

#![allow(clippy::get_first)]

use parsanol::portable::{
    parser_dsl::{choice, dynamic, re, seq, str, GrammarBuilder},
    AstArena, Grammar, PortableParser,
};

/// Build a markup grammar
fn build_markup_grammar() -> Grammar {
    GrammarBuilder::new()
        // Inline: bold **text**, italic *text*, code `text`
        .rule(
            "bold",
            seq(vec![
                dynamic(str("**")),
                dynamic(re("[^*]+")),
                dynamic(str("**")),
            ]),
        )
        .rule(
            "italic",
            seq(vec![
                dynamic(str("*")),
                dynamic(re("[^*]+")),
                dynamic(str("*")),
            ]),
        )
        .rule(
            "code",
            seq(vec![
                dynamic(str("`")),
                dynamic(re("[^`]+")),
                dynamic(str("`")),
            ]),
        )
        // Header: # to ######
        .rule("h1", seq(vec![dynamic(str("# ")), dynamic(re("[^\\n]+"))]))
        .rule("h2", seq(vec![dynamic(str("## ")), dynamic(re("[^\\n]+"))]))
        .rule(
            "h3",
            seq(vec![dynamic(str("### ")), dynamic(re("[^\\n]+"))]),
        )
        // List item: - or * followed by text
        .rule(
            "list_item",
            seq(vec![
                dynamic(re("[ \t]*[-*][ \t]+")),
                dynamic(re("[^\\n]+")),
            ]),
        )
        // Paragraph: text until blank line or EOF
        .rule("paragraph", re("[^\\n]+(\\n[^\\n]+)*"))
        // Document: any of the above
        .rule(
            "block",
            choice(vec![
                dynamic(seq(vec![dynamic(str("# ")), dynamic(re("[^\\n]+"))])),
                dynamic(seq(vec![dynamic(str("## ")), dynamic(re("[^\\n]+"))])),
                dynamic(seq(vec![dynamic(str("### ")), dynamic(re("[^\\n]+"))])),
                dynamic(seq(vec![
                    dynamic(re("[ \t]*[-*][ \t]+")),
                    dynamic(re("[^\\n]+")),
                ])),
                dynamic(re("[^\\n]+")),
            ]),
        )
        .build()
}

/// Markup element types
#[derive(Debug, Clone)]
pub enum MarkupElement {
    /// Header with level and text
    Header { level: u8, text: String },
    /// Paragraph with raw text
    Paragraph(String),
    /// List item with text
    ListItem(String),
    /// Bold text
    Bold(String),
    /// Italic text
    Italic(String),
    /// Code inline
    Code(String),
}

impl std::fmt::Display for MarkupElement {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            MarkupElement::Header { level, text } => {
                write!(f, "H{}: {}", level, text)
            }
            MarkupElement::Paragraph(text) => write!(f, "P: {}", text),
            MarkupElement::ListItem(text) => write!(f, "LI: {}", text),
            MarkupElement::Bold(text) => write!(f, "**{}**", text),
            MarkupElement::Italic(text) => write!(f, "*{}*", text),
            MarkupElement::Code(text) => write!(f, "`{}`", text),
        }
    }
}

/// Parsed markup document
#[derive(Debug, Clone, Default)]
pub struct MarkupDocument {
    pub elements: Vec<MarkupElement>,
}

/// Parse a markup string into structured elements
pub fn parse_markup(input: &str) -> Result<MarkupDocument, String> {
    let grammar = build_markup_grammar();
    let mut arena = AstArena::for_input(input.len());
    let mut parser = PortableParser::new(&grammar, input, &mut arena);
    let _ast = parser
        .parse()
        .map_err(|e| format!("Parse error: {:?}", e))?;

    // Manual parsing for clarity
    let mut doc = MarkupDocument::default();

    for line in input.lines() {
        let trimmed = line.trim();

        // Skip empty lines
        if trimmed.is_empty() {
            continue;
        }

        // Headers
        if let Some(stripped) = trimmed.strip_prefix("### ") {
            doc.elements.push(MarkupElement::Header {
                level: 3,
                text: stripped.to_string(),
            });
        } else if let Some(stripped) = trimmed.strip_prefix("## ") {
            doc.elements.push(MarkupElement::Header {
                level: 2,
                text: stripped.to_string(),
            });
        } else if let Some(stripped) = trimmed.strip_prefix("# ") {
            doc.elements.push(MarkupElement::Header {
                level: 1,
                text: stripped.to_string(),
            });
        }
        // List items
        else if let Some(stripped) = trimmed.strip_prefix("- ") {
            doc.elements
                .push(MarkupElement::ListItem(stripped.to_string()));
        } else if let Some(stripped) = trimmed.strip_prefix("* ") {
            doc.elements
                .push(MarkupElement::ListItem(stripped.to_string()));
        }
        // Regular paragraph
        else {
            doc.elements
                .push(MarkupElement::Paragraph(trimmed.to_string()));
        }
    }

    Ok(doc)
}

/// Parse inline formatting (bold, italic, code)
pub fn parse_inline(text: &str) -> Vec<MarkupElement> {
    let mut elements = Vec::new();
    let mut chars = text.chars().peekable();
    let mut current = String::new();

    while let Some(c) = chars.next() {
        match c {
            '*' if chars.peek() == Some(&'*') => {
                chars.next(); // consume second *
                if !current.is_empty() {
                    elements.push(MarkupElement::Paragraph(current.clone()));
                    current.clear();
                }
                // Read until closing **
                let mut bold_text = String::new();
                while let Some(&next) = chars.peek() {
                    if next == '*' {
                        chars.next();
                        if chars.peek() == Some(&'*') {
                            chars.next();
                            break;
                        }
                        bold_text.push('*');
                    } else {
                        bold_text.push(chars.next().unwrap());
                    }
                }
                elements.push(MarkupElement::Bold(bold_text));
            }
            '*' => {
                if !current.is_empty() {
                    elements.push(MarkupElement::Paragraph(current.clone()));
                    current.clear();
                }
                // Read until closing *
                let mut italic_text = String::new();
                while let Some(&next) = chars.peek() {
                    if next == '*' {
                        chars.next();
                        break;
                    }
                    italic_text.push(chars.next().unwrap());
                }
                elements.push(MarkupElement::Italic(italic_text));
            }
            '`' => {
                if !current.is_empty() {
                    elements.push(MarkupElement::Paragraph(current.clone()));
                    current.clear();
                }
                // Read until closing `
                let mut code_text = String::new();
                while let Some(&next) = chars.peek() {
                    if next == '`' {
                        chars.next();
                        break;
                    }
                    code_text.push(chars.next().unwrap());
                }
                elements.push(MarkupElement::Code(code_text));
            }
            _ => {
                current.push(c);
            }
        }
    }

    if !current.is_empty() {
        elements.push(MarkupElement::Paragraph(current));
    }

    elements
}

fn main() {
    println!("Simple Markup Parser Example");
    println!("============================\n");

    let markup = r#"
# Main Title

This is a paragraph with **bold** and *italic* text.

## Features

- Fast parsing
- Memory efficient
- Easy to use API

### Code Example

Here is some `inline code` for demonstration.

- Supports **nested** formatting
- Can parse *multiple* styles
"#;

    println!("Input Markup:");
    println!("-------------");
    println!("{}\n", markup);

    match parse_markup(markup) {
        Ok(doc) => {
            println!("Parsed Elements:");
            println!("----------------");
            for (i, element) in doc.elements.iter().enumerate() {
                println!("{}. {}", i + 1, element);
            }

            println!("\nInline Formatting Demo:");
            println!("-----------------------");
            let inline_text = "This has **bold**, *italic*, and `code` formatting";
            let inline_elements = parse_inline(inline_text);
            for element in inline_elements {
                println!("  {}", element);
            }
        }
        Err(e) => println!("Error: {}", e),
    }
}
Ruby
# frozen_string_literal: true

# Markup Parser - Ruby Implementation
#
# Parse a simple markup language: headers, lists, paragraphs, inline formatting.
#
# Run with: ruby example/markup/basic.rb

$LOAD_PATH.unshift "#{File.dirname(__FILE__)}/../lib"

require "parsanol/parslet"

# Simple markup parser
class MarkupParser < Parsanol::Parser
  root :document

  # Document is a sequence of blocks
  rule(:document) { block.repeat(1).as(:document) }

  # Block-level elements
  rule(:block) do
    heading |
      unordered_list |
      paragraph |
      blank_line.as(:blank)
  end

  # Heading: = for H1, == for H2, === for H3
  rule(:heading) do
    (str("=").repeat(1, 3).as(:level) >>
     space >>
     heading_content.as(:text) >>
     newline).as(:heading)
  end

  rule(:heading_content) do
    (newline.absent? >> any).repeat(1)
  end

  # Paragraph: text until blank line
  rule(:paragraph) do
    (paragraph_line >> newline).repeat(1).as(:paragraph)
  end

  rule(:paragraph_line) do
    (blank_line.absent? >> (str("=").absent? | space.absent?) >> any).repeat(1)
  end

  # Unordered list: - items
  rule(:unordered_list) do
    list_item.repeat(1).as(:unordered_list)
  end

  rule(:list_item) do
    (str("-") >>
     space >>
     list_content.as(:text) >>
     newline).as(:item)
  end

  rule(:list_content) do
    (newline.absent? >> any).repeat(1)
  end

  # Helpers
  rule(:space) { str(" ") }
  rule(:newline) { match('\n') }
  rule(:blank_line) { match('\s').repeat >> newline }
end

# Markup node classes
MarkupDocument = Struct.new(:children) do
  def to_html
    children.map { |c| c.respond_to?(:to_html) ? c.to_html : "" }.join("\n")
  end
end

MarkupHeading = Struct.new(:level, :text) do
  def to_html
    h = level.length
    "<h#{h}>#{text.strip}</h#{h}>"
  end
end

MarkupParagraph = Struct.new(:lines) do
  def to_html
    content = lines.map(&:strip).join(" ")
    "<p>#{content}</p>" unless content.empty?
  end
end

MarkupList = Struct.new(:items) do
  def to_html
    html = items.map { |i| "<li>#{i[:text].strip}</li>" }.join("\n")
    "<ul>\n#{html}\n</ul>"
  end
end

# Transform parse tree to AST
class MarkupTransform < Parsanol::Transform
  rule(document: sequence(:blocks)) do
    MarkupDocument.new(blocks.reject { |b| b == :blank })
  end

  rule(heading: { level: simple(:l), text: simple(:t) }) do
    MarkupHeading.new(l.to_s, t.to_s)
  end

  rule(paragraph: sequence(:lines)) do
    MarkupParagraph.new(lines.map(&:to_s))
  end

  rule(unordered_list: sequence(:items)) do
    MarkupList.new(items)
  end

  rule(item: { text: simple(:t) }) { { text: t.to_s } }
  rule(blank: simple(:_)) { :blank }
end

# Parse markup string
def parse_markup(str)
  parser = MarkupParser.new
  transform = MarkupTransform.new

  tree = parser.parse(str)
  transform.apply(tree)
rescue Parsanol::ParseError => e
  puts "Parse error: #{e.message}"
  nil
end

# Main demo
if __FILE__ == $PROGRAM_NAME
  puts "Markup Parser"
  puts "=" * 50
  puts

  markup = <<~MU
    = Main Title

    This is a paragraph of text.
    It can span multiple lines.

    = Section One

    - First item
    - Second item
    - Third item

    == Subsection

    Another paragraph here.

    === Detail

    Final content.
  MU

  puts "Input:"
  puts "-" * 50
  puts markup
  puts "-" * 50
  puts

  result = parse_markup("#{markup}\n")

  if result
    puts "Parsed AST:"
    pp result
    puts
    puts "HTML Output:"
    puts result.to_html
  end
end