nom en Rust: parsing combinatorio para protocolos, formatos y lenguajes

nom es una librería de parser combinators para Rust. En lugar de generar un parser desde una gramática (como ANTLR o yacc), en nom compones parsers pequeños y simples para construir parsers más complejos. Cada parser es una función que toma entrada y devuelve la entrada restante junto con el dato parseado.

Es especialmente útil para parsear protocolos binarios, formatos de datos personalizados o lenguajes de configuración.

[dependencies]
nom = "7"

IResult: el tipo de retorno de nom

Todos los parsers de nom devuelven IResult:

pub type IResult<I, O, E = Error<I>> = Result<(I, O), Err<E>>;
//                                              ^^^^^^^^
//                                    (entrada_restante, salida_parseada)

Si el parser tiene éxito, devuelve Ok((resto, valor)) donde resto es la parte de la entrada que no se ha consumido y valor es el dato parseado. Si falla, devuelve un error.

Parsers básicos

use nom::{
    bytes::complete::{tag, take, take_while},
    character::complete::{alpha1, digit1, alphanumeric1, char, space0, space1},
    IResult,
};

// tag: coincide con un string literal exacto
fn parsear_hola(input: &str) -> IResult<&str, &str> {
    tag("hola")(input)
}

// digit1: uno o más dígitos
fn parsear_numero(input: &str) -> IResult<&str, &str> {
    digit1(input)
}

fn main() {
    println!("{:?}", parsear_hola("hola mundo"));
    // Ok((" mundo", "hola"))

    println!("{:?}", parsear_numero("123abc"));
    // Ok(("abc", "123"))

    println!("{:?}", parsear_hola("adios"));
    // Err(...)
}

Combinadores: componer parsers

La potencia de nom está en los combinadores, que toman parsers como argumento y devuelven parsers más complejos:

use nom::{
    branch::alt,
    combinator::{map, opt, value},
    multi::{many0, many1, separated_list0},
    sequence::{preceded, terminated, delimited, pair, tuple},
    IResult,
};

// pair: ejecuta dos parsers en secuencia, devuelve tupla
fn clave_valor(input: &str) -> IResult<&str, (&str, &str)> {
    pair(
        alphanumeric1,
        preceded(char('='), alphanumeric1),
    )(input)
}

// alt: prueba alternativas en orden, devuelve la primera que funciona
fn booleano(input: &str) -> IResult<&str, bool> {
    alt((
        value(true, tag("true")),
        value(false, tag("false")),
    ))(input)
}

// many1: repite un parser una o más veces
fn lista_digitos(input: &str) -> IResult<&str, Vec<&str>> {
    many1(preceded(space0, digit1))(input)
}

println!("{:?}", clave_valor("nombre=Ana"));
// Ok(("", ("nombre", "Ana")))

println!("{:?}", booleano("true y algo más"));
// Ok((" y algo más", true))

Un parser de CSV

use nom::{
    bytes::complete::is_not,
    character::complete::char,
    multi::separated_list0,
    sequence::delimited,
    IResult,
};

// Un campo CSV puede estar entre comillas o no
fn campo_csv(input: &str) -> IResult<&str, &str> {
    alt((
        delimited(char('"'), is_not("""), char('"')),
        is_not(",n"),
    ))(input)
}

// Una línea CSV es campos separados por comas
fn linea_csv(input: &str) -> IResult<&str, Vec<&str>> {
    separated_list0(char(','), campo_csv)(input)
}

// El CSV completo es líneas separadas por newlines
fn csv_completo(input: &str) -> IResult<&str, Vec<Vec<&str>>> {
    separated_list0(char('n'), linea_csv)(input)
}

fn main() {
    let csv = "nombre,edad,ciudadnAna,30,Madridn"García, José",25,Barcelona";
    let (_, datos) = csv_completo(csv).unwrap();
    for fila in datos {
        println!("{:?}", fila);
    }
    // ["nombre", "edad", "ciudad"]
    // ["Ana", "30", "Madrid"]
    // ["García, José", "25", "Barcelona"]
}

Parsear protocolos binarios

nom es especialmente útil para protocolos binarios donde cada campo tiene un tamaño fijo y un encoding específico:

use nom::{
    bytes::complete::take,
    number::complete::{be_u16, be_u32, le_u32},
    IResult,
};

#[derive(Debug)]
struct CabeceraPaquete {
    version: u8,
    tipo_mensaje: u8,
    longitud: u32,
    id_sesion: u16,
}

#[derive(Debug)]
struct Paquete<'a> {
    cabecera: CabeceraPaquete,
    datos: &'a [u8],
}

fn parsear_cabecera(input: &[u8]) -> IResult<&[u8], CabeceraPaquete> {
    let (input, cabecera_bytes) = take(8u8)(input)?;  // cabecera de 8 bytes
    let (_, version) = take(1u8)(cabecera_bytes)?;
    let (_, tipo) = take(1u8)(&cabecera_bytes[1..])?;
    let (_, longitud) = be_u32(&cabecera_bytes[2..6])?;
    let (_, id_sesion) = be_u16(&cabecera_bytes[6..8])?;

    Ok((input, CabeceraPaquete {
        version: version[0],
        tipo_mensaje: tipo[0],
        longitud,
        id_sesion,
    }))
}

fn parsear_paquete(input: &[u8]) -> IResult<&[u8], Paquete> {
    let (input, cabecera) = parsear_cabecera(input)?;
    let longitud = cabecera.longitud as usize;
    let (input, datos) = take(longitud)(input)?;

    Ok((input, Paquete { cabecera, datos }))
}

map y map_res: transformar resultados

El combinador map transforma el valor producido por un parser. map_res permite transformaciones que pueden fallar:

use nom::{
    character::complete::digit1,
    combinator::{map, map_res},
    IResult,
};

// Parsear un número y convertirlo a u64
fn numero_u64(input: &str) -> IResult<&str, u64> {
    map_res(digit1, |s: &str| s.parse::<u64>())(input)
}

// Parsear "true"/"false" a bool
fn booleano_v2(input: &str) -> IResult<&str, bool> {
    map(
        alt((tag("true"), tag("false"))),
        |s: &str| s == "true",
    )(input)
}

println!("{:?}", numero_u64("42 abc"));    // Ok((" abc", 42u64))
println!("{:?}", booleano_v2("false!"));  // Ok(("!", false))

Gestión de errores en nom

Por defecto nom usa errores básicos. Para mensajes de error más informativos, usa el tipo VerboseError:

use nom::{
    error::{VerboseError, convert_error},
    Err,
};

fn parsear_con_error(input: &str) -> IResult<&str, &str, VerboseError<&str>> {
    nom::bytes::complete::tag::<_, _, VerboseError<&str>>("hola")(input)
}

match parsear_con_error("adios") {
    Err(Err::Error(e)) | Err(Err::Failure(e)) => {
        println!("Error:n{}", convert_error("adios", e));
    }
    _ => {}
}

nom es la herramienta estándar en el ecosistema Rust para parsear datos binarios y formatos personalizados. Para texto con gramáticas más complejas, también existen pest (que usa una gramática PEG) y chumsky (parser combinators con mejor manejo de errores). Cada uno tiene su caso de uso ideal.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP