Parsing a TeX-like language with lpeg

559 Views Asked by At

I am struggling to get my head around LPEG. I have managed to produce one grammar which does what I want, but I have been beating my head against this one and not getting far. The idea is to parse a document which is a simplified form of TeX. I want to split a document into:

  • Environments, which are \begin{cmd} and \end{cmd} pairs.
  • Commands which can either take an argument like so: \foo{bar} or can be bare: \foo.
  • Both environments and commands can have parameters like so: \command[color=green,background=blue]{content}.
  • Other stuff.

I also would like to keep track of line number information for error handling purposes. Here's what I have so far:

lpeg = require("lpeg")
lpeg.locale(lpeg)
-- Assume a lot of "X = lpeg.X" here.

-- Line number handling from http://lua-users.org/lists/lua-l/2011-05/msg00607.html
-- with additional print statements to check they are working.
local newline = P"\r"^-1 * "\n" / function (a) print("New"); end
local incrementline = Cg( Cb"linenum" )/ function ( a ) print("NL");  return a + 1 end , "linenum"
local setup = Cg ( Cc ( 1) , "linenum" )
nl = newline * incrementline
space = nl + lpeg.space

-- Taken from "Name-value lists" in http://www.inf.puc-rio.br/~roberto/lpeg/
local identifier = (R("AZ") + R("az") + P("_") + R("09"))^1
local sep = lpeg.S(",;") * space^0
local value = (1-lpeg.S(",;]"))^1
local pair = lpeg.Cg(C(identifier) * space ^0 * "=" * space ^0 * C(value)) * sep^-1
local list = lpeg.Cf(lpeg.Ct("") * pair^0, rawset)
local parameters = (P("[") * list * P("]")) ^-1

-- And the rest is mine

anything = C( (space^1 + (1-lpeg.S("\\{}")) )^1) * Cb("linenum") / function (a,b) return { text = a, line = b } end

begin_environment = P("\\begin") * Ct(parameters) * P("{") * Cg(identifier, "environment") * Cb("environment") * P("}") / function (a,b) return { params = a[1], environment = b } end
end_environment = P("\\end{") * Cg(identifier) * P("}") 

texlike = lpeg.P{
  "document";
  document = setup * V("stuff") * -1,
  stuff = Cg(V"environment" + anything + V"bracketed_stuff" + V"command_with" + V"command_without")^0,
  bracketed_stuff = P"{" * V"stuff" * P"}" / function (a) return a end,
  command_with =((P("\\") * Cg(identifier) * Ct(parameters) * Ct(V"bracketed_stuff"))-P("\\end{")) / function (i,p,n) return { command = i, parameters = p, nodes = n } end,
  command_without = (( P("\\") * Cg(identifier) * Ct(parameters) )-P("\\end{")) / function (i,p) return { command = i, parameters = p } end,
  environment = Cg(begin_environment * Ct(V("stuff")) * end_environment) / function (b,stuff, e) return { b = b, stuff = stuff, e = e} end
}

It almost works!

> texlike:match("\\foo[one=two]thing\\bar")
{
  command = "foo",
  parameters = {
    {
      one = "two",
    },
  },
}
{
  line = 1,
  text = "thing",
}
{
  command = "bar",
  parameters = {
  },
}

But! First, I can't get the line number handling part to work at all. The function within incrementline is never fired.

I also can't quite work out how nested capture information is passed to handling functions (which is why I have scattered Cg, C and Ct semirandomly over the grammar). This means that only one item is returned from within a command_with:

> texlike:match("\\foo{text \\command moretext}")
{
  command = "foo",
  nodes = {
    {
      line = 1,
      text = "text ",
    },
  },
  parameters = {
  },
}

I would also love to be able to check that the environment start and ends match up but when I tried to do so, my back references from "begin" were not in scope by the time I got to "end". I don't know where to go from here.

1

There are 1 best solutions below

0
On

Late answer but hopefully it'll offer some insight if you're still looking for a solution or wondering what the problem was.

There are a couple of issues with your grammar, some of which can be tricky to spot.

Your line increment here looks incorrect:

local incrementline = Cg( Cb"linenum" ) / 
                      function ( a ) print("NL");  return a + 1 end, 
                      "linenum"

It looks like you meant to create a named capture group and not an anonymous group. The backcapture linenum is essentially being used like a variable. The problem is because this is inside an anonymous capture, linenum will not update properly -- function(a) will always receive 1 when called. You need to move the closing ) to the end so "linenum" is included:

local incrementline = Cg( Cb"linenum" / 
                      function ( a ) print("NL");  return a + 1 end, 
                      "linenum")

Relevant LPeg documentation for Cg capture.

The second problem is with your anything non-terminal rule:

anything = C( (space^1 + (1-lpeg.S("\\{}")) )^1) * Cb("linenum") ...

There are several things to be careful here. First, a named Cg capture (from incrementline rule once it's fixed) doesn't produce anything unless it's in a table or you backref it. The second major thing is that it has an adhoc scope like a variable. More precisely, its scope ends once you close it in an outer capture -- like what you're doing here:

C( (space^1 + (...) )^1)

Which means by the time you reference its backcapture with * Cb("linenum"), that's already too late -- the linenum you really want already closed its scope.

I always found LPeg's re syntax a bit easier to grok so I've rewritten the grammar with that instead:

local grammar_cb =
{
  fold = pairfold, 
  resetlinenum = resetlinenum,
  incrementlinenum = incrementlinenum, getlinenum = getlinenum, 
  error = error
}

local texlike_grammar = re.compile(
[[
  document    <- '' -> resetlinenum {| docpiece* |} !.
  docpiece    <- {| envcmd |} / {| cmd |} / multiline
  beginslash  <- cmdslash 'begin'
  endslash    <- cmdslash 'end'
  envcmd      <- beginslash paramblock? {:beginenv: envblock :} (!endslash docpiece)*
                 endslash openbrace {:endenv: =beginenv :} closebrace / &beginslash {} -> error .
  envblock    <- openbrace key closebrace
  cmd         <- cmdslash {:command: identifier :} (paramblock? cmdblock)?
  cmdblock    <- openbrace {:nodes: {| docpiece* |} :} closebrace
  paramblock  <- opensq ( {:parameters: {| parampairs |} -> fold :} / whitesp) closesq
  parampairs  <- parampair (sep parampair)*
  parampair   <- key assign value
  key         <- whitesp { identifier }
  value       <- whitesp { [^],;%s]+ }
  multiline   <- (nl? text)+
  text        <- {| {:text: (!cmd !closebrace !%nl [_%w%p%s])+ :} {:line: '' -> getlinenum :} |}
  identifier  <- [_%w]+
  cmdslash    <- whitesp '\'
  assign      <- whitesp '='
  sep         <- whitesp ','
  openbrace   <- whitesp '{'
  closebrace  <- whitesp '}'
  opensq      <- whitesp '['
  closesq     <- whitesp ']'
  nl          <- {%nl+} -> incrementlinenum
  whitesp     <- (nl / %s)*
]], grammar_cb)

The callback functions are straight-forwardly defined as:

local function pairfold(...)
  local t, kv = {}, ...
  if #kv % 2 == 1 then return ... end
  for i = #kv, 2, -2 do
    t[ kv[i - 1] ] = kv[i]
  end
  return t
end

local incrementlinenum, getlinenum, resetlinenum do
  local line = 1
  function incrementlinenum(nl)
    assert(not nl:match "%S")
    line = line + #nl
  end

  function getlinenum() return line end
  function resetlinenum() line = 1 end
end

Testing the grammar with a non-trivial tex-like str with multiple lines:

  local test1 = [[\foo{text \bar[color = red, background =   black]{
  moretext \baz{
even 
more text} }


this time skipping multiple

lines even, such wow!}]]

Produces the follow AST in lua-table format:

{
  command = "foo",
  nodes = {
    {
      text = "text",
      line = 1
    },
    {
      parameters = {
        color = "red",
        background = "black"
      },
      command = "bar",
      nodes = {
        {
          text = "  moretext",
          line = 2
        },
        {
          command = "baz",
          nodes = {
            {
              text = "even ",
              line = 3
            },
            {
              text = "more text",
              line = 4
            }
          }
        }
      }
    },
    {
      text = "this time skipping multiple",
      line = 7
    },
    {
      text = "lines even, such wow!",
      line = 9
    }
  }
}

And a second test for begin/end environments:

  local test2 = [[\begin[p1
=apple,
p2=blue]{scope} scope foobar   
\end{scope} global foobar]]

Which seems to give approximately what you're looking for:

{
  {
    {
      text = " scope foobar",
      line = 3
    },
    parameters = {
      p1 = "apple",
      p2 = "blue"
    },
    beginenv = "scope",
    endenv = "scope"
  },
  {
    text = " global foobar",
    line = 4
  }
}