O formato INI e um dos formatos de configuracao mais antigos e difundidos na computacao. Usado pelo Windows, PHP, MySQL, Git e inumeros outros programas, ele se destaca pela simplicidade: secoes entre colchetes, pares chave-valor separados por = e comentarios com ; ou #. Apesar de simples, implementar um parser completo envolve tratamento de casos de borda como valores multiline, espacos em branco, secoes duplicadas e caracteres especiais.
Neste projeto, vamos construir um parser de arquivos INI completo em Rust, projetado como uma biblioteca reutilizavel com API publica ergonomica. Voce aprendera a projetar APIs publicas em Rust, trabalhar com HashMap aninhado, implementar Display e FromStr, e escrever testes abrangentes. O resultado e uma crate que pode ser publicada e usada por outros projetos.
O Que Vamos Construir
Nossa biblioteca ini-parser tera os seguintes recursos:
- Parsing de secoes (
[secao]), chaves e valores - Suporte a comentarios com
;e# - Valores multiline com continuacao por
\ - Secao global (chaves antes de qualquer
[secao]) - API para leitura, escrita e modificacao de valores
- Serializacao de volta para formato INI
- Carregamento a partir de arquivo ou string
- Mensagens de erro com numero da linha
- Suite completa de testes unitarios
Estrutura do Projeto
ini-parser/
├── Cargo.toml
└── src/
├── lib.rs
├── parser.rs
├── documento.rs
└── erro.rs
Note que usamos lib.rs em vez de main.rs — este projeto e uma biblioteca. Incluiremos um binario de exemplo para demonstracao.
Configurando o Projeto
cargo new ini-parser --lib
cd ini-parser
Configure o Cargo.toml:
[package]
name = "ini-parser"
version = "0.1.0"
edition = "2021"
description = "Parser de arquivos INI completo e reutilizavel"
[[example]]
name = "demonstracao"
path = "examples/demonstracao.rs"
Este projeto nao precisa de dependencias externas — usaremos apenas a biblioteca padrao do Rust, o que torna a crate leve e sem transitividades.
Passo 1: Tipos de Erro com Contexto
Um bom parser precisa de mensagens de erro claras. O modulo erro.rs define erros com o numero da linha onde o problema ocorreu.
// src/erro.rs
use std::fmt;
/// Erros que podem ocorrer durante o parsing de um arquivo INI
#[derive(Debug, Clone, PartialEq)]
pub enum ErroIni {
/// Secao mal formada (falta ] de fechamento)
SecaoInvalida { linha: usize, conteudo: String },
/// Chave vazia em um par chave=valor
ChaveVazia { linha: usize },
/// Linha nao reconhecida (nao e secao, par chave=valor nem comentario)
LinhaInvalida { linha: usize, conteudo: String },
/// Erro de leitura de arquivo
ErroArquivo(String),
}
impl fmt::Display for ErroIni {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::SecaoInvalida { linha, conteudo } => {
write!(
f,
"Linha {}: secao mal formada: '{}'",
linha, conteudo
)
}
Self::ChaveVazia { linha } => {
write!(f, "Linha {}: chave vazia nao e permitida", linha)
}
Self::LinhaInvalida { linha, conteudo } => {
write!(
f,
"Linha {}: linha nao reconhecida: '{}'",
linha, conteudo
)
}
Self::ErroArquivo(msg) => {
write!(f, "Erro de arquivo: {}", msg)
}
}
}
}
impl std::error::Error for ErroIni {}
Implementamos std::error::Error para que nosso tipo de erro seja compativel com o ecossistema de tratamento de erros do Rust, incluindo o operador ?.
Passo 2: O Documento INI
O modulo documento.rs define a estrutura de dados que representa um documento INI em memoria e sua API publica.
// src/documento.rs
use std::collections::HashMap;
use std::fmt;
/// Nome da secao global (chaves que aparecem antes de qualquer [secao])
pub const SECAO_GLOBAL: &str = "";
/// Representa um documento INI completo em memoria.
///
/// Um documento INI contem secoes, cada uma com seus pares chave-valor.
/// Chaves definidas antes de qualquer secao pertencem a secao global.
#[derive(Debug, Clone)]
pub struct DocumentoIni {
/// Mapa de secoes: nome_secao -> (mapa de chave -> valor)
secoes: HashMap<String, HashMap<String, String>>,
/// Ordem de insercao das secoes (para preservar ao serializar)
ordem_secoes: Vec<String>,
}
impl DocumentoIni {
/// Cria um documento INI vazio
pub fn novo() -> Self {
Self {
secoes: HashMap::new(),
ordem_secoes: Vec::new(),
}
}
/// Retorna o valor de uma chave em uma secao.
/// Use `SECAO_GLOBAL` (string vazia) para a secao global.
pub fn obter(&self, secao: &str, chave: &str) -> Option<&str> {
self.secoes
.get(secao)
.and_then(|s| s.get(chave))
.map(|v| v.as_str())
}
/// Retorna o valor com um padrao caso nao exista
pub fn obter_ou(&self, secao: &str, chave: &str, padrao: &str) -> String {
self.obter(secao, chave)
.unwrap_or(padrao)
.to_string()
}
/// Define o valor de uma chave em uma secao.
/// Cria a secao se ela nao existir.
pub fn definir(&mut self, secao: &str, chave: &str, valor: &str) {
if !self.secoes.contains_key(secao) {
self.ordem_secoes.push(secao.to_string());
}
self.secoes
.entry(secao.to_string())
.or_default()
.insert(chave.to_string(), valor.to_string());
}
/// Remove uma chave de uma secao. Retorna o valor removido, se existia.
pub fn remover(&mut self, secao: &str, chave: &str) -> Option<String> {
self.secoes
.get_mut(secao)
.and_then(|s| s.remove(chave))
}
/// Remove uma secao inteira. Retorna os pares chave-valor removidos.
pub fn remover_secao(&mut self, secao: &str) -> Option<HashMap<String, String>> {
self.ordem_secoes.retain(|s| s != secao);
self.secoes.remove(secao)
}
/// Verifica se uma secao existe
pub fn tem_secao(&self, secao: &str) -> bool {
self.secoes.contains_key(secao)
}
/// Verifica se uma chave existe em uma secao
pub fn tem_chave(&self, secao: &str, chave: &str) -> bool {
self.secoes
.get(secao)
.map(|s| s.contains_key(chave))
.unwrap_or(false)
}
/// Retorna uma lista de todas as secoes (incluindo global se existir)
pub fn secoes(&self) -> &[String] {
&self.ordem_secoes
}
/// Retorna todas as chaves de uma secao
pub fn chaves(&self, secao: &str) -> Vec<String> {
self.secoes
.get(secao)
.map(|s| s.keys().cloned().collect())
.unwrap_or_default()
}
/// Retorna todos os pares chave-valor de uma secao
pub fn pares(&self, secao: &str) -> Vec<(String, String)> {
self.secoes
.get(secao)
.map(|s| {
s.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
})
.unwrap_or_default()
}
/// Retorna o numero total de chaves em todas as secoes
pub fn total_chaves(&self) -> usize {
self.secoes.values().map(|s| s.len()).sum()
}
/// Adiciona os dados parseados internamente
pub(crate) fn adicionar_entrada(
&mut self,
secao: &str,
chave: String,
valor: String,
) {
if !self.secoes.contains_key(secao) {
self.ordem_secoes.push(secao.to_string());
}
self.secoes
.entry(secao.to_string())
.or_default()
.insert(chave, valor);
}
/// Registra uma secao na ordem (usado pelo parser)
pub(crate) fn registrar_secao(&mut self, secao: &str) {
if !self.ordem_secoes.contains(&secao.to_string()) {
self.ordem_secoes.push(secao.to_string());
self.secoes.entry(secao.to_string()).or_default();
}
}
}
impl fmt::Display for DocumentoIni {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for nome_secao in &self.ordem_secoes {
let secao = match self.secoes.get(nome_secao) {
Some(s) => s,
None => continue,
};
// Secao global nao tem cabecalho [nome]
if !nome_secao.is_empty() {
writeln!(f, "[{}]", nome_secao)?;
}
// Coletar e ordenar chaves para saida deterministica
let mut chaves: Vec<&String> = secao.keys().collect();
chaves.sort();
for chave in chaves {
if let Some(valor) = secao.get(chave) {
writeln!(f, "{} = {}", chave, valor)?;
}
}
writeln!(f)?;
}
Ok(())
}
}
impl Default for DocumentoIni {
fn default() -> Self {
Self::novo()
}
}
A API foi projetada para ser ergonomica: obter retorna Option<&str> para seguranca, enquanto obter_ou fornece um valor padrao. O metodo Display permite serializar o documento de volta para o formato INI. Usamos pub(crate) para metodos internos que so o parser deve acessar.
Passo 3: O Parser
O modulo parser.rs contem a logica de parsing linha a linha, tratando todos os casos do formato INI.
// src/parser.rs
use crate::documento::{DocumentoIni, SECAO_GLOBAL};
use crate::erro::ErroIni;
use std::fs;
use std::path::Path;
/// Faz o parse de uma string no formato INI e retorna um DocumentoIni
pub fn parse_string(conteudo: &str) -> Result<DocumentoIni, ErroIni> {
let mut documento = DocumentoIni::novo();
let mut secao_atual = SECAO_GLOBAL.to_string();
let mut num_linha: usize = 0;
let mut valor_continuacao: Option<(String, String)> = None;
for linha_raw in conteudo.lines() {
num_linha += 1;
let linha = linha_raw.trim();
// Verificar continuacao de valor multiline (terminava com \)
if let Some((ref chave, ref valor_acumulado)) = valor_continuacao.clone() {
if let Some(sem_barra) = linha.strip_suffix('\\') {
// Continua na proxima linha
let novo_valor = format!(
"{}\n{}",
valor_acumulado,
sem_barra.trim()
);
valor_continuacao = Some((chave.clone(), novo_valor));
continue;
} else {
// Ultima linha da continuacao
let valor_final = format!(
"{}\n{}",
valor_acumulado,
linha
);
documento.adicionar_entrada(
&secao_atual,
chave.clone(),
valor_final,
);
valor_continuacao = None;
continue;
}
}
// Ignorar linhas vazias
if linha.is_empty() {
continue;
}
// Ignorar comentarios (; ou #)
if linha.starts_with(';') || linha.starts_with('#') {
continue;
}
// Verificar se e uma secao [nome]
if linha.starts_with('[') {
if let Some(fim) = linha.find(']') {
let nome = linha[1..fim].trim();
if nome.is_empty() {
return Err(ErroIni::SecaoInvalida {
linha: num_linha,
conteudo: linha.to_string(),
});
}
secao_atual = nome.to_string();
documento.registrar_secao(&secao_atual);
continue;
} else {
return Err(ErroIni::SecaoInvalida {
linha: num_linha,
conteudo: linha.to_string(),
});
}
}
// Verificar se e um par chave = valor
if let Some(pos_igual) = linha.find('=') {
let chave = linha[..pos_igual].trim();
let valor_raw = linha[pos_igual + 1..].trim();
if chave.is_empty() {
return Err(ErroIni::ChaveVazia { linha: num_linha });
}
// Remover comentario inline (somente se precedido de espaco)
let valor = remover_comentario_inline(valor_raw);
// Verificar se o valor continua na proxima linha
if let Some(sem_barra) = valor.strip_suffix('\\') {
valor_continuacao = Some((
chave.to_string(),
sem_barra.trim_end().to_string(),
));
continue;
}
// Remover aspas ao redor do valor, se presentes
let valor_limpo = remover_aspas(&valor);
documento.adicionar_entrada(
&secao_atual,
chave.to_string(),
valor_limpo,
);
continue;
}
// Linha nao reconhecida
return Err(ErroIni::LinhaInvalida {
linha: num_linha,
conteudo: linha.to_string(),
});
}
// Se ficou uma continuacao pendente no final do arquivo
if let Some((chave, valor)) = valor_continuacao {
documento.adicionar_entrada(&secao_atual, chave, valor);
}
Ok(documento)
}
/// Faz o parse de um arquivo INI no caminho especificado
pub fn parse_arquivo(caminho: &str) -> Result<DocumentoIni, ErroIni> {
let path = Path::new(caminho);
let conteudo = fs::read_to_string(path).map_err(|e| {
ErroIni::ErroArquivo(format!("{}: {}", caminho, e))
})?;
parse_string(&conteudo)
}
/// Salva um DocumentoIni em um arquivo
pub fn salvar_arquivo(
documento: &DocumentoIni,
caminho: &str,
) -> Result<(), ErroIni> {
let conteudo = documento.to_string();
fs::write(caminho, conteudo).map_err(|e| {
ErroIni::ErroArquivo(format!("{}: {}", caminho, e))
})
}
/// Remove comentarios inline (texto apos ; ou # precedido de espaco)
fn remover_comentario_inline(valor: &str) -> String {
// Procurar ; ou # que seja precedido por espaco
// (evita remover ; dentro de valores como URLs)
let bytes = valor.as_bytes();
for i in 1..bytes.len() {
if (bytes[i] == b';' || bytes[i] == b'#') && bytes[i - 1] == b' ' {
return valor[..i].trim().to_string();
}
}
valor.to_string()
}
/// Remove aspas simples ou duplas ao redor do valor
fn remover_aspas(valor: &str) -> String {
let v = valor.trim();
if v.len() >= 2 {
if (v.starts_with('"') && v.ends_with('"'))
|| (v.starts_with('\'') && v.ends_with('\''))
{
return v[1..v.len() - 1].to_string();
}
}
v.to_string()
}
#[cfg(test)]
mod testes {
use super::*;
#[test]
fn testar_secao_e_chaves() {
let ini = "\
[servidor]
host = 127.0.0.1
porta = 8080
";
let doc = parse_string(ini).unwrap();
assert_eq!(doc.obter("servidor", "host"), Some("127.0.0.1"));
assert_eq!(doc.obter("servidor", "porta"), Some("8080"));
}
#[test]
fn testar_secao_global() {
let ini = "\
nome = minha-app
versao = 1.0
[banco]
url = sqlite://dados.db
";
let doc = parse_string(ini).unwrap();
assert_eq!(doc.obter("", "nome"), Some("minha-app"));
assert_eq!(doc.obter("", "versao"), Some("1.0"));
assert_eq!(doc.obter("banco", "url"), Some("sqlite://dados.db"));
}
#[test]
fn testar_comentarios() {
let ini = "\
; Isso e um comentario
# Isso tambem
[secao]
chave = valor ; comentario inline
";
let doc = parse_string(ini).unwrap();
assert_eq!(doc.obter("secao", "chave"), Some("valor"));
}
#[test]
fn testar_valores_com_aspas() {
let ini = "\
[dados]
nome = \"Joao da Silva\"
caminho = '/usr/local/bin'
";
let doc = parse_string(ini).unwrap();
assert_eq!(doc.obter("dados", "nome"), Some("Joao da Silva"));
assert_eq!(doc.obter("dados", "caminho"), Some("/usr/local/bin"));
}
#[test]
fn testar_valor_multiline() {
let ini = "\
[texto]
descricao = primeira linha \\
segunda linha \\
terceira linha
";
let doc = parse_string(ini).unwrap();
let valor = doc.obter("texto", "descricao").unwrap();
assert!(valor.contains("primeira linha"));
assert!(valor.contains("segunda linha"));
assert!(valor.contains("terceira linha"));
}
#[test]
fn testar_secao_invalida() {
let ini = "[secao sem fechamento\nchave = valor\n";
let resultado = parse_string(ini);
assert!(resultado.is_err());
}
#[test]
fn testar_chave_vazia() {
let ini = "[secao]\n = valor\n";
let resultado = parse_string(ini);
assert!(resultado.is_err());
}
#[test]
fn testar_multiplas_secoes() {
let ini = "\
[a]
x = 1
[b]
y = 2
[c]
z = 3
";
let doc = parse_string(ini).unwrap();
assert_eq!(doc.secoes().len(), 3);
assert_eq!(doc.obter("a", "x"), Some("1"));
assert_eq!(doc.obter("b", "y"), Some("2"));
assert_eq!(doc.obter("c", "z"), Some("3"));
}
#[test]
fn testar_linhas_vazias_e_espacos() {
let ini = "\
[secao]
chave = valor com espacos
";
let doc = parse_string(ini).unwrap();
assert_eq!(
doc.obter("secao", "chave"),
Some("valor com espacos")
);
}
#[test]
fn testar_serializacao() {
let mut doc = DocumentoIni::novo();
doc.definir("servidor", "host", "localhost");
doc.definir("servidor", "porta", "3000");
let saida = doc.to_string();
assert!(saida.contains("[servidor]"));
assert!(saida.contains("host = localhost"));
assert!(saida.contains("porta = 3000"));
}
}
O parser processa o arquivo linha a linha, mantendo o estado da secao atual. Para valores multiline, ele acumula o conteudo ate encontrar uma linha sem \ no final. Comentarios inline sao tratados com cuidado — o ; so e considerado comentario se precedido por espaco, evitando falsos positivos em URLs e valores.
Passo 4: Montando a Biblioteca e o Exemplo
O arquivo lib.rs exporta a API publica da biblioteca:
// src/lib.rs
pub mod documento;
pub mod erro;
pub mod parser;
// Re-exportar tipos principais para facilitar o uso
pub use documento::DocumentoIni;
pub use documento::SECAO_GLOBAL;
pub use erro::ErroIni;
pub use parser::{parse_arquivo, parse_string, salvar_arquivo};
Agora crie o exemplo de demonstracao em examples/demonstracao.rs:
// examples/demonstracao.rs
use ini_parser::{parse_string, DocumentoIni, SECAO_GLOBAL};
fn main() {
println!("=== Parser de Arquivos INI ===\n");
// Exemplo 1: Parsing de uma string INI
let conteudo_ini = r#"
; Configuracao da aplicacao
nome = MeuApp
versao = 2.5
[servidor]
host = 0.0.0.0
porta = 8080
max_conexoes = 200
[banco_de_dados]
url = postgres://localhost/meubanco
pool = 10
[log]
nivel = info
arquivo = /var/log/meuapp.log
"#;
match parse_string(conteudo_ini) {
Ok(doc) => {
println!("Documento parseado com sucesso!");
println!(
" Secoes: {}",
doc.secoes()
.iter()
.map(|s| if s.is_empty() { "(global)" } else { s })
.collect::<Vec<_>>()
.join(", ")
);
println!(" Total de chaves: {}\n", doc.total_chaves());
// Acessar valores
println!("Nome: {}", doc.obter(SECAO_GLOBAL, "nome").unwrap_or("?"));
println!("Host: {}", doc.obter("servidor", "host").unwrap_or("?"));
println!("Porta: {}", doc.obter("servidor", "porta").unwrap_or("?"));
println!(
"Banco: {}",
doc.obter("banco_de_dados", "url").unwrap_or("?")
);
println!(
"Nivel log: {}",
doc.obter("log", "nivel").unwrap_or("?")
);
}
Err(e) => {
eprintln!("Erro ao fazer parse: {}", e);
return;
}
}
// Exemplo 2: Criar e modificar um documento programaticamente
println!("\n--- Criando documento programaticamente ---\n");
let mut doc = DocumentoIni::novo();
doc.definir(SECAO_GLOBAL, "app", "demo");
doc.definir("rede", "porta", "3000");
doc.definir("rede", "protocolo", "https");
doc.definir("cache", "ttl", "3600");
doc.definir("cache", "max_tamanho", "100mb");
// Verificar existencia
println!("Tem secao 'rede'? {}", doc.tem_secao("rede"));
println!(
"Tem chave 'cache.ttl'? {}",
doc.tem_chave("cache", "ttl")
);
// Obter com valor padrao
println!(
"Timeout: {}",
doc.obter_ou("rede", "timeout", "30")
);
// Serializar de volta para INI
println!("\n--- Documento serializado ---\n");
println!("{}", doc);
// Exemplo 3: Modificar valores existentes
doc.definir("rede", "porta", "9090");
println!(
"Porta atualizada: {}",
doc.obter("rede", "porta").unwrap_or("?")
);
// Remover uma chave
if let Some(removido) = doc.remover("cache", "max_tamanho") {
println!("Removido cache.max_tamanho = {}", removido);
}
// Listar chaves de uma secao
println!("\nChaves em 'rede': {:?}", doc.chaves("rede"));
println!("Chaves em 'cache': {:?}", doc.chaves("cache"));
}
Como Executar
# Executar o exemplo de demonstracao
cargo run --example demonstracao
# Saida esperada:
# === Parser de Arquivos INI ===
#
# Documento parseado com sucesso!
# Secoes: (global), servidor, banco_de_dados, log
# Total de chaves: 9
#
# Nome: MeuApp
# Host: 0.0.0.0
# Porta: 8080
# Banco: postgres://localhost/meubanco
# Nivel log: info
#
# --- Criando documento programaticamente ---
#
# Tem secao 'rede'? true
# Tem chave 'cache.ttl'? true
# Timeout: 30
#
# --- Documento serializado ---
#
# app = demo
#
# [rede]
# porta = 3000
# protocolo = https
#
# [cache]
# max_tamanho = 100mb
# ttl = 3600
# Executar todos os testes
cargo test
# Saida esperada:
# running 10 tests
# test parser::testes::testar_secao_e_chaves ... ok
# test parser::testes::testar_secao_global ... ok
# test parser::testes::testar_comentarios ... ok
# test parser::testes::testar_valores_com_aspas ... ok
# test parser::testes::testar_valor_multiline ... ok
# test parser::testes::testar_secao_invalida ... ok
# test parser::testes::testar_chave_vazia ... ok
# test parser::testes::testar_multiplas_secoes ... ok
# test parser::testes::testar_linhas_vazias_e_espacos ... ok
# test parser::testes::testar_serializacao ... ok
# test result: ok. 10 passed; 0 failed
# Para usar como dependencia em outro projeto, adicione ao Cargo.toml:
# [dependencies]
# ini-parser = { path = "../ini-parser" }
Desafios para Expandir
Interpolacao de variaveis: Implemente suporte a
${secao.chave}nos valores, substituindo pela referencia ao valor de outra chave. Isso requer resolucao de dependencias e deteccao de ciclos.Tipagem de valores: Adicione metodos como
obter_inteiro,obter_float,obter_boolque convertem o valor string para o tipo correto, retornandoResultcom mensagens de erro claras.Merge de arquivos INI: Implemente uma funcao
mergeque combina dois documentos INI, com estrategias configuraveis para chaves duplicadas (sobrescrever, manter original, concatenar).Modo estrito e tolerante: Adicione um
ParserConfigque permite configurar o comportamento: modo estrito (erro em qualquer problema) vs. tolerante (ignora linhas invalidas e continua). Adicione suporte a:como separador alternativo (alem de=).Publicar como crate: Prepare a biblioteca para publicacao no crates.io: adicione documentacao com
///, exemplos em doc comments, um README, licenca e badges. Usecargo docpara gerar a documentacao HTML.
Veja Tambem
- HashMap: Tabelas Hash — estrutura central do nosso documento INI
- Manipulacao de Strings — parsing e processamento de texto
- Tipo Result — tratamento de erros no parser
- Traits Display e Debug — implementacao de formatacao para tipos
- Testes em Rust — como escrever e organizar testes