Parse indentation-based input
Problem. A file nests its entries by indentation, as YAML or Python do, and you want the nesting as Go maps. A line that is indented inconsistently should be reported as that.
PEG has no memory, so the grammar keeps the indentation of the current block in a variable and checks it with predicates.
// nested.pego
type Entry struct {
Key Match
Value *Match
Children []Entry
}
def main = [indent = 0] es:block blank* $$ -> $es
// The entries at the current indentation.
def block = (blank* e:entry)+ -> map($1, (x) => $x.e)
// A line indented exactly at the current depth, with the lines below it that are indented deeper.
def entry: Entry = s:spaces [len($s) == indent] #error(message="inconsistent indentation")
k:key ":" " "* v:value? eol cs:children?
-> new Entry{Key: $k, Value: $v, Children: concat($cs)}
// If the next line is indented deeper, read it and its siblings with that depth as the new indentation.
// A variable is visible only in the rule that defines it and the rules it calls, so when this rule returns, the
// parent's indentation is back.
def children = &(blank* s:spaces) [len($s) > indent] [indent = len($s)] b:block -> $b
def spaces = @" "*
def key = @(?a-z_)+
def value = @(?^\n)+
def eol = "\n" / $$
def blank = (? \t)* "\n"
// main.go
package main
import (
_ "embed"
"encoding/json"
"fmt"
"log"
"os"
"github.com/ornew/pego"
)
//go:embed nested.pego
var grammar string
// toMap turns entries into nested maps: an entry with children becomes a map, any other a string.
func toMap(entries []*pego.Node) map[string]any {
m := map[string]any{}
for _, e := range entries {
key := e.Field("Key").(*pego.Node).Text
if kids := e.Field("Children").(*pego.Node).Children; len(kids) > 0 {
m[key] = toMap(kids)
} else if v, ok := e.Field("Value").(*pego.Node); ok {
m[key] = v.Text
} else {
m[key] = ""
}
}
return m
}
func main() {
log.SetFlags(0) // no time stamps in front of the errors
p, err := pego.CompileSource(grammar, "main")
if err != nil {
log.Fatal(err)
}
src, err := os.ReadFile(os.Args[1])
if err != nil {
log.Fatal(err)
}
tree, err := p.Parse(string(src))
if err != nil {
log.Fatal(err)
}
out, _ := json.MarshalIndent(toMap(tree.Children), "", " ")
fmt.Println(string(out))
}
With this app.yml:
server:
host: localhost
port: 8080
tls:
cert: server.pem
key: server.key
log: debug
run it:
go run . app.yml
{
"log": "debug",
"server": {
"host": "localhost",
"port": "8080",
"tls": {
"cert": "server.pem",
"key": "server.key"
}
}
}
A line that is indented to a depth that no enclosing block has, in bad.yml:
server:
host: localhost
port: 8080
go run . bad.yml
3:3: inconsistent indentation
How it works
[indent = 0]defines the variable at the start. A predicate[name = value]defines a variable; it never changes one. The definition is visible to the rules called after it, and ends when the rule that made it returns.entrymatches the leading spaces, captures them ass, and the predicate[len($s) == indent]succeeds only at the current depth. This is also how a block ends: at a line with another indentation,entryfails andblock's repetition stops.childrenlooks ahead at the next line with&(blank* s:spaces). A capture made in a positive lookahead stays visible after it, so the predicates can read$swithout consuming the spaces. If the line is indented deeper, the new depth is defined with[indent = len($s)], and the ruleblockcalled after it sees the new depth. Whenchildrenreturns, the parent'sindentis back: the variable is scoped by the rule invocation, and a counter is a chain of new bindings, never a mutation.#erroron the predicate. A predicate that fails records no expected token, so without the label the message for the third line above issyntax error: expected " ", "\n", (? \t), which is true and unhelpful.*Matchfor the optional value.v:value?is aMatchor nothing, which the struct field must say with*Match; in Go,Field("Value")is then a*pego.Nodeornil, whichv, ok := ....(*pego.Node)tells apart.
Variations
- Tabs. This grammar counts only spaces: a tab at the start of a line is a syntax error. To allow tabs, make
spacesmatch them and decide whatlenshould count, or reject them with a message of their own. - Values with structure. Make
valuea rule of its own (numbers, quoted strings, lists), as in Read a configuration file into Go structs. - Lists and other forms.
- itemlines are one more alternative ofentryat the same indentation. - Cost. A rule that reads a variable is cached per value of the variable, so keep the number of values small (a depth, not a position); see the guide below.
- Python and YAML. Their grammars, parsers/python and parsers/yaml, use the same technique at a larger scale.
See Context-sensitive parsing for predicates, variables and lookahead, and examples/outline for the grammar this one is based on.