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 - 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: 
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("<"),
'>' => resultado.push_str(">"),
'&' => resultado.push_str("&"),
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('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
/// 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!");
}
- Item ordenado
- Segundo item
- Terceiro item

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
Tabelas Markdown: Adicione suporte a tabelas com a sintaxe
| col1 | col2 |e alinhamento com:---,:---:,---:.Notas de rodapé: Implemente notas de rodapé com
[^1]que geram links de ida e volta entre a referência e a nota.Syntax highlighting: Integre a crate
syntectpara aplicar cores ao código dentro de blocos de código, respeitando a linguagem indicada.Modo watch: Use a crate
notifypara monitorar o arquivo Markdown e reconverter automaticamente quando ele for modificado — ideal para escrever com preview em tempo real.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
- Manipulação de Strings — operações com
String, slicing e iteração - Trabalhando com Vec — vetores e coleções dinâmicas
- Iteradores em Rust — composição funcional para processamento
- Lendo Arquivos — padrões de leitura de arquivos
- Escrevendo em Arquivos — persistência de dados