Better nom Error Messages with nom_locate

August 2026

UPDATE: The original version of this post only passed the name of the last parser in the chain. This version is updated to show all the parsers that lead to the error.

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 = "charlie delta";
  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 words "charlie delta" 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: "charlie delta",
            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 "charlie delta" 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 Content<'a> = LocatedSpan<&'a str, Vec<&'a str>>;

fn main() {
  let input = "alfa not bravo";
  let content = Content::new_extra(input, vec![]);
  let result = alfa_parser(content);
  fs::write("output.txt", format!("{:#?}", result))
    .unwrap();
}

fn alfa_parser(
  mut content: Content
) -> IResult<Content, ()> {
  content.extra.push("alfa_parser");
  let (content, _) = tag("alfa ").parse(content)?;
  let (content, _) = bravo_parser.parse(content)?;
  Ok((content, ()))
}

fn bravo_parser(
  mut content: Content
) -> IResult<Content, ()> {
  content.extra.push("bravo_parser");
  let (content, _) = tag("xxx").parse(content)?;
  Ok((content, ()))
}

That codes contains an intentional error where the bravo_parser fails. This is the error that gets produced:

Err(
    Error(
        Error {
            input: LocatedSpan {
                offset: 5,
                line: 1,
                fragment: "not bravo",
                extra: [
                    "alfa_parser",
                    "bravo_parser",
                ],
            },
            code: Tag,
        },
    ),
)

The input of the Error now contains a LocatedSpan with more details about where the error occurred. The extra field contains the values we appended to its Vec on lines 19 and 28. Collectively they give us the path to the parser that failed.

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::Content;
use crate::second_parser;

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

pub fn first_parser(
  mut content: Content
) -> IResult<Content, Content> {
  content.extra.push("first_parser");
  let (content, _) = tag("alfa").parse(content)?;
  let (content, _) = line_ending.parse(content)?;
  let (content, _) = tag("bravo").parse(content)?;
  let (content, result) =
    second_parser.parse(content)?;
  Ok((content, result))
}

and

src/second_parser.rs
use crate::Content;

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

pub fn second_parser(
  mut content: Content
) -> IResult<Content, Content> {
  content.extra.push("second_parser");
  let (content, result) =
    tag("fail here").parse(content)?;
  Ok((content, result))
}

These are the same basic type of parsers from the prior example. 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::Content;

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

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

pub fn report(result: ResultHolder) {
  match result.finish() {
    Ok(_) => println!("Parsing successful"),
    Err(e) => {
      let error_message = format!(
        "PARSING ERROR:\n-> {}\nfailed at:\n{}\non line {} column {}:",
        e.input.extra.join("\n-> "),
        e.input.fragment(),
        e.input.location_line(),
        e.input.get_utf8_column(),
      );
      let error_line = String::from_utf8(
        e.input.get_line_beginning().to_vec(),
      )
      .unwrap();
      let pointer_line = format!(
        "{}^",
        " ".repeat(e.input.get_utf8_column() - 1),
      );
      let parts = [
        error_message.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 Content<'a> = LocatedSpan<&'a str, Vec<&'a str>>;

fn main() {
  let input = Content::new_extra(
    "alfa\nbravo charlie delta",
    vec![],
  );
  let result = first_parser.parse(input);
  report(result);
}

And, when we run it, we get this:

PARSING ERROR:
-> first_parser
-> second_parser
failed at:
 charlie delta
on line 2 column 6:
bravo charlie delta
     ^

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