Parser de Arquivos INI em Rust

Construa um parser completo de arquivos INI em Rust como biblioteca reutilizavel com secoes, comentarios, valores multiline, API publica e testes.

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

  1. 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.

  2. Tipagem de valores: Adicione metodos como obter_inteiro, obter_float, obter_bool que convertem o valor string para o tipo correto, retornando Result com mensagens de erro claras.

  3. Merge de arquivos INI: Implemente uma funcao merge que combina dois documentos INI, com estrategias configuraveis para chaves duplicadas (sobrescrever, manter original, concatenar).

  4. Modo estrito e tolerante: Adicione um ParserConfig que 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 =).

  5. Publicar como crate: Prepare a biblioteca para publicacao no crates.io: adicione documentacao com ///, exemplos em doc comments, um README, licenca e badges. Use cargo doc para gerar a documentacao HTML.

Veja Tambem