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
- Heranca de templates: Implemente
{% extends "base.html" %}e{% block conteudo %}para permitir que templates filhos sobreescrevam blocos do template pai. - Expressoes em condicionais: Melhore o avaliador de condicoes para suportar operadores
>,<,>=,<=e operadores logicosand/or. - Filtros com argumentos: Permita filtros como
{{ data | format "%d/%m/%Y" }}e{{ texto | truncate 50 }}que aceitam parametros. - Cache de templates: Implemente um cache que armazena templates ja compilados (tokenizados e parseados), evitando reprocessamento em aplicacoes web.
- 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
- Trabalhando com Strings — manipulacao de texto e templates
- HashMap: Tabelas Hash — armazenamento de contexto
- Vec: Vetores Dinamicos — listas e arvores de nos
- Iteradores em Rust — processamento funcional de colecoes
- Como Formatar Strings — formatacao de saida