milmazz

milmazz

How to parse an ATX Heading (MarkDown) with NimbleParsec?

Just as an exercise and learn more about NimbleParsec, I started trying to parse a few sections of the CommonMark specification with NimbleParsec v0.5, some sections are easier (e.g. thematic breaks) than others, but I still have some issues trying to complete the “ATX Heading” section, according to the spec:

An ATX heading consists of a string of characters, parsed as inline content, between an opening sequence of 1–6 unescaped # characters and an optional closing sequence of any number of unescaped # characters. The opening sequence of # characters must be followed by a space or by the end of line. The optional closing sequence of #s must be preceded by a space and may be followed by spaces only. The opening # character may be indented 0-3 spaces. The raw contents of the heading are stripped of leading and trailing spaces before being parsed as inline content. The heading level is equal to the number of # characters in the opening sequence.

What I have so far is this:

defmodule ATXHeading do
  import NimbleParsec

  @space 0x0020
  @tab 0x009

  spacechar = utf8_char([@space, @tab])
  sp = optional(times(spacechar, min: 1))

  non_indented_space =
    [@space]
    |> utf8_char()
    |> times(max: 3)

  atx_start =
    non_indented_space
    |> optional()
    |> ignore()
    |> ascii_char([?#])
    |> times(min: 1, max: 6)
    |> reduce(:length)
    |> unwrap_and_tag(:level)

  atx_end =
    [@space]
    |> utf8_char()
    |> times(ascii_char([?#]), min: 1)
    |> concat(sp)
    |> choice([
      ascii_char([?\n]),
      eos()
    ])

  heading =
    atx_start
    |> choice([
      [?\n] |> ascii_char() |> ignore(),
      [@space]
      |> utf8_char()
      |> ignore()
      # NOTE: `lookahead_not` seems to work with string(" #"), but it
      # should also accept `atx_end`, right?
      |> repeat([not: ?\n] |> utf8_char() |> lookahead_not(string(" #")))
      # Take the next character in case lookahead(combinator) is found and ignore `atx_end`
      |> optional([not: ?\n, not: ?\s] |> utf8_char() |> ignore(atx_end))
      |> optional(ascii_char([?\n]))
      |> reduce({List, :to_string, []})
      # TODO: After trim, the result should be parsed as inline
      # content
      |> reduce(:trim)
    ])
    |> tag(:heading)

  defp trim([string]), do: String.trim(string)

  @doc """
  Parses ATX Headings.

  ## Examples

      iex> ~S(### foo) |> ATXHeading.heading() |> elem(1)
      [heading: [{:level, 3}, "foo"]]
      iex> ~S(###      foo     ) |> ATXHeading.heading() |> elem(1)
      [heading: [{:level, 3}, "foo"]]
      iex> ~S(###      foo    #### ) |> ATXHeading.heading() |> elem(1)
      [heading: [{:level, 3}, "foo"]]
      iex> ~S(### foo \\###) |> ATXHeading.heading() |> elem(1)
      [heading: [{:level, 3}, "foo ###"]]
      iex> ~S(### foo #\\##) |> ATXHeading.heading() |> elem(1)
      [heading: [{:level, 3}, "foo ###"]]
      iex> ~S(# foo \\#) |> ATXHeading.heading() |> elem(1)
      [heading: [{:level, 1}, "foo #"]]
      iex> ~S(## foo ### b) |> ATXHeading.heading() |> elem(1)
      [heading: [{:level, 2}, "foo ### b"]]

  """

  # defparsec(:heading, heading)
  # NOTE: This is just to see if `atx_end` is parsed correctly
  defparsec(:heading, choice([heading, atx_end]))
end

The problem with the current implementation is that I haven’t found a way to use the combinator atx_end with lookahead_not (but atx_end works with ignore and also is parsed correctly if I run something like ATXHeading.heading(" ######### \n"). In the meantime I used lookahead_not(string(" #")) and it works but does not cover all the cases of the specification. So, at the moment seems that I can’t comply with this part of the spec:

The optional closing sequence of #s must be preceded by a space and may be followed by spaces only

Is this behavior a bug on lookahead_not or am I missing something here? Do you have any recommendation on how to parse an ATX Heading with NimbleParsec?

Most Liked

tmbb

tmbb

It looks like what you want to do is made more complex because of the fact that the sequence of # in the beginning must match the sequence of # at the end. That requires context-sensitive features, which are not possible on nimble_parsec as it currently works.

However, you can “cheat” your way out of context-sensitivity by relying on the fact that headers can’t go more than 6 levels deep. That way, you only need to handle 6 possibilities and it can be made to work with nimble_parsec.

For an example (which doesn’t handle markup inside the header), look at the following code:

defmodule CommonMark do
  import NimbleParsec

  ignored_whitespace =
    ascii_string([?\s], min: 1)
    |> ignore()
    |> optional()

  header_char = utf8_char(not: ?\n)

  headers =
    for n <- 6..1 do
      prefix = String.duplicate("#", n)
      suffix = " " <> prefix

      content =
        lookahead_not(string(suffix))
        |> concat(header_char)
        |> times(min: 1)
        |> reduce({List, :to_string, []})

      ignore(optional(ascii_string([?\s], max: 3)))
      |> string(prefix)
      |> concat(ignored_whitespace)
      |> concat(content)
      |> optional(ignore(string(suffix)))
      |> post_traverse(:tag_with_level)
    end

  @doc false
  def tag_with_level(_rest, [content, prefix] = _args, context, _line, _offset) do
    result = [{:header, [level: byte_size(prefix)], content}]
    {result, context}
  end

  defparsec(
    :atx_header,
    choice(headers)
  )
end

Example output:

iex(50)> CommonMark.atx_header("## abc ")
{:ok, [{:header, [level: 2], "abc "}], "", %{}, {1, 0}, 7}
iex(51)> CommonMark.atx_header("## abc #")
{:ok, [{:header, [level: 2], "abc #"}], "", %{}, {1, 0}, 8}
iex(52)> CommonMark.atx_header("# abc #")
{:ok, [{:header, [level: 1], "abc"}], "", %{}, {1, 0}, 7}
iex(53)> CommonMark.atx_header("## abc #")
{:ok, [{:header, [level: 2], "abc #"}], "", %{}, {1, 0}, 8}
iex(54)> CommonMark.atx_header("# abc")
{:ok, [{:header, [level: 1], "abc"}], "", %{}, {1, 0}, 5}
iex(55)> CommonMark.atx_header("## abc")
{:ok, [{:header, [level: 2], "abc"}], "", %{}, {1, 0}, 6}
iex(56)> CommonMark.atx_header("### abc")
{:ok, [{:header, [level: 3], "abc"}], "", %{}, {1, 0}, 7}
iex(57)> CommonMark.atx_header("### abc ###")
{:ok, [{:header, [level: 3], "abc"}], "", %{}, {1, 0}, 11}
iex(58)> CommonMark.atx_header("### abc ##")
{:ok, [{:header, [level: 3], "abc ##"}], "", %{}, {1, 0}, 10}
iex(59)> CommonMark.atx_header("# abc ##")
{:ok, [{:header, [level: 1], "abc"}], "#", %{}, {1, 0}, 7}
iex(60)> CommonMark.atx_header("####### abc")
{:ok, [{:header, [level: 6], "# abc"}], "", %{}, {1, 0}, 11}

There are some warts, but you get the idea. You have to ensure that the ### is followed by a space, that a new line or an oef() comes afterwards and stuff like that. Regarding the handling of markup inside a header, it might be better to do that in another path.

But I hope this can help you move forward.

EDIT: There are similar tricks for other context-sensitive features of markdown, which you can emulate with a simpler context-free grammar. For example, code delimited by a variable number of backticks.

tmbb

tmbb

On re-reading the spec, I’ve just noticed that the closing sequence of hashes doesn’t need to be the same sequence as the opening sequence. Which means I haven’t undertood your problem at all. Sorry.

milmazz

milmazz

@tmbb First of all, thanks for your feedback.

The specification does not mention this, I mean, the sequence of # at the end does not need to match with the sequence of # at the beginning, for example:

# foo ######
## foo #####
### foo ####
#### foo ###
##### foo ##
###### foo #

Is a valid input and it should produce something like this (if you transform the result into HTML for example):

<h1>foo</h1>
<h2>foo</h2>
<h3>foo</h3>
<h4>foo</h4>
<h5>foo</h5>
<h6>foo</h6>

But, if you provide a string like this: "### foo ### b\n" it should produce <h3>foo ### b</h3>\n as the result. That’s why I need to be sure that the optional closing sequence of #s must be preceded by a space and may be followed by spaces only (until I find the end of the string or the end of the line).

Where Next?

Popular in Questions Top

New
gshaw
What is the idiomatic way of matching for not nil in Elixir? E.g., First way: defp halt_if_not_signed_in(conn, signed_in_account) when...
New
greenz1
I have a phoenix application from which a user can download multiple(5-6) files of size 1MB. I couldn’t find anything related to sending ...
New
JeremM34
Hello, how can I check the Phoenix version ? Thanks !
New
LegitStack
I’m trying to make a websocket server in Phoenix or raw Elixir. I heard about gun, I think I could use cowboy, but since I’m not that sma...
New
dokuzbir
I want to highlight html closing tags when i click a html tag. That works in .html files but doesnt work for html.eex templates. How can...
New
joeerl
Hello again - after a longish gap I’ve decided I really must dig into Elixir and see what’s been happening here - so I have a few questio...
New
Emily
I have VueJS GUIs with the project generated using Webpack. I have Elixir modules that will need to be used by the VueJS GUIs. I forese...
New
sergio_101
I am VERY much an elixir newbie. I have taken one elixir course and one phoenix course on Udemy. During that course, I saw the instructor...
New
WestKeys
Currently suffering from paralysis by [HTTP client] analysis. This is rather unusual in Elixirland as there tends to be consensus on the ...
New

Other popular topics Top

danschultzer
None of the current solutions worked well for me, so I went ahead and built a user management system from scratch. This project took far...
548 29377 241
New
sorentwo
Hello! tl;dr Announcing Oban, an Ecto based job processing library with a focus on reliability and historical observability. After spen...
985 42920 311
New
AngeloChecked
What learn first? Rust or Elixir Hi Elixir community! I’m here because i want learn a new language. I’m a junior developer and mainly i ...
New
jaysoifer
Is there a way to rollback a specific migration and only that one (“skipping” all the other ones)? Would mix ecto.rollback -v 200809061...
New
vonH
When I run the Plug and I recompile I wind up having to use Ctrl C to quit iex and start again. Witht the help of rlwrap I can use the cu...
New
boundedvariable
I am going through the kafka architecture. All the features what the kafka is providing are already in Erlang. I would like hear your opi...
New
nsuchy
Hi. I’ve noticed that Windows Powershell has it’s own IEX command and you cannot access Elixir’s IEX due to the conflict. This isn’t a cr...
New
hariharasudhan94
Lets say I have map like this fetching from my database %{"_id" =&gt; #BSON.ObjectId&lt;58eb1a7a9ad169198c3dXXXX&gt;, "email" =&gt; ...
New
jononomo
For some reason my phoenix channels are working for me in my local dev environment, but as soon as I deploy via Docker, I get a 403 error...
New
svb
Hi! Currently I want to submit a form by pressing the Enter key. However, since my input field is of type “textarea” this is just adds a...
New

We're in Beta

About us Mission Statement