Motor de Templates em Rust

Construa um motor de templates em Rust com interpolacao de variaveis, condicionais, loops e includes usando tokenizador e interpretador tree-walking.

Motores de templates estao por tras de praticamente toda aplicacao web: eles transformam dados dinamicos em HTML, e-mails, documentos e mais. Neste projeto, vamos construir um motor de templates completo em Rust com suporte a interpolacao de variaveis ({{ nome }}), condicionais ({% if %}...{% endif %}), loops ({% for %}...{% endfor %}) e inclusao de arquivos ({% include %}).

Construir um motor de templates ensina conceitos de parsing, avaliacao de expressoes e design de linguagens de dominio especifico (DSLs). A tecnica de tree-walking interpretation que usaremos e a mesma empregada em muitos interpretadores reais.

O Que Vamos Construir

Um motor de templates com as seguintes funcionalidades:

  • Interpolacao de variaveis: {{ variavel }}
  • Acesso a campos aninhados: {{ usuario.nome }}
  • Condicionais: {% if condicao %}...{% else %}...{% endif %}
  • Loops: {% for item in lista %}...{% endfor %}
  • Inclusao de templates: {% include "header.html" %}
  • Filtros simples: {{ nome | upper }}, {{ texto | lower }}
  • Texto literal entre as tags
  • Contexto de dados usando HashMap aninhado

Estrutura do Projeto

template-engine/
├── Cargo.toml
└── src/
    ├── main.rs
    ├── lexer.rs
    ├── parser.rs
    ├── contexto.rs
    └── renderizador.rs

Configurando o Projeto

cargo new template-engine
cd template-engine
[package]
name = "template-engine"
version = "0.1.0"
edition = "2021"

Nao precisamos de dependencias externas.

Passo 1: O Tokenizador (Lexer)

O tokenizador divide o template em tres tipos de tokens: texto literal, expressoes de saida ({{ }}) e tags de controle ({% %}).

Crie o arquivo src/lexer.rs:

/// Tipos de token no template
#[derive(Debug, Clone, PartialEq)]
pub enum Token {
    /// Texto literal (HTML, etc.)
    Texto(String),
    /// Expressao de saida {{ expressao }}
    Expressao(String),
    /// Tag if: {% if condicao %}
    If(String),
    /// Tag else: {% else %}
    Else,
    /// Tag endif: {% endif %}
    EndIf,
    /// Tag for: {% for var in colecao %}
    For { variavel: String, colecao: String },
    /// Tag endfor: {% endfor %}
    EndFor,
    /// Tag include: {% include "arquivo" %}
    Include(String),
}

/// Tokeniza um template e retorna a lista de tokens
pub fn tokenizar(template: &str) -> Result<Vec<Token>, String> {
    let mut tokens = Vec::new();
    let mut pos = 0;
    let bytes = template.as_bytes();

    while pos < bytes.len() {
        if pos + 1 < bytes.len() {
            // Verifica {{ expressao }}
            if bytes[pos] == b'{' && bytes[pos + 1] == b'{' {
                let inicio = pos + 2;
                let fim = encontrar_fechamento(template, inicio, "}}")
                    .ok_or("Expressao {{ sem fechamento }}")?;
                let conteudo = template[inicio..fim].trim().to_string();
                tokens.push(Token::Expressao(conteudo));
                pos = fim + 2;
                continue;
            }

            // Verifica {% tag %}
            if bytes[pos] == b'{' && bytes[pos + 1] == b'%' {
                let inicio = pos + 2;
                let fim = encontrar_fechamento(template, inicio, "%}")
                    .ok_or("Tag {% sem fechamento %}")?;
                let conteudo = template[inicio..fim].trim();
                let token = analisar_tag(conteudo)?;
                tokens.push(token);
                pos = fim + 2;
                continue;
            }
        }

        // Texto literal: coleta ate encontrar {{ ou {%
        let inicio = pos;
        while pos < bytes.len() {
            if pos + 1 < bytes.len()
                && bytes[pos] == b'{'
                && (bytes[pos + 1] == b'{' || bytes[pos + 1] == b'%')
            {
                break;
            }
            pos += 1;
        }

        if pos > inicio {
            tokens.push(Token::Texto(template[inicio..pos].to_string()));
        }
    }

    Ok(tokens)
}

/// Encontra a posicao do delimitador de fechamento
fn encontrar_fechamento(texto: &str, inicio: usize, delimitador: &str) -> Option<usize> {
    texto[inicio..].find(delimitador).map(|pos| inicio + pos)
}

/// Analisa o conteudo de uma tag {% ... %}
fn analisar_tag(conteudo: &str) -> Result<Token, String> {
    let partes: Vec<&str> = conteudo.split_whitespace().collect();

    if partes.is_empty() {
        return Err("Tag vazia".to_string());
    }

    match partes[0] {
        "if" => {
            if partes.len() < 2 {
                Err("Tag 'if' requer uma condicao".to_string())
            } else {
                Ok(Token::If(partes[1..].join(" ")))
            }
        }
        "else" => Ok(Token::Else),
        "endif" => Ok(Token::EndIf),
        "for" => {
            // {% for item in lista %}
            if partes.len() < 4 || partes[2] != "in" {
                Err("Formato esperado: {% for var in colecao %}".to_string())
            } else {
                Ok(Token::For {
                    variavel: partes[1].to_string(),
                    colecao: partes[3].to_string(),
                })
            }
        }
        "endfor" => Ok(Token::EndFor),
        "include" => {
            if partes.len() < 2 {
                Err("Tag 'include' requer um nome de arquivo".to_string())
            } else {
                // Remove aspas do nome do arquivo
                let arquivo = partes[1].trim_matches('"').trim_matches('\'');
                Ok(Token::Include(arquivo.to_string()))
            }
        }
        outro => Err(format!("Tag desconhecida: '{}'", outro)),
    }
}

#[cfg(test)]
mod testes {
    use super::*;

    #[test]
    fn testar_texto_simples() {
        let tokens = tokenizar("Ola mundo").unwrap();
        assert_eq!(tokens, vec![Token::Texto("Ola mundo".to_string())]);
    }

    #[test]
    fn testar_expressao() {
        let tokens = tokenizar("Ola {{ nome }}!").unwrap();
        assert_eq!(tokens.len(), 3);
        assert_eq!(tokens[1], Token::Expressao("nome".to_string()));
    }

    #[test]
    fn testar_for() {
        let tokens = tokenizar("{% for item in lista %}{{ item }}{% endfor %}").unwrap();
        assert_eq!(tokens.len(), 3);
    }
}

Passo 2: O Contexto de Dados

O contexto armazena os dados que serao disponibilizados ao template. Suportamos valores escalares, listas e mapas aninhados.

Crie o arquivo src/contexto.rs:

use std::collections::HashMap;

/// Valor que pode ser armazenado no contexto
#[derive(Debug, Clone)]
pub enum Valor {
    Texto(String),
    Numero(f64),
    Booleano(bool),
    Lista(Vec<Valor>),
    Mapa(HashMap<String, Valor>),
    Nulo,
}

impl Valor {
    /// Converte o valor para string para saida
    pub fn para_string(&self) -> String {
        match self {
            Valor::Texto(s) => s.clone(),
            Valor::Numero(n) => {
                if *n == n.floor() && n.abs() < 1e15 {
                    format!("{}", *n as i64)
                } else {
                    format!("{}", n)
                }
            }
            Valor::Booleano(b) => b.to_string(),
            Valor::Lista(items) => {
                let strs: Vec<String> = items.iter().map(|v| v.para_string()).collect();
                format!("[{}]", strs.join(", "))
            }
            Valor::Mapa(_) => "[Mapa]".to_string(),
            Valor::Nulo => String::new(),
        }
    }

    /// Verifica se o valor e "verdadeiro" para condicionais
    pub fn eh_verdadeiro(&self) -> bool {
        match self {
            Valor::Texto(s) => !s.is_empty(),
            Valor::Numero(n) => *n != 0.0,
            Valor::Booleano(b) => *b,
            Valor::Lista(l) => !l.is_empty(),
            Valor::Mapa(m) => !m.is_empty(),
            Valor::Nulo => false,
        }
    }

    /// Acessa um campo aninhado usando notacao de ponto
    pub fn acessar_campo(&self, caminho: &str) -> &Valor {
        let partes: Vec<&str> = caminho.split('.').collect();
        let mut atual = self;

        for parte in partes {
            match atual {
                Valor::Mapa(mapa) => {
                    atual = mapa.get(parte).unwrap_or(&Valor::Nulo);
                }
                _ => return &Valor::Nulo,
            }
        }

        atual
    }

    /// Converte para lista, se possivel
    pub fn como_lista(&self) -> Option<&Vec<Valor>> {
        match self {
            Valor::Lista(lista) => Some(lista),
            _ => None,
        }
    }
}

/// Contexto hierarquico para resolucao de variaveis
pub struct Contexto {
    escopos: Vec<HashMap<String, Valor>>,
}

impl Contexto {
    pub fn new() -> Self {
        Self {
            escopos: vec![HashMap::new()],
        }
    }

    /// Define um valor no escopo atual
    pub fn definir(&mut self, nome: &str, valor: Valor) {
        if let Some(escopo) = self.escopos.last_mut() {
            escopo.insert(nome.to_string(), valor);
        }
    }

    /// Busca um valor pelos escopos (do mais interno para o mais externo)
    pub fn buscar(&self, nome: &str) -> Valor {
        // Verifica se tem notacao de ponto (campo.subcampo)
        let (raiz, resto) = match nome.split_once('.') {
            Some((r, rest)) => (r, Some(rest)),
            None => (nome, None),
        };

        for escopo in self.escopos.iter().rev() {
            if let Some(valor) = escopo.get(raiz) {
                return match resto {
                    Some(caminho) => valor.acessar_campo(caminho).clone(),
                    None => valor.clone(),
                };
            }
        }

        Valor::Nulo
    }

    /// Abre um novo escopo (para loops, etc.)
    pub fn abrir_escopo(&mut self) {
        self.escopos.push(HashMap::new());
    }

    /// Fecha o escopo mais interno
    pub fn fechar_escopo(&mut self) {
        if self.escopos.len() > 1 {
            self.escopos.pop();
        }
    }
}

Passo 3: O Parser e Renderizador

O parser transforma tokens em uma arvore de nos e o renderizador percorre essa arvore produzindo a saida final.

Crie o arquivo src/parser.rs:

use crate::lexer::Token;

/// No da arvore do template
#[derive(Debug, Clone)]
pub enum No {
    Texto(String),
    Expressao { expressao: String, filtros: Vec<String> },
    If {
        condicao: String,
        corpo: Vec<No>,
        senao: Vec<No>,
    },
    For {
        variavel: String,
        colecao: String,
        corpo: Vec<No>,
    },
    Include(String),
}

/// Constroi a arvore a partir dos tokens
pub fn construir_arvore(tokens: &[Token]) -> Result<Vec<No>, String> {
    let (nos, pos) = analisar_bloco(tokens, 0, None)?;
    if pos < tokens.len() {
        return Err(format!("Token inesperado na posicao {}", pos));
    }
    Ok(nos)
}

fn analisar_bloco(
    tokens: &[Token],
    mut pos: usize,
    terminador: Option<&str>,
) -> Result<(Vec<No>, usize), String> {
    let mut nos = Vec::new();

    while pos < tokens.len() {
        match &tokens[pos] {
            Token::Texto(texto) => {
                nos.push(No::Texto(texto.clone()));
                pos += 1;
            }

            Token::Expressao(expr) => {
                // Verifica se tem filtros (ex: nome | upper)
                let (expressao, filtros) = analisar_filtros(expr);
                nos.push(No::Expressao { expressao, filtros });
                pos += 1;
            }

            Token::If(condicao) => {
                pos += 1;
                let (corpo, nova_pos) = analisar_bloco(tokens, pos, Some("endif_ou_else"))?;
                pos = nova_pos;

                let senao = if pos < tokens.len() && tokens[pos] == Token::Else {
                    pos += 1;
                    let (corpo_senao, nova_pos) = analisar_bloco(tokens, pos, Some("endif"))?;
                    pos = nova_pos;
                    corpo_senao
                } else {
                    Vec::new()
                };

                // Consome o EndIf
                if pos < tokens.len() && tokens[pos] == Token::EndIf {
                    pos += 1;
                }

                nos.push(No::If {
                    condicao: condicao.clone(),
                    corpo,
                    senao,
                });
            }

            Token::Else => {
                if terminador == Some("endif_ou_else") {
                    return Ok((nos, pos));
                }
                return Err("'else' sem 'if' correspondente".to_string());
            }

            Token::EndIf => {
                if terminador == Some("endif") || terminador == Some("endif_ou_else") {
                    return Ok((nos, pos));
                }
                return Err("'endif' sem 'if' correspondente".to_string());
            }

            Token::For { variavel, colecao } => {
                let var = variavel.clone();
                let col = colecao.clone();
                pos += 1;

                let (corpo, nova_pos) = analisar_bloco(tokens, pos, Some("endfor"))?;
                pos = nova_pos;

                // Consome o EndFor
                if pos < tokens.len() && tokens[pos] == Token::EndFor {
                    pos += 1;
                }

                nos.push(No::For {
                    variavel: var,
                    colecao: col,
                    corpo,
                });
            }

            Token::EndFor => {
                if terminador == Some("endfor") {
                    return Ok((nos, pos));
                }
                return Err("'endfor' sem 'for' correspondente".to_string());
            }

            Token::Include(arquivo) => {
                nos.push(No::Include(arquivo.clone()));
                pos += 1;
            }
        }
    }

    if let Some(term) = terminador {
        return Err(format!("Esperado fechamento '{}', mas o template terminou", term));
    }

    Ok((nos, pos))
}

/// Separa expressao e filtros: "nome | upper | trim" -> ("nome", ["upper", "trim"])
fn analisar_filtros(expr: &str) -> (String, Vec<String>) {
    let partes: Vec<&str> = expr.split('|').collect();
    let expressao = partes[0].trim().to_string();
    let filtros: Vec<String> = partes[1..]
        .iter()
        .map(|f| f.trim().to_string())
        .collect();
    (expressao, filtros)
}

Crie o arquivo src/renderizador.rs:

use crate::contexto::{Contexto, Valor};
use crate::parser::No;
use std::collections::HashMap;
use std::fs;
use std::path::Path;

/// Renderiza uma arvore de nos usando o contexto fornecido
pub fn renderizar(nos: &[No], contexto: &mut Contexto) -> Result<String, String> {
    renderizar_com_diretorio(nos, contexto, None)
}

/// Renderiza com suporte a includes relativos a um diretorio
pub fn renderizar_com_diretorio(
    nos: &[No],
    contexto: &mut Contexto,
    diretorio_base: Option<&Path>,
) -> Result<String, String> {
    let mut saida = String::new();

    for no in nos {
        match no {
            No::Texto(texto) => {
                saida.push_str(texto);
            }

            No::Expressao { expressao, filtros } => {
                let valor = contexto.buscar(expressao);
                let mut texto = valor.para_string();

                // Aplica filtros
                for filtro in filtros {
                    texto = aplicar_filtro(&texto, filtro)?;
                }

                saida.push_str(&texto);
            }

            No::If {
                condicao,
                corpo,
                senao,
            } => {
                let valor = avaliar_condicao(condicao, contexto);
                if valor {
                    saida.push_str(&renderizar_com_diretorio(
                        corpo,
                        contexto,
                        diretorio_base,
                    )?);
                } else {
                    saida.push_str(&renderizar_com_diretorio(
                        senao,
                        contexto,
                        diretorio_base,
                    )?);
                }
            }

            No::For {
                variavel,
                colecao,
                corpo,
            } => {
                let valor_colecao = contexto.buscar(colecao);
                if let Some(lista) = valor_colecao.como_lista() {
                    let lista_clone = lista.clone();
                    for (indice, item) in lista_clone.iter().enumerate() {
                        contexto.abrir_escopo();
                        contexto.definir(variavel, item.clone());
                        // Variaveis especiais do loop
                        contexto.definir("loop_indice", Valor::Numero(indice as f64));
                        contexto.definir(
                            "loop_primeiro",
                            Valor::Booleano(indice == 0),
                        );
                        contexto.definir(
                            "loop_ultimo",
                            Valor::Booleano(indice == lista_clone.len() - 1),
                        );

                        saida.push_str(&renderizar_com_diretorio(
                            corpo,
                            contexto,
                            diretorio_base,
                        )?);
                        contexto.fechar_escopo();
                    }
                }
            }

            No::Include(arquivo) => {
                let caminho = match diretorio_base {
                    Some(dir) => dir.join(arquivo),
                    None => Path::new(arquivo).to_path_buf(),
                };

                let conteudo = fs::read_to_string(&caminho)
                    .map_err(|e| format!("Erro ao incluir '{}': {}", arquivo, e))?;

                // Processa o template incluido recursivamente
                let tokens = crate::lexer::tokenizar(&conteudo)?;
                let arvore = crate::parser::construir_arvore(&tokens)?;
                saida.push_str(&renderizar_com_diretorio(
                    &arvore,
                    contexto,
                    caminho.parent(),
                )?);
            }
        }
    }

    Ok(saida)
}

/// Avalia uma condicao simples
fn avaliar_condicao(condicao: &str, contexto: &Contexto) -> bool {
    let condicao = condicao.trim();

    // Condicao com negacao: not variavel
    if let Some(var) = condicao.strip_prefix("not ") {
        return !contexto.buscar(var.trim()).eh_verdadeiro();
    }

    // Condicao com comparacao: var == "valor"
    if let Some((esq, dir)) = condicao.split_once("==") {
        let val_esq = contexto.buscar(esq.trim()).para_string();
        let val_dir = dir.trim().trim_matches('"').trim_matches('\'');
        return val_esq == val_dir;
    }

    // Condicao com desigualdade: var != "valor"
    if let Some((esq, dir)) = condicao.split_once("!=") {
        let val_esq = contexto.buscar(esq.trim()).para_string();
        let val_dir = dir.trim().trim_matches('"').trim_matches('\'');
        return val_esq != val_dir;
    }

    // Condicao simples: valor verdadeiro/falso
    contexto.buscar(condicao).eh_verdadeiro()
}

/// Aplica um filtro a um texto
fn aplicar_filtro(texto: &str, filtro: &str) -> Result<String, String> {
    match filtro {
        "upper" | "maiusculo" => Ok(texto.to_uppercase()),
        "lower" | "minusculo" => Ok(texto.to_lowercase()),
        "trim" => Ok(texto.trim().to_string()),
        "capitalize" => {
            let mut chars = texto.chars();
            match chars.next() {
                None => Ok(String::new()),
                Some(c) => {
                    let mut s = c.to_uppercase().to_string();
                    s.extend(chars);
                    Ok(s)
                }
            }
        }
        "length" | "tamanho" => Ok(texto.len().to_string()),
        "reverse" | "inverter" => Ok(texto.chars().rev().collect()),
        _ => Err(format!("Filtro desconhecido: '{}'", filtro)),
    }
}

/// Funcao auxiliar para criar um contexto a partir de um HashMap simples
pub fn contexto_de_mapa(dados: HashMap<String, String>) -> Contexto {
    let mut ctx = Contexto::new();
    for (chave, valor) in dados {
        ctx.definir(&chave, Valor::Texto(valor));
    }
    ctx
}

Passo 4: Juntando Tudo no main.rs

mod contexto;
mod lexer;
mod parser;
mod renderizador;

use contexto::{Contexto, Valor};
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::Path;

fn exemplo_basico() {
    let template = r#"
Ola, {{ nome | upper }}!

{% if admin %}
Voce tem acesso de administrador.
{% else %}
Voce e um usuario comum.
{% endif %}

Seus projetos:
{% for projeto in projetos %}
- {{ projeto }}
{% endfor %}
"#;

    let mut ctx = Contexto::new();
    ctx.definir("nome", Valor::Texto("Maria".to_string()));
    ctx.definir("admin", Valor::Booleano(true));
    ctx.definir(
        "projetos",
        Valor::Lista(vec![
            Valor::Texto("API REST".to_string()),
            Valor::Texto("CLI Tool".to_string()),
            Valor::Texto("Web Crawler".to_string()),
        ]),
    );

    let tokens = lexer::tokenizar(template).expect("Erro no tokenizador");
    let arvore = parser::construir_arvore(&tokens).expect("Erro no parser");
    let saida = renderizador::renderizar(&arvore, &mut ctx)
        .expect("Erro no renderizador");

    println!("=== Resultado ===");
    println!("{}", saida);
}

fn exemplo_campos_aninhados() {
    let template = "Bem-vindo, {{ usuario.nome }}! Seu cargo: {{ usuario.cargo | capitalize }}";

    let mut dados_usuario = HashMap::new();
    dados_usuario.insert("nome".to_string(), Valor::Texto("Joao".to_string()));
    dados_usuario.insert("cargo".to_string(), Valor::Texto("desenvolvedor".to_string()));

    let mut ctx = Contexto::new();
    ctx.definir("usuario", Valor::Mapa(dados_usuario));

    let tokens = lexer::tokenizar(template).expect("Erro no tokenizador");
    let arvore = parser::construir_arvore(&tokens).expect("Erro no parser");
    let saida = renderizador::renderizar(&arvore, &mut ctx)
        .expect("Erro no renderizador");

    println!("=== Campos Aninhados ===");
    println!("{}", saida);
}

fn processar_arquivo(caminho: &str) {
    let conteudo = fs::read_to_string(caminho)
        .unwrap_or_else(|e| {
            eprintln!("Erro ao ler '{}': {}", caminho, e);
            std::process::exit(1);
        });

    let mut ctx = Contexto::new();
    ctx.definir("titulo", Valor::Texto("Minha Pagina".to_string()));
    ctx.definir("ano", Valor::Numero(2026.0));
    ctx.definir("ativo", Valor::Booleano(true));

    let tokens = lexer::tokenizar(&conteudo).expect("Erro no tokenizador");
    let arvore = parser::construir_arvore(&tokens).expect("Erro no parser");

    let diretorio = Path::new(caminho).parent();
    let saida = renderizador::renderizar_com_diretorio(&arvore, &mut ctx, diretorio)
        .expect("Erro no renderizador");

    println!("{}", saida);
}

fn main() {
    let args: Vec<String> = env::args().collect();

    if args.len() > 1 {
        processar_arquivo(&args[1]);
    } else {
        println!("=== Motor de Templates em Rust ===\n");
        println!("Uso: {} <arquivo.html>", args[0]);
        println!("Sem argumentos: executando exemplos embutidos.\n");

        exemplo_basico();
        println!();
        exemplo_campos_aninhados();
    }
}

Como Executar

# Compilar e executar os exemplos embutidos
cargo run

# Saida esperada:
=== Motor de Templates em Rust ===

Uso: target/debug/template-engine <arquivo.html>
Sem argumentos: executando exemplos embutidos.

=== Resultado ===

Ola, MARIA!

Voce tem acesso de administrador.

Seus projetos:
- API REST
- CLI Tool
- Web Crawler

=== Campos Aninhados ===
Bem-vindo, Joao! Seu cargo: Desenvolvedor

# Processar um arquivo de template
echo '{{ titulo }} - Copyright {{ ano }}' > exemplo.html
cargo run -- exemplo.html

# Executar os testes
cargo test

Desafios para Expandir

  1. Heranca de templates: Implemente {% extends "base.html" %} e {% block conteudo %} para permitir que templates filhos sobreescrevam blocos do template pai.
  2. Expressoes em condicionais: Melhore o avaliador de condicoes para suportar operadores >, <, >=, <= e operadores logicos and/or.
  3. Filtros com argumentos: Permita filtros como {{ data | format "%d/%m/%Y" }} e {{ texto | truncate 50 }} que aceitam parametros.
  4. Cache de templates: Implemente um cache que armazena templates ja compilados (tokenizados e parseados), evitando reprocessamento em aplicacoes web.
  5. Modo seguro (sandbox): Limite as operacoes permitidas nos templates para prevenir acesso a dados sensíveis ou execução de código arbitrário, ideal para templates de terceiros.

Veja Tambem