Parsing

March ships a parser combinator library, Parse, for turning text into structured data — config formats, wire protocols, small languages, anything with a grammar you own.

A combinator parser is built out of ordinary values: each piece is a Parser(a), and you compose small ones into big ones with plain function calls. There is no separate grammar file and no code generation step.

What makes this library worth using is the error messages. Getting a parser to accept valid input is the easy half; the hard half is telling someone what is wrong with input that is invalid, and most of this page is about the tools for that.


Your first parser

mod Main do
  needs IO.Console

  pfn is_digit(b : Int) : Bool do b >= 48 && b <= 57 end

  pfn number() : Parser.Parser(String) do
    Parser.take_while1("a number", is_digit)
  end

  fn main(_c : Cap(IO.Console)) : Unit do
    match Parser.run_all(number(), "123") do
      Ok(v)  -> println("parsed " ++ v)
      Err(e) -> println(Parser.render("123", e))
    end
  end
end

Two things to notice, because both are load-bearing.

Parser.run_all, not Parser.run. run succeeds as soon as its parser is satisfied and silently ignores whatever is left over:

Parser.run(number(), "123xyz")      -- Ok("123")  — the `xyz` is never looked at
Parser.run_all(number(), "123xyz")  -- Err — 1:4: I was expecting end of input

run is the right call when you are composing a parser into a larger one, or deliberately parsing a prefix of a stream. At the top level it is almost always a bug waiting to happen, so reach for run_all by default.

Bytes, not characters. Predicates take a byte code (0255), and positions are byte offsets. This is what keeps the scanning loop allocation-free. Offsets are converted to line/column exactly once, at render time, by Parser.render or Parser.line_col.


The building blocks

Combinator What it matches
lit(s) the literal string s
byte(code) one byte with that code
byte_if(name, pred) one byte satisfying pred
take_while(pred) zero or more bytes, as one String
take_while1(name, pred) one or more bytes, as one String
eof() only at end of input
pure(x) succeeds with x, consuming nothing

Prefer take_while1 over many(byte_if(...)) for runs of characters. The first slices once; the second allocates a list cell per byte and then rebuilds a string from it.


Sequencing and choice

Combinator Meaning
and_then(p, q) both, as a tuple (a, b)
skip_then(p, q) both, keep p’s value
skip_first(p, q) both, keep q’s value
map(p, f) transform the result
alt(p, q) try p; if it fails softly, try q
optional(p) Some/None, never fails softly
many(p) / many1(p) zero-or-more / one-or-more
sep_by(p, s) / sep_by1(p, s) separated lists
repeat(p, n) exactly n, failing if fewer
flat_map(p, f) choose the next parser from the parsed value

flat_map is the one that cannot be expressed by the others: it picks what to parse next based on what it just parsed, which is what length-prefixed formats and indentation-sensitive grammars need.


Recursive grammars need delay

Parsers are ordinary values built eagerly, so a rule that mentions itself would recurse while being constructed — before reading a byte — and never return. Wrap the back-edge in delay:

pfn expr() : Parser.Parser(String) do
  Parser.alt(
    Parser.take_while1("a number", is_digit),
    Parser.skip_first(Parser.lit("("),
      Parser.skip_then(Parser.delay(fn -> expr()), Parser.lit(")"))))
end

Parser.run_all(expr(), "(((7)))")   -- Ok("7")

This is not a March quirk — every combinator library in a strict language needs the same device. Forget it and you get a hang at construction time, not a parse error, so it is worth recognising the symptom.


Making errors good

This is the part that earns the library its keep. Three tools, each fixing a different failure of the default message.

label — replace token soup with a name

Without labels an expected-set degrades into expected -, 0..9, (, or fn``. Name the class instead:

Parser.label("a number", Parser.take_while1("a number", is_digit))

A label only substitutes when the parser failed without consuming input. If it got somewhere first, its inner error is more specific and is kept — so a label can never hide real progress.

ctx — say which construct you were inside

Parser.ctx("entry", ...)
-- 1:9: I was expecting a number in the entry that started at 1:1

ctx records where the enclosing construct started, which is usually the information that actually locates the mistake — the error is at 1:9, but the thing that is wrong began at 1:1.

commit — stop backtracking past the point of no return

By default a failed alternative just backtracks and something else gets tried, which is how a malformed if ends up reported as a bad expression at the wrong column. commit turns a soft failure into a hard one that alt will not swallow.

Where the commit sits decides whether the good message survives, and getting it wrong is silent — both spellings accept all valid input:

-- WRONG: only the `=` itself is committed; a bad VALUE still backtracks
and_then(key, and_then(commit(lit("=")), value))

-- RIGHT: everything after the key is committed
then_commit(key, skip_first(lit("="), value))

then_commit(p, q) is and_then(p, commit(q)) — the short spelling of the right shape. fence(p) scopes a commit, converting a hard failure back to a soft one so one construct’s commit cannot abort a sibling.

All three together

pfn entry() : Parser.Parser((String, String)) do
  Parser.ctx("entry",
    Parser.and_then(
      Parser.skip_then(key(), Parser.skip_then(ws(), Parser.lit("="))),
      Parser.commit(Parser.skip_first(ws(), number()))))
end
"width=80"      ->  Ok(("width", "80"))
"width=  "      ->  1:9: I was expecting a number in the entry that started at 1:1
"width=80junk"  ->  1:9: I was expecting end of input

Reporting every error, not just the first

A config file with three bad keys should report three problems. recover turns a failure into a value and resynchronises, so many collects the lot in one pass:

let item = Parser.skip_then(Parser.lit("ok"), Parser.optional(Parser.lit(";")))
let p    = Parser.many(Parser.recover(item, Parser.lit(";")))

Parser.run(p, "ok;BAD;ok;NOPE")
-- Ok([Ok(..), Err(..), Ok(..), Err(..)])  — 2 parsed, 2 errors, one pass

The result is the partial AST and the full error list in one structure — no side channel needed.

Have the item consume its own separator, as above. Otherwise the parser is handed a separator where an item is expected, fails there too, and you get a spurious error per separator.


Rendering

Parser.render(input, err) produces a diagnostic in the compiler’s own voice:

2:3: I was expecting `end` in the block that started at 1:1

Parser.line_col(input, pos) gives the raw 1-based (line, column) if you want to build your own.


Context-sensitive grammars with let*

flat_map picks the next parser from a value you just parsed. let* is the readable spelling of the same thing, and it works with Parser because the module and the type share a name:

-- a leading digit says how many items follow
pfn counted() : Parser.Parser(List(String)) do
  let* n = digit()
  Parser.repeat(Parser.lit("x"), n - 48)
end

Parser.run_all(counted(), "3xxx")   -- Ok(["x", "x", "x"])

Each let* binds the parsed value and sequences the rest, so length-prefixed formats, indentation-sensitive grammars and version negotiation all read as ordinary top-to-bottom code. Parser.flat_map remains available directly if you prefer it.