Better nom Error Messages with nom_locate

August 2026

Parsing Parser Errors

I designed a note taking format called NeoDocnd. It currently exists only as an embedded feature of my personal static site generator. I'm in the process of extracting it to its own library. That means digging into the nomnom parser/combinator and its cryptic as hell error messages.

"Just how cryptic?" you ask. Let me give you an example.

A First Look

This codes makes a tiny rust app that uses nom's alt feature to attempt to use two child parsers to process a piece of text.

Cargo.toml
[package]
name = "default_errors"
version = "0.1.0"
edition = "2024"

[dependencies]
nom = "8.0.0"
use nom::branch::alt;
use nom::bytes::complete::tag;
use nom::{IResult, Parser};
use std::fs;

fn main() {
  let input = "target";
  let result = alt((alfa, bravo)).parse(input);
  fs::write("output.txt", format!("{:#?}", result))
    .unwrap();
}

fn alfa(input: &str) -> IResult<&str, &str> {
  let (input, result) = tag("xxx").parse(input)?;
  Ok((input, result))
}

fn bravo(input: &str) -> IResult<&str, &str> {
  let (input, result) = tag("yyy").parse(input)?;
  Ok((input, result))
}

The parsers attempt to process the word "target" as input. They both fail since neither of their patterns (i.e. "xxx" or "yyy") match the word. The result is that nom throws this error:

Err(
    Error(
        Error {
            input: "target",
            code: Tag,
        },
    ),
)

Figuring out the issue in this example is no big deal. It's quick to see that neither the alfa nor the bravo parsers match the "target" input passed to them. Things aren't so obvious in real world parsers. There are tons of different functions branching out and interacting with each other. It doesn't take long to cross a threshold where figuring out where an error occurred is a non-trivial task.

I used to use a crate called nom_supremens to help with this. It provided errors with more details about where they came from. Unfortunately, it doesn't work with the most recent version of nom.

That's where the nom_locate crate enters the scene.

Locating Errors

nom_locatenl works by adding a LocatedSpan struct to the mix. Your input goes inside and gets passed around as a passenger. For example:

Cargo.toml
[package]
name = "locate_basic"
version = "0.1.0"
edition = "2024"

[dependencies]
nom = "8.0.0"
nom_locate = "5.0.0"
use nom::bytes::complete::tag;
use nom::{IResult, Parser};
use nom_locate::LocatedSpan;
use std::fs;

type Span<'a> = LocatedSpan<&'a str, &'a str>;

fn main() {
  let input = "target";
  let span = Span::new_extra(input, "");
  let result = some_parser(span);
  fs::write("output.txt", format!("{:#?}", result))
    .unwrap();
}

fn some_parser(mut span: Span) -> IResult<Span, Span> {
  span.extra = "some_parser";
  let (span, result) = tag("xxx").parse(span)?;
  Ok((span, result))
}
This is the error that gets produced:
Err(
    Error(
        Error {
            input: LocatedSpan {
                offset: 0,
                line: 1,
                fragment: "target",
                extra: "some_parser",
            },
            code: Tag,
        },
    ),
)

The input now contains the LocatedSpan with more details about where the error occurred. The value of the extra field is what we defined on line 17. Using it as a reference, we know in which function the failure occurred.

That's a huge improvement. And, it gets even better.

Showing Off Errors

We'll use a little more complicated example to demonstrate what nom_locate can do. It starts by defining two parser functions:

Cargo.toml
[package]
name = "locate_report"
version = "0.1.0"
edition = "2024"

[dependencies]
nom = "8.0.0"
nom_locate = "5.0.0"
src/first_parser.rs
use crate::Span;
use crate::second_parser;

use nom::bytes::complete::tag;
use nom::character::complete::line_ending;
use nom::{IResult, Parser};

pub fn first_parser(
  mut span: Span
) -> IResult<Span, Span> {
  span.extra = "prelude_parser";
  let (span, _) = tag("prelude").parse(span)?;
  let (span, _) = line_ending.parse(span)?;
  let (span, _) = tag("spacer").parse(span)?;
  let (span, result) = second_parser.parse(span)?;
  Ok((span, result))
}

and

src/second_parser.rs
use crate::Span;

use nom::bytes::complete::tag;
use nom::{IResult, Parser};

pub fn second_parser(
  mut span: Span
) -> IResult<Span, Span> {
  span.extra = "second_parser";
  let (span, result) = tag("xxx").parse(span)?;
  Ok((span, result))
}

These are the same basic type of parsers from the prior examples. The big difference is that first_parser does a little more work to get us down to a second line before passing off to second_parser. That'll help demonstrate the error output of this report function:

src/report.rs
use crate::Span;

use nom::error::Error;
use nom::{Err, Finish};
use nom_locate::LocatedSpan;
use std::cmp::max;
use std::fs;

type ResultHolder<'a> = Result<
  (Span<'a>, Span<'a>),
  Err<Error<LocatedSpan<&'a str, &'a str>>>,
>;

pub fn report(result: ResultHolder) {
  match result.finish() {
    Ok(_) => println!("Parsing successful"),
    Err(e) => {
      let error_message = format!(
        "ERROR: {} failed on line {} column {}",
        e.input.extra,
        e.input.location_line(),
        e.input.get_utf8_column(),
      );
      let error_line = String::from_utf8(
        e.input.get_line_beginning().to_vec(),
      )
      .unwrap();
      let divider_spaces = max(
        error_message.chars().collect::<Vec<_>>().len(),
        error_line.chars().collect::<Vec<_>>().len(),
      );
      let pointer_line = format!(
        "{}^",
        " ".repeat(e.input.get_utf8_column() - 1),
      );
      let parts = [
        error_message.to_string(),
        "-".repeat(divider_spaces).to_string(),
        error_line.to_string(),
        pointer_line.to_string(),
      ];
      fs::write("output.txt", parts.join("\n"))
        .unwrap();
    }
  }
}

Here's the overview of how it works:

We tie all that toghter in this main.rs file:

src/main.rs
mod first_parser;
mod report;
mod second_parser;

use first_parser::first_parser;
use nom::Parser;
use nom_locate::LocatedSpan;
use report::report;
use second_parser::second_parser;

type Span<'a> = LocatedSpan<&'a str, &'a str>;

fn main() {
  let input =
    Span::new_extra("prelude\nspacer target", "");
  let result = first_parser.parse(input);
  report(result);
}

And, when we run it, we get this:

ERROR: second_parser failed on line 2 column 7
----------------------------------------------
spacer target
      ^

Orders of magnititude more useful than the original.

Outro

It took an hour or two to figure out the approach and dial things in. It took the rest of the day to write this post. At 8pm I've done zero work on the parser itself. I'm cool with that. I'll be able to move so much faster with these upgraded error messages. I'll make up the time in nothing flat.

-a

Endnote

  • I'm not knocking nom for the terse error messages. It's designed to be as fast as possible. You can use the error payload to figure out where issues are. It just takes a lot of effort.

    I expect I'm taking a hit on the parsing speed by using nom_locate. I can't tell though. It rips through blog posts so fast it might as well be instentaneous.

Footnotes

  • nd NeoDoc is a note taking format. It's similar to Markdown, but much more powerful. It's currently embedded in my personal static site generator. I want to be able to do more with it so I'll pulling it out to its own library.

  • nom nom is a parser combinator (aka text processig on steriods). The learning curve is significant. I recommend against learning it at the same time you're learning Rust. Ask me how I know.

  • ns nom_supreme was my go to helper for improving nom error messages in nom v7. It was a bit tricky to set up though. Even if it was available in the current nom v8 I'd probably still use the nom_locate approach as it's simpler to get going.

  • nl nom_locate is my new best friend. Judging by the 30 million downloads on crates.io it looks like I'm not alone. It's going to save me sooo much time. It almost feels like cheating.