defmodule Ergo.Combinators do alias Ergo.{Context, Parser} import Ergo.Utils, only: [ellipsize: 2] require Logger @moduledoc """ `Ergo.Combinators` is the key set of parsers used for combining together other parsers. # Parsers * choice * sequence * many * optional * ignore * transform * lookeahead * not_lookahead """ @doc ~S""" The `choice/1` parser takes a list of parsers. It tries each in order attempting to match one. Once a match has been made choice returns the result of the matching parser. ## Examples iex> alias Ergo.Context iex> import Ergo.{Terminals, Combinators} iex> parser = choice([literal("Foo"), literal("Bar"), literal("Hello"), literal("World")], label: "Foo|Bar|Hello|World") iex> Ergo.parse(parser, "Hello World") %Context{status: :ok, ast: "Hello", input: " World", char: ?o, index: 5, col: 6} iex> alias Ergo.Context iex> import Ergo.{Terminals, Combinators} iex> parser = choice([literal("Foo"), literal("Bar")]) iex> Ergo.parse(parser, "Hello World") %Context{status: {:error, :no_valid_choice}, message: "No valid choice", ast: nil, input: "Hello World"} """ def choice(parsers, opts \\ []) when is_list(parsers) do label = Keyword.get(opts, :label, "#") map_fn = Keyword.get(opts, :map, nil) Parser.new( fn %Context{debug: debug, input: input} = ctx -> if debug, do: Logger.info("Trying Choice<#{label}> on [#{ellipsize(input, 20)}]") with %Context{status: :ok} = new_ctx <- apply_parsers_in_turn(parsers, ctx) do if map_fn do %{new_ctx | ast: map_fn.(new_ctx.ast)} else new_ctx end end end, description: "Choice<#{label}>" ) end defp apply_parsers_in_turn(parsers, ctx) do Enum.reduce_while( parsers, %{ctx | status: {:error, :no_valid_choice}, message: "No valid choice", ast: nil}, fn parser, %Context{debug: debug} = ctx -> case Parser.call(parser, ctx) do %Context{status: :ok, ast: ast} = new_ctx -> if debug, do: Logger.info("<-- Choice: [#{ast}]") {:halt, %{new_ctx | message: nil}} _ -> {:cont, ctx} end end ) end @doc ~S""" ## Examples iex> alias Ergo.Context iex> import Ergo.{Terminals, Combinators} iex> parser = sequence([literal("Hello"), ws(), literal("World")]) iex> Ergo.parse(parser, "Hello World") %Context{status: :ok, ast: ["Hello", ?\s, "World"], char: ?d, index: 11, line: 1, col: 12} iex> Logger.disable(self()) iex> alias Ergo.Context iex> import Ergo.{Terminals, Combinators} iex> parser = sequence([literal("Hello"), ws(), literal("World")], label: "HelloWorld") iex> Ergo.parse(parser, "Hello World", debug: true) %Context{status: :ok, debug: true, ast: ["Hello", ?\s, "World"], char: ?d, index: 11, line: 1, col: 12} iex> alias Ergo.Context iex> import Ergo.{Terminals, Combinators} iex> parser = sequence([literal("Hello"), ws(), literal("World")], map: fn ast -> Enum.join(ast, " ") end) iex> Ergo.parse(parser, "Hello World") %Context{status: :ok, ast: "Hello 32 World", char: ?d, index: 11, line: 1, col: 12} iex> Logger.disable(self()) iex> alias Ergo.Context iex> import Ergo.{Terminals, Combinators} iex> parser = sequence([literal("Hello"), ws(), literal("World")], label: "HelloWorld", map: fn ast -> Enum.join(ast, " ") end) iex> Ergo.parse(parser, "Hello World", debug: true) %Context{status: :ok, debug: true, ast: "Hello 32 World", char: ?d, index: 11, line: 1, col: 12} iex> alias Ergo.Context iex> import Ergo.{Combinators, Terminals} iex> parser = sequence([literal("foo"), ws(), literal("bar")]) iex> assert %Context{status: {:error, :unexpected_char}} = Ergo.parse(parser, "Hello World") """ def sequence(parsers, opts \\ []) def sequence(parsers, opts) when is_list(parsers) do label = Keyword.get(opts, :label, "#") map_fn = Keyword.get(opts, :map, nil) Parser.new( fn %Context{debug: debug, input: input} = ctx -> if debug, do: Logger.info("Trying Sequence<#{label}> on [#{ellipsize(input, 20)}]") with %Context{status: :ok} = new_ctx <- sequence_reduce(parsers, ctx) do if debug, do: Logger.info("Sequence matched") # We reject nils from the AST since they represent ignored values new_ctx |> Context.ast_without_ignored() |> Context.ast_in_parsed_order() |> Context.ast_transform(map_fn) else err_ctx -> if debug, do: Logger.info("Sequence failed to match") err_ctx end end, description: "Sequence<#{label}>" ) end def sequence([], _opts) do raise "You must supply at least one parser to sequence/2" end defp sequence_reduce(parsers, %Context{} = ctx) when is_list(parsers) do Enum.reduce_while(parsers, %{ctx | ast: []}, fn parser, ctx -> case Parser.call(parser, ctx) do %Context{status: :ok, ast: ast} = new_ctx -> {:cont, %{new_ctx | ast: [ast | ctx.ast]}} err_ctx -> {:halt, err_ctx} end end) end @doc ~S""" ## Examples iex> Logger.disable(self()) iex> alias Ergo.Context iex> import Ergo.{Combinators, Terminals} iex> parser = many(wc(), label: "Chars") iex> Ergo.parse(parser, "Hello World", debug: true) %Context{status: :ok, debug: true, ast: [?H, ?e, ?l, ?l, ?o], input: " World", index: 5, col: 6, char: ?o} iex> alias Ergo.Context iex> import Ergo.{Combinators, Terminals} iex> parser = many(wc(), min: 6) iex> Ergo.parse(parser, "Hello World") %Context{status: {:error, :many_less_than_min}, ast: nil, input: " World", char: ?o, index: 5, col: 6} iex> alias Ergo.{Context, Parser} iex> import Ergo.{Combinators, Terminals} iex> parser = many(wc(), max: 3) iex> Ergo.parse(parser, "Hello World") %Context{status: :ok, ast: [?H, ?e, ?l], input: "lo World", char: ?l, index: 3, col: 4} iex> alias Ergo.{Context, Parser} iex> import Ergo.{Combinators, Terminals} iex> parser = many(wc(), map: &Enum.count/1) iex> Ergo.parse(parser, "Hello World") %Context{status: :ok, ast: 5, input: " World", index: 5, col: 6, char: ?o} """ def many(parser, opts \\ []) def many(%Parser{} = parser, opts) do label = Keyword.get(opts, :label, "#") min = Keyword.get(opts, :min, 0) max = Keyword.get(opts, :max, :infinity) map_fn = Keyword.get(opts, :map, nil) Parser.new( fn %Context{debug: debug, input: input} = ctx -> if debug, do: Logger.info("Trying Many<#{label}> on [#{ellipsize(input, 20)}]") with %Context{status: :ok} = new_ctx <- parse_many(parser, %{ctx | ast: []}, min, max, 0) do new_ctx |> Context.ast_without_ignored() |> Context.ast_in_parsed_order() |> Context.ast_transform(map_fn) end end, description: "Many<#{label}, #{min}..#{max}>" ) end def parse_many(%Parser{} = parser, %Context{} = ctx, min, max, count) when is_integer(min) and min >= 0 and ((is_integer(max) and max > min) or max == :infinity) and is_integer(count) do case Parser.call(parser, ctx) do %Context{status: {:error, _}} -> if count < min do %{ctx | status: {:error, :many_less_than_min}, ast: nil} else ctx end %Context{status: :ok} = new_ctx -> if max != :infinity && count == max - 1 do %{new_ctx | ast: [new_ctx.ast | ctx.ast]} else parse_many(parser, %{new_ctx | ast: [new_ctx.ast | ctx.ast]}, min, max, count + 1) end end end @doc ~S""" ## Examples iex> alias Ergo.Context iex> import Ergo.{Terminals, Combinators} iex> Ergo.parse(optional(literal("Hello")), "Hello World") %Context{status: :ok, ast: "Hello", input: " World", index: 5, col: 6, char: ?o} In this example we deliberately ensure that the Context ast is not nil iex> alias Ergo.{Context, Parser} iex> import Ergo.{Terminals, Combinators} iex> context = Context.new(" World") iex> context = %{context | ast: []} iex> parser = optional(literal("Hello")) iex> Parser.call(parser, context) %Context{status: :ok, ast: nil, input: " World", index: 0, col: 1, char: 0} """ def optional(%Parser{} = parser, opts \\ []) do label = Keyword.get(opts, :label, "#") map_fn = Keyword.get(opts, :map, nil) Parser.new( fn %Context{debug: debug, input: input} = ctx -> if debug, do: Logger.info("Trying Optional<#{label}> on [#{ellipsize(input, 20)}]") case Parser.call(parser, ctx) do %Context{status: :ok, ast: ast} = new_ctx -> if debug, do: Logger.info("<- Matched: [#{ast}]") if map_fn do mapped_ast = map_fn.(ast) if debug, do: Logger.info("<- Return [#{mapped_ast}]") %{new_ctx | ast: mapped_ast} else new_ctx end _ -> %{ctx | status: :ok} end end, description: "Optional<#{label}>" ) end @doc ~S""" The ignore/1 parser matches but ignores the AST of its child parser. ## Examples iex> alias Ergo.{Context, Parser} iex> import Ergo.{Terminals, Combinators} iex> parser = sequence([literal("Hello"), ignore(ws()), literal("World")]) iex> Ergo.parse(parser, "Hello World") %Context{status: :ok, ast: ["Hello", "World"], index: 11, col: 12, char: ?d} """ def ignore(%Parser{} = parser, opts \\ []) do label = Keyword.get(opts, :label, "#") Parser.new( fn %Context{debug: debug, input: input} = ctx -> if debug, do: Logger.info("Trying Ignore<#{label}> on [#{ellipsize(input, 20)}]") with %Context{status: :ok} = new_ctx <- Parser.call(parser, ctx) do %{new_ctx | ast: nil} end end, description: "Ignore<#{label}>" ) end @doc ~S""" The `transform/2` parser runs a transforming function on the AST of its child parser. ## Examples # Sum the digits iex> alias Ergo.{Context, Parser} iex> import Ergo.{Combinators, Terminals} iex> digit_to_int = fn d -> List.to_string([d]) |> String.to_integer() end iex> t_fn = fn ast -> ast |> Enum.map(digit_to_int) |> Enum.sum() end iex> context = Context.new("1234") iex> parser_1 = sequence([digit(), digit(), digit(), digit()]) iex> parser_2 = transform(parser_1, t_fn) iex> Parser.call(parser_2, context) %Context{status: :ok, ast: 10, char: ?4, index: 4, line: 1, col: 5} """ def transform(%Parser{} = parser, t_fn, opts \\ []) when is_function(t_fn) do label = Keyword.get(opts, :label, "#") Parser.new( fn %Context{debug: debug, ast: ast} = ctx -> if debug, do: Logger.info("Trying Transform<#{label}> on [#{ast}]") with %Context{status: :ok, ast: ast} = new_ctx <- Parser.call(parser, ctx), tranformed_ast <- t_fn.(ast) do if debug, do: Logger.info("<-- Transformed to: [#{tranformed_ast}]") %{new_ctx | ast: tranformed_ast} end end, description: "Transform<#{label}>" ) end @doc ~S""" The `lookahead` parser accepts a parser and matches it but does not update the context when it succeeds. ## Example iex> alias Ergo.Context iex> import Ergo.{Combinators, Terminals} iex> parser = lookahead(literal("Hello")) iex> Ergo.parse(parser, "Hello World") %Context{status: :ok, ast: nil, input: "Hello World", char: 0, index: 0, line: 1, col: 1} iex> alias Ergo.Context iex> import Ergo.{Combinators, Terminals} iex> parser = lookahead(literal("Helga")) iex> Ergo.parse(parser, "Hello World") %Context{status: {:error, :lookahead_fail}, ast: [?l, ?e, ?H], char: ?l, index: 3, col: 4, input: "lo World"} """ def lookahead(%Parser{} = parser, opts \\ []) do label = Keyword.get(opts, :label, "#") Parser.new( fn %Context{debug: debug, input: input} = ctx -> if debug, do: Logger.info("Trying Lookahead<#{label}> on [#{ellipsize(input, 20)}]") case Parser.call(parser, ctx) do %Context{status: :ok} -> %{ctx | ast: nil} bad_ctx -> %{bad_ctx | status: {:error, :lookahead_fail}, message: nil} end end, description: "Lookahead<#{label}>" ) end @doc ~S""" The `not_lookahead` parser accepts a parser and attempts to match it. If the match fails the not_lookahead parser returns status: :ok but does not affect the context otherwise. If the match succeeds the `not_lookahead` parser fails with {:error, :lookahead_fail} ## Examples iex> alias Ergo.Context iex> import Ergo.{Combinators, Terminals} iex> parser = not_lookahead(literal("Foo")) iex> Ergo.parse(parser, "Hello World") %Context{status: :ok, input: "Hello World"} iex> alias Ergo.{Context, Parser} iex> import Ergo.{Combinators, Terminals} iex> parser = not_lookahead(literal("Hello")) iex> Ergo.parse(parser, "Hello World") %Context{status: {:error, :lookahead_fail}, input: "Hello World"} """ def not_lookahead(%Parser{} = parser, opts \\ []) do label = Keyword.get(opts, :label, "#") Parser.new( fn %Context{debug: debug, input: input} = ctx -> if debug, do: Logger.info("Trying NotLookahead<#{label}> on [#{ellipsize(input, 20)}]") case Parser.call(parser, ctx) do %Context{status: {:error, _}} -> %{ctx | status: :ok} %Context{} -> %{ctx | status: {:error, :lookahead_fail}, message: nil} end end, description: "NotLookahead<#{label}>" ) end end