Conversor Markdown para HTML

Construa um conversor de Markdown para HTML em Rust com parser manual, suporte a cabeçalhos, listas, links e blocos de código.

Markdown é a linguagem de marcação mais popular para documentação, arquivos README, blogs e anotações. Neste projeto, vamos construir um conversor de Markdown para HTML completamente do zero, sem usar nenhuma crate externa de parsing de Markdown. O objetivo é aprender os fundamentos de parsing de texto: leitura linha a linha, detecção de padrões, gerenciamento de estado e geração de saída estruturada.

Ao construir seu próprio parser, você vai entender profundamente como ferramentas como pulldown-cmark e comrak funcionam internamente. É um exercício excelente para praticar pattern matching, manipulação de strings e máquinas de estado em Rust.

O Que Vamos Construir

Nosso md2html terá suporte a:

  • Cabeçalhos (h1 a h6 com #)
  • Parágrafos com quebras de linha
  • Texto em negrito e itálico
  • Links [texto](url) e imagens ![alt](url)
  • Blocos de código com ```
  • Código inline com crases
  • Listas não ordenadas com - ou *
  • Listas ordenadas com números
  • Linhas horizontais com ---
  • Citações em bloco com >

Estrutura do Projeto

md2html/
├── Cargo.toml
└── src/
    ├── main.rs
    ├── parser.rs
    └── inline.rs

Configurando o Projeto

cargo new md2html
cd md2html

Configure o Cargo.toml:

[package]
name = "md2html"
version = "0.1.0"
edition = "2021"

[dependencies]
clap = { version = "4", features = ["derive"] }

Note que não precisamos de nenhuma crate de parsing — vamos implementar tudo manualmente. Apenas o clap para a interface de linha de comando.

Passo 1: Parser de Elementos Inline

O módulo inline.rs trata da formatação dentro de uma linha: negrito, itálico, links, imagens e código inline.

// src/inline.rs

/// Processa formatação inline de uma linha de Markdown para HTML
pub fn processar_inline(texto: &str) -> String {
    let mut resultado = String::with_capacity(texto.len() * 2);
    let chars: Vec<char> = texto.chars().collect();
    let len = chars.len();
    let mut i = 0;

    while i < len {
        // Imagens: ![alt](url)
        if i + 1 < len && chars[i] == '!' && chars[i + 1] == '[' {
            if let Some((html, avanco)) = tentar_imagem(&chars, i) {
                resultado.push_str(&html);
                i += avanco;
                continue;
            }
        }

        // Links: [texto](url)
        if chars[i] == '[' {
            if let Some((html, avanco)) = tentar_link(&chars, i) {
                resultado.push_str(&html);
                i += avanco;
                continue;
            }
        }

        // Código inline: `código`
        if chars[i] == '`' {
            if let Some((html, avanco)) = tentar_codigo_inline(&chars, i) {
                resultado.push_str(&html);
                i += avanco;
                continue;
            }
        }

        // Negrito: **texto** ou __texto__
        if i + 1 < len && ((chars[i] == '*' && chars[i + 1] == '*')
            || (chars[i] == '_' && chars[i + 1] == '_'))
        {
            let marcador = chars[i];
            if let Some((html, avanco)) = tentar_enfase_dupla(&chars, i, marcador) {
                resultado.push_str(&html);
                i += avanco;
                continue;
            }
        }

        // Itálico: *texto* ou _texto_
        if chars[i] == '*' || chars[i] == '_' {
            let marcador = chars[i];
            if let Some((html, avanco)) = tentar_enfase_simples(&chars, i, marcador) {
                resultado.push_str(&html);
                i += avanco;
                continue;
            }
        }

        // Escapar caracteres HTML especiais
        match chars[i] {
            '<' => resultado.push_str("&lt;"),
            '>' => resultado.push_str("&gt;"),
            '&' => resultado.push_str("&amp;"),
            c => resultado.push(c),
        }
        i += 1;
    }

    resultado
}

fn tentar_imagem(chars: &[char], inicio: usize) -> Option<(String, usize)> {
    // Pular "!["
    let mut i = inicio + 2;

    // Buscar "]"
    let alt_inicio = i;
    while i < chars.len() && chars[i] != ']' {
        i += 1;
    }
    if i >= chars.len() { return None; }
    let alt: String = chars[alt_inicio..i].iter().collect();
    i += 1; // Pular "]"

    // Esperar "("
    if i >= chars.len() || chars[i] != '(' { return None; }
    i += 1;

    // Buscar ")"
    let url_inicio = i;
    while i < chars.len() && chars[i] != ')' {
        i += 1;
    }
    if i >= chars.len() { return None; }
    let url: String = chars[url_inicio..i].iter().collect();
    i += 1; // Pular ")"

    let html = format!(r#"<img src="{}" alt="{}">"#, url, alt);
    Some((html, i - inicio))
}

fn tentar_link(chars: &[char], inicio: usize) -> Option<(String, usize)> {
    let mut i = inicio + 1;

    // Buscar "]"
    let texto_inicio = i;
    while i < chars.len() && chars[i] != ']' {
        i += 1;
    }
    if i >= chars.len() { return None; }
    let texto: String = chars[texto_inicio..i].iter().collect();
    i += 1;

    // Esperar "("
    if i >= chars.len() || chars[i] != '(' { return None; }
    i += 1;

    // Buscar ")"
    let url_inicio = i;
    while i < chars.len() && chars[i] != ')' {
        i += 1;
    }
    if i >= chars.len() { return None; }
    let url: String = chars[url_inicio..i].iter().collect();
    i += 1;

    let html = format!(r#"<a href="{}">{}</a>"#, url, texto);
    Some((html, i - inicio))
}

fn tentar_codigo_inline(chars: &[char], inicio: usize) -> Option<(String, usize)> {
    let mut i = inicio + 1;

    while i < chars.len() && chars[i] != '`' {
        i += 1;
    }
    if i >= chars.len() { return None; }

    let codigo: String = chars[inicio + 1..i].iter().collect();
    i += 1;

    let html = format!("<code>{}</code>", codigo);
    Some((html, i - inicio))
}

fn tentar_enfase_dupla(chars: &[char], inicio: usize, marcador: char) -> Option<(String, usize)> {
    let mut i = inicio + 2;

    // Buscar o fechamento (dois marcadores consecutivos)
    while i + 1 < chars.len() {
        if chars[i] == marcador && chars[i + 1] == marcador {
            let conteudo: String = chars[inicio + 2..i].iter().collect();
            let html = format!("<strong>{}</strong>", conteudo);
            return Some((html, i + 2 - inicio));
        }
        i += 1;
    }

    None
}

fn tentar_enfase_simples(chars: &[char], inicio: usize, marcador: char) -> Option<(String, usize)> {
    let mut i = inicio + 1;

    while i < chars.len() {
        if chars[i] == marcador {
            let conteudo: String = chars[inicio + 1..i].iter().collect();
            let html = format!("<em>{}</em>", conteudo);
            return Some((html, i + 1 - inicio));
        }
        i += 1;
    }

    None
}

O parser inline funciona como um scanner: percorre cada caractere da linha e tenta detectar marcadores de formatação. Quando encontra um padrão de abertura (como **), busca o fechamento correspondente e gera o HTML adequado.

Passo 2: Parser de Blocos

O módulo parser.rs lida com a estrutura de blocos do documento: cabeçalhos, parágrafos, listas e blocos de código.

// src/parser.rs
use crate::inline::processar_inline;

/// Converte um documento Markdown completo para HTML
pub fn markdown_para_html(entrada: &str) -> String {
    let mut html = String::new();
    let linhas: Vec<&str> = entrada.lines().collect();
    let total = linhas.len();
    let mut i = 0;

    while i < total {
        let linha = linhas[i];
        let trimmed = linha.trim();

        // Linha vazia — pula
        if trimmed.is_empty() {
            i += 1;
            continue;
        }

        // Bloco de código com ```
        if trimmed.starts_with("```") {
            let linguagem = trimmed.trim_start_matches('`').trim();
            i += 1;

            let mut codigo = String::new();
            while i < total && !linhas[i].trim().starts_with("```") {
                if !codigo.is_empty() {
                    codigo.push('\n');
                }
                codigo.push_str(linhas[i]);
                i += 1;
            }
            if i < total {
                i += 1; // Pular o ``` de fechamento
            }

            if linguagem.is_empty() {
                html.push_str(&format!("<pre><code>{}</code></pre>\n", escapar_html(&codigo)));
            } else {
                html.push_str(&format!(
                    "<pre><code class=\"language-{}\">{}</code></pre>\n",
                    linguagem,
                    escapar_html(&codigo)
                ));
            }
            continue;
        }

        // Cabeçalhos: # até ######
        if trimmed.starts_with('#') {
            let nivel = trimmed.chars().take_while(|&c| c == '#').count();
            if nivel <= 6 {
                let conteudo = trimmed[nivel..].trim();
                html.push_str(&format!(
                    "<h{}>{}</h{}>\n",
                    nivel,
                    processar_inline(conteudo),
                    nivel
                ));
                i += 1;
                continue;
            }
        }

        // Linha horizontal: --- ou *** ou ___
        if (trimmed.starts_with("---") || trimmed.starts_with("***") || trimmed.starts_with("___"))
            && trimmed.chars().all(|c| c == '-' || c == '*' || c == '_' || c == ' ')
            && trimmed.len() >= 3
        {
            html.push_str("<hr>\n");
            i += 1;
            continue;
        }

        // Citação em bloco: >
        if trimmed.starts_with('>') {
            let mut linhas_citacao = Vec::new();
            while i < total && linhas[i].trim().starts_with('>') {
                let conteudo = linhas[i].trim().trim_start_matches('>').trim();
                linhas_citacao.push(conteudo.to_string());
                i += 1;
            }
            let conteudo_citacao = linhas_citacao.join(" ");
            html.push_str(&format!(
                "<blockquote><p>{}</p></blockquote>\n",
                processar_inline(&conteudo_citacao)
            ));
            continue;
        }

        // Lista não ordenada: - item ou * item
        if (trimmed.starts_with("- ") || trimmed.starts_with("* "))
            && !trimmed.starts_with("---")
            && !trimmed.starts_with("***")
        {
            html.push_str("<ul>\n");
            while i < total {
                let l = linhas[i].trim();
                if l.starts_with("- ") || l.starts_with("* ") {
                    let conteudo = &l[2..];
                    html.push_str(&format!(
                        "  <li>{}</li>\n",
                        processar_inline(conteudo)
                    ));
                    i += 1;
                } else {
                    break;
                }
            }
            html.push_str("</ul>\n");
            continue;
        }

        // Lista ordenada: 1. item
        if e_lista_ordenada(trimmed) {
            html.push_str("<ol>\n");
            while i < total {
                let l = linhas[i].trim();
                if e_lista_ordenada(l) {
                    let pos_ponto = l.find('.').unwrap_or(0);
                    let conteudo = l[pos_ponto + 1..].trim();
                    html.push_str(&format!(
                        "  <li>{}</li>\n",
                        processar_inline(conteudo)
                    ));
                    i += 1;
                } else {
                    break;
                }
            }
            html.push_str("</ol>\n");
            continue;
        }

        // Parágrafo (padrão)
        let mut linhas_paragrafo = Vec::new();
        while i < total {
            let l = linhas[i].trim();
            if l.is_empty()
                || l.starts_with('#')
                || l.starts_with("```")
                || l.starts_with("> ")
                || l.starts_with("- ")
                || l.starts_with("* ")
                || l.starts_with("---")
                || e_lista_ordenada(l)
            {
                break;
            }
            linhas_paragrafo.push(l);
            i += 1;
        }

        if !linhas_paragrafo.is_empty() {
            let conteudo = linhas_paragrafo.join(" ");
            html.push_str(&format!("<p>{}</p>\n", processar_inline(&conteudo)));
        }
    }

    html
}

/// Verifica se uma linha é item de lista ordenada (começa com número seguido de ponto)
fn e_lista_ordenada(linha: &str) -> bool {
    let mut chars = linha.chars().peekable();
    let mut tem_digito = false;

    while let Some(&c) = chars.peek() {
        if c.is_ascii_digit() {
            tem_digito = true;
            chars.next();
        } else {
            break;
        }
    }

    if !tem_digito {
        return false;
    }

    if let Some(&'.') = chars.peek() {
        chars.next();
        if let Some(&' ') = chars.peek() {
            return true;
        }
    }

    false
}

/// Escapa caracteres especiais de HTML em blocos de código
fn escapar_html(texto: &str) -> String {
    texto
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}

/// Gera um documento HTML completo com <head> e <body>
pub fn envolver_html(titulo: &str, corpo: &str) -> String {
    format!(
        r#"<!DOCTYPE html>
<html lang="pt-BR">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{}</title>
    <style>
        body {{ font-family: sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; line-height: 1.6; }}
        code {{ background: #f4f4f4; padding: 0.2em 0.4em; border-radius: 3px; }}
        pre {{ background: #f4f4f4; padding: 1rem; border-radius: 5px; overflow-x: auto; }}
        pre code {{ background: none; padding: 0; }}
        blockquote {{ border-left: 4px solid #ddd; margin: 1rem 0; padding: 0.5rem 1rem; color: #666; }}
        hr {{ border: none; border-top: 1px solid #ddd; margin: 2rem 0; }}
    </style>
</head>
<body>
{}
</body>
</html>"#,
        titulo, corpo
    )
}

O parser de blocos funciona como uma máquina de estado simples: para cada linha, verifica o prefixo para determinar o tipo de bloco e consome as linhas correspondentes. Parágrafos são o caso padrão — qualquer linha que não corresponda a outro tipo de bloco vira parágrafo.

Passo 3: Integrando no main.rs

// src/main.rs
mod inline;
mod parser;

use clap::Parser as ClapParser;
use std::fs;
use std::process;

#[derive(ClapParser, Debug)]
#[command(name = "md2html")]
#[command(about = "Converte arquivos Markdown para HTML")]
struct Cli {
    /// Arquivo Markdown de entrada
    entrada: String,

    /// Arquivo HTML de saída (padrão: mesmo nome com .html)
    #[arg(short, long)]
    saida: Option<String>,

    /// Título da página HTML
    #[arg(short, long, default_value = "Documento")]
    titulo: String,

    /// Gerar apenas o fragmento HTML (sem <html>, <head>, <body>)
    #[arg(long)]
    fragmento: bool,
}

fn main() {
    let cli = Cli::parse();

    // Ler o arquivo de entrada
    let conteudo = match fs::read_to_string(&cli.entrada) {
        Ok(c) => c,
        Err(e) => {
            eprintln!("Erro ao ler '{}': {}", cli.entrada, e);
            process::exit(1);
        }
    };

    // Converter Markdown para HTML
    let corpo_html = parser::markdown_para_html(&conteudo);

    let html_final = if cli.fragmento {
        corpo_html
    } else {
        parser::envolver_html(&cli.titulo, &corpo_html)
    };

    // Determinar o arquivo de saída
    let arquivo_saida = cli.saida.unwrap_or_else(|| {
        let mut nome = cli.entrada.clone();
        if nome.ends_with(".md") {
            nome.truncate(nome.len() - 3);
        }
        format!("{}.html", nome)
    });

    // Escrever o resultado
    match fs::write(&arquivo_saida, &html_final) {
        Ok(()) => {
            println!("Convertido com sucesso: {} -> {}", cli.entrada, arquivo_saida);
            println!("  Tamanho da entrada:  {} bytes", conteudo.len());
            println!("  Tamanho da saída:    {} bytes", html_final.len());
        }
        Err(e) => {
            eprintln!("Erro ao escrever '{}': {}", arquivo_saida, e);
            process::exit(1);
        }
    }
}

Como Executar

cargo build --release

Crie um arquivo exemplo.md:

# Meu Documento

Este é um parágrafo com **negrito** e *itálico*.

## Seção com Lista

- Primeiro item
- Segundo item com `código`
- Terceiro item com [link](https://rust-lang.org)

> Uma citação importante sobre Rust.

### Código em Rust

```rust
fn main() {
    println!("Olá, mundo!");
}

  1. Item ordenado
  2. Segundo item
  3. Terceiro item

Logo Rust


Execute o conversor:

```bash
# Converter para HTML completo
./target/release/md2html exemplo.md
# Convertido com sucesso: exemplo.md -> exemplo.html

# Especificar arquivo de saída e título
./target/release/md2html exemplo.md -s pagina.html -t "Minha Página"

# Gerar apenas o fragmento HTML
./target/release/md2html exemplo.md --fragmento -s conteudo.html

Desafios para Expandir

  1. Tabelas Markdown: Adicione suporte a tabelas com a sintaxe | col1 | col2 | e alinhamento com :---, :---:, ---:.

  2. Notas de rodapé: Implemente notas de rodapé com [^1] que geram links de ida e volta entre a referência e a nota.

  3. Syntax highlighting: Integre a crate syntect para aplicar cores ao código dentro de blocos de código, respeitando a linguagem indicada.

  4. Modo watch: Use a crate notify para monitorar o arquivo Markdown e reconverter automaticamente quando ele for modificado — ideal para escrever com preview em tempo real.

  5. Template HTML customizável: Permita que o usuário forneça um template HTML personalizado onde o conteúdo Markdown será inserido, usando um marcador como {{conteudo}}.

Veja Também