Skip to content
/ parcom Public

Parser combinators for Zig, ready to parse on-the-fly. Consume input, not memory.

License

Notifications You must be signed in to change notification settings

dokwork/parcom

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

21 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

parcom

parcom ci zig version

Consume input, not memory.

Warning

This library is underdeveloped. API is not stable.

This library provides an implementation of the parser combinators.

Three different types of parser implementations exist:

  • The base parser implementations contain the logic for parsing input and serve as the fundamental building blocks;
  • The ParserCombinatorprovides methods to combine parsers and create new ones;
  • The TaggedParser erases the type of the underlying parser and simplifies the parser's type declaration.

Every parser provides the type of the parsing result as a constant ResultType: type.

Parcom offers two options for consuming data:

  • parse the entire input string at once,
  • or consume and parse byte by byte from AnyReader.

When the input is a reader, Parcom works as a buffered reader. It reads few bytes to the buffer and then parse them.

The result of parsing by any parser can be a value of type ResultType in successful case, or null if parsing was failed. In successful case not whole input can be consumed. If you have to be sure, that every byte was consumed and parsed, use the end() parser explicitly.

Installation

Fetch Parcom from github:

zig fetch --save git+https://github.com/dokwork/parcom

Check that it was added to the list of dependencies in your build.zig.zon file:

...
    .dependencies = .{
        .parcom = .{
            .url = "git+https://github.com/dokwork/parcom#b93b8fb14f489007f27d42f8254f12b7d57d07da",
            .hash = "parcom-0.3.0-Hs8wfHFUAQBhhH-swYl1wrMLSh76uApvVzYBl56t90Ua",
        },
    },

Add Parcom module to your build.zig:

    const parcom = b.dependency("parcom", .{
        .target = target,
        .optimize = optimize,
    });
    ...
    exe.root_module.addImport("parcom", parcom.module("parcom"));

Quick start

Let's create a parser, which will parse and execute a simple math expression with follow grammar:

# The `number` is a sequence of unsigned integer numbers
Number := [0-9]+
# The `value` is a `number` or an `expression` in brackets
Value  := Number / '(' Expr ')'
# The `sum` is an operation of adding or substraction of two or more values
Sum    := Value (('+' / '-') Value)*
# The `expression` is result of evaluation the combination of values and operations
Expr   := evaluate(Sum)

Our parser will be capable of parsing and evaluating mathematical expressions that include addition and subtraction operations, unsigned integers, and nested expressions within brackets.

Base parser

The number from the grammar above is a sequence of symbols from the range ['0', '9']. Parcom has a constructor of the parser of bytes in a range, but we will create our own parser starting from the base parser AnyChar. AnyChar is a simplest parser consumed the input. It returns the next byte from the input, or null if the input is empty.

To parse only numeric symbols we should provide a classifier - function that receives the result of a parser and returns true only if it is an expected value:

const parcom = @import("parcom");

// ResultType: u8
const num_char = parcom.anyChar().suchThat({}, struct {
    fn condition(_: void, ch: u8) bool {
        return switch (ch) {
            '0' ... '9' => true,
            else => false,
        };
    }
}.condition);

Every function required i combinators in Parcom library has a context parameter. That gives more flexibility for possible implementations of that functions.

Repeat parsers

Next, we should continue applying our parser until we encounter the first non-numeric symbol or reach the end of the input. To achieve this, we need to store the parsed results. The simplest solution is to use a sentinel array:

// ResultType: [10:0]u8
const number = num_char.repeatToSentinelArray(.{ .max_count = 10 });

But that option is available only for parsers with scalar result types. For more general cases a regular array can be used. If you know exact count of elements in the parsed sequence, you can specified it to have an array with exact length as result:

// ResultType: [3]u8
const number = num_char.repeatToArray(3);

However, this is a rare case. More often, the exact number of elements is unknown, but the maximum number can be estimated:

// ResultType: struct { [10]u8, usize }
const number = num_char.repeatToArray(.{ .max_count = 10 });

In such cases, the result is a tuple consisting of the array and a count of the parsed items within it.

For cases, when impossible to predict the maximum count we can allocate a slice to store the parsed results:

// ResultType: []u8
const number = num_char.repeat(allocator, .{});

// Don't forget to free the memory, allocated for the slice!

or use an arbitrary storage and a function to add an item to it:

var list = std.ArrayList(u8).init(allocator);
defer list.deinit();
// ResultType: *std.ArrayList(u8)
const p = anyChar().repeatTo(&list, .{}, std.ArrayList(u8).append);

Notice, that no matter which combinator you use to collected repeated numbers, you have to set the .min_count to 1, because of empty collection of chars is not a number!

// ResultType: []u8
const number = num_char.repeat(allocator, .{ .min_count = 1 });

RepeatOptions

All repeated combinators except the repeatToArray(usize) receive the RepeatOptions, a structure with minimum and maximum counts of the elements in the sequence. All parsers stop when reach the maximum count and fail if don't reach the minimum.

Try one or try another

We'll postpone the value parser for now, and instead of that will focus on creating a parsers for the '+' and '-' symbols.

// ResultType: i32
const value: ParserCombinator(???) = ???; 

First of all, we should be able to parse every symbol separately. The char parser is the best candidate for it:

const plus = parcom.char('+');
const minus = parcom.char('-');

Next, we have to choose one of them. To accomplish this, let's combine parsers to a new one, that first attempt one, and if it fails, it will try the other:

// ResultType: parcom.Either(u8, u8)
const plus_or_minus = plus.orElse(minus);

The result type of the new parser is parcom.Either(L, R), an alias for union(enum) { left: L, right: R } type.

Combine results

We have a parser for operations and we assume that we have a parser for values as well. This is sufficient to build the Sum parser, which, as you may recall, follows this structure:

Sum := Value (('+' / '-') Value)*

Let's start from the part in brackets. We have to combine the plus_or_minus parser with value parser and repeat result:

// ResultType: []struct{ parcom.Either(u8, u8), i32 }
plus_or_minus.andThen(value).repeat(allocator, .{});

The andThen combinator runs the left parser and then the right. If both parsers were successful, it returns a tuple of results. Finally, we can combine the value with the new parser to have the version of the expression parser that follows the grammar:

// ResultType: struct{ i32, []struct{ parcom.Either(u8, u8), i32 } }
const sum = value.andThen(plus_or_minus.andThen(value).repeat(allocator, .{}));

Transform the result

So far so good. We are ready to create a parser that will not only parse the input, but also sum of parsed values:

const expr = sum.transform(i32, {}, struct {
    fn evaluate(_: void, value: struct{ i32, []struct{ Either(u8, u8), i32 } }) !i32 {
        var result: i32 = value[0];
        for (value[1]) |op_and_arg| {
            switch(op_and_arg[0]) {
                .left => result += op_and_arg[1],
                .right => result -= op_and_arg[1],
            )
        }
        return result;
    }
}.evaluate);

The combinator transform requires a context and a function for transformation. It runs the left parser and applies the function to the parsed result.

Tagged parser

Now the time to build the value parser:

Value  := Number / '(' Expr ')'

This is a recursive parser that not only forms part of the expression parser but also depends on it. How we can implement this? First of all, let's wrap the expression parser to the function:

const std = @import("std");
const parcom = @import("parcom");

fn expression(allocator: std.mem.Allocator) ??? {

    // ResultType: u8
    const num_char = parcom.anyChar().suchThat({}, struct {
        fn condition(_: void, ch: u8) bool {
            return switch (ch) {
                '0' ... '9' => true,
                else => false,
            };
        }
    }.condition);

    // ResultType: i32
    const number = num_char.repeat(allocator, .{ .min_count = 1 }).transform(i32, {}, struct {
        fn parseInt(_: void, value: []u8) !i32 {
            return try std.fmt.parseInt(i32, value, 10);
        }
    }.parseInt);

    // ResultType: i32
    const value = ???;

    // ResultType: parcom.Either(u8, u8)
    const plus_or_minus = parcom.char('+').orElse(parcom.char('-'));

    // ResultType: struct{ i32, []struct{ parcom.Either(u8, u8), i32 } }
    const sum = value.andThen(plus_or_minus.andThen(value).repeat(allocator, .{}));

    const expr = sum.transform(i32, {}, struct {
        fn evaluate(_: void, v: struct{ i32, []struct{ parcom.Either(u8, u8), i32 } }) !i32 {
            var result: i32 = v[0];
            for (v[1]) |op_and_arg| {
                switch(op_and_arg[0]) {
                    .left => result += op_and_arg[1],
                    .right => result -= op_and_arg[1],
                }
            }
            return result;
        }
    }.evaluate);

    return expr;
}

The type of ParserCombinator in Parcom can be very cumbersome, and it is often impractical to manually declare it as a function's type. However, Zig requires this type to allocate enough memory for the parser instance. While most parsers in Parcom are simply namespaces, this is not true for all of them. What can we do is moving our parser to heap and replace particular type by the pointer to it. This is exactly how the TaggedParser works. It has a pointer to the original parser, and a pointer to a function responsible for parsing the input. More over, the TaggedParser has explicit ResultType:

const std = @import("std");
const parcom = @import("parcom");

fn expression(allocator: std.mem.Allocator) parcom.TaggedParser(i32) {
    ...
    return expr.taggedAllocated(allocator);
}

Deferred parser

Let's go ahead and finally build the value parser:

const value = number.orElse(
    parcom.char('(').rightThen(expression(allocator)).leftThen(parcom.char(')')
);

Pay attention on rightThen and leftThen combinators. Unlike the andThen combinator, these two do not produce a tuple. Instead, they ignore one value and return another. The rightThen uses only result of the right parser, and leftThen of the left parser respectively. It means, that both brackets will be parsed, but ignored in the example above.

But this is not all. Unfortunately, such implementation of the value parser will lead to infinite loop of invocations the expression function. We can solve this by invoking the function only when we need to parse an expression within brackets. The Parcom has the deferred parser for such purposes. It receives the ResultType of TaggedParser which should be returned by the function, a context that should be passed to the function and pointer to the function:

const value = number.orElse(
    parcom.char('(').rightThen(parcom.deferred(i32, allocator, expression)).leftThen(parcom.char(')'))
);

When the tagged parsed completes its deferred work, the deinit method will be invoked, and memory will be freed. But, do not forget to invoke deinit manually, when you create the TaggedParser outside the deferred parser!

Complete solution
const std = @import("std");
const parcom = @import("parcom");

fn expression(allocator: std.mem.Allocator) !parcom.TaggedParser(i32) {

    // ResultType: u8
    const num_char = parcom.anyChar().suchThat({}, struct {
        fn condition(_: void, ch: u8) bool {
            return switch (ch) {
                '0' ... '9' => true,
                else => false,
            };
        }
    }.condition);

    // ResultType: i32
    const number = num_char.repeat(allocator, .{ .min_count = 1 }).transform(i32, {}, struct {
        fn parseInt(_: void, value: []u8) !i32 {
            return try std.fmt.parseInt(i32, value, 10);
        }
    }.parseInt);

    // ResultType: i32
    const value = number.orElse(
        parcom.char('(').rightThen(parcom.deferred(i32, allocator, expression)).leftThen(parcom.char(')'))
    )
    .transform(i32, {}, struct {
        fn getFromEither(_: void, v: parcom.Either(i32, i32)) !i32 {
            return switch (v) {
                .left => v.left,
                .right => v.right,
            };
        }
    }.getFromEither);

    // ResultType: parcom.Either(u8, u8)
    const plus_or_minus = parcom.char('+').orElse(parcom.char('-'));

    // ResultType: struct{ i32, []struct{ parcom.Either(u8, u8), i32 } }
    const sum = value.andThen(plus_or_minus.andThen(value).repeat(allocator, .{}));

    // ResultType: i32
    const expr = sum.transform(i32, {}, struct {
        fn evaluate(_: void, v: struct{ i32, []struct{ parcom.Either(u8, u8), i32 } }) !i32 {
            var result: i32 = v[0];
            for (v[1]) |op_and_arg| {
                switch(op_and_arg[0]) {
                    .left => result += op_and_arg[1],
                    .right => result -= op_and_arg[1],
                }
            }
            return result;
        }
    }.evaluate);

    return expr.taggedAllocated(allocator);
}

test "9-(5+2) == 2" {
    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    const parser = try expression(arena.allocator());
    try std.testing.expectEqual(2, try parser.parseString("9-(5+2)"));
}

Cutting the input

In some cases it is reasonable not to consume the entire input to the string, and instead parse it on-the-fly. For such cases, the Parcom library provides the parseFromReader method, which takes a std.io.AnyReader as the input. During the parsing, all consumed bytes are stored in an internal buffer to make it possible to rollback the input and try another parser (such as with the orElse combinator). While this approach may lead to the same result as reading the whole input to the string, rollback may not make sense for some parsers. For example, when parsing JSON, encountering the '{' symbol means the entire JObject must be parsed. If parsing cannot proceed, it indicates that the input is malformed, and all parsers will failed. It means, that the input can be cropped right before the '{' symbol.

In the example above can be reasonable to cut the input when the left brace is parsed:

...
const value = number.orElse(
    parcom.char('(').cut().rightThen(parcom.deferred(i32, allocator, expression)).leftThen(parcom.char(')'))
//         added this ^
)
...

Cropping the input, when possible, can significantly reduce required memory and may improve the speed of parsing. See this example for more details.

Debug

When something is going wrong during the parsing, and a correct at first glance parser returns null, it can be difficult to understand the root cause without additional insights. In Parcom you can turn on logging for any particular parser to see how it works during the parsing. For example, let's turn on logging for the expression parser from the example above (with added cut combinator)

...
    return expr.logged(.{ .label = "EXPR", .scope = .example }).taggedAllocated(allocator);
}

and run it on a string with unexpected symbol '!':

test "parse unexpected symbol" {
    // don't forget to turn on debug level for the test
    std.testing.log_level = .debug;
    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    const parser = try expression(arena.allocator());
    try std.testing.expectEqual(2, try parser.parseString("9-(!5+2)"));
}

Now, we have enough insights to understand what happened and where it occurred:

error: 'expression.test.parse unexpected symbol' failed: [example] (debug):
The parsing by the <EXPR> has been started from position 0:
[9]-(!5+2)
[example] (debug):
The parsing by the <EXPR> has been started from position 3:
…[!]5+2)
[example] (debug): The parsing is failed at position 3:
…[!]5+2)
[example] (debug): End parsing by the <EXPR>. Cut 3 items during the parsing process.
[parcom] (warn): Imposible to reset the input from 3 to 2 at position 3:
…[!]5+2).
[example] (debug): An error error.ResetImposible occured on parsing by <EXPR> at position 3:
…[!]5+2)
[example] (debug): End parsing by the <EXPR>. Cut 3 items during the parsing process.

Documentation

https://dokwork.github.io/parcom/index.html

Examples