Folding elixir blocks in vim

Folding elixir blocks in vim
  • Created: 11/08/2018
  • Last updated: 11/08/2018

I am currently working on a few restful APIs written in elixir, on the Phoenix framework. We use swagger documentation to describe the API endpoints as it is a widely used, open standard, which is used by many open source / free tools.

And we use phoenix_swagger to generate our swagger docs. This package provides a nice DSL which you can use in your controllers to generate your swagger.json. On top of this, phoenix_swagger provides helpers to integrate your swagger schema definitions with you controller tests. I think this is a great feature, it makes it easy to ensure that your docs are accurate.

Using phoenix_swagger your docs are written inline with your controller actions like this:

defmodule TypingWeb.PhraseController do
  use TypingWeb, :controller
  use PhoenixSwagger

  alias Typing.Games

  action_fallback(TypingWeb.FallbackController)

  def swagger_definitions do
    %{
      Phrase:
        swagger_schema do
          title("Phrase")
          description("A phrase")

          properties do
            id(:string, "Phrase uuid")
            body(:string, "Phrase body text")
            citation(:string, "Phrase citation")
            duration(:integer, "Time allowed to type phrase body")
            name(:string, "Phrase name")
          end
        end,
      PhraseResponse:
        swagger_schema do
          title("Phrase response")
          description("Response schema for single phrase")
          property(:data, Schema.ref(:Phrase), "Phrase")
        end,
      PhrasesResponse:
        swagger_schema do
          title("Phrases response")
          description("Response schema for many phrases")
          property(:data, Schema.array(:Phrase), "Phrases")
        end
    }
  end

  swagger_path :index do
    get("/api/v1/phrases")
    summary("List phrases")
    produces("application/json")
    response(200, "OK", Schema.ref(:PhrasesResponse))
  end

  def index(conn, _params) do
    phrases = Games.list_phrases()
    render(conn, "index.json", phrases: phrases)
  end

  swagger_path :show do
    get("/api/v1/phrases/{phrase_id}")
    summary("List phrases")
    produces("application/json")
    parameter(:id, :path, :string, "The phrase uuid")
    response(200, "OK", Schema.ref(:PhraseResponse))
  end

  def show(conn, %{"id" => id}) do
    phrase = Games.get_phrase!(id)
    render(conn, "show.json", phrase: phrase)
  end
end

That was quite a long snippet. Out of 63 lines in this file, 42 are there to generate documentation…

The good and bad side of this is that our otherwise slim controllers are now full of documentation. The docs have to be stored somewhere, and inline with controller functions makes good sense to me, but this does make the files rather long, and the actual controller code is much harder to read.

Nemanja, who I am working with currently, suggested that ‘It would be great to be able to collapse docs in vim’. This would solve my minor complaint nicely. Readability would be restored, and documentation would remain inline where it can easily be unfolded and maintained.

A quick search brought up this vim cast, which shows that this kind of thing is possible, so I will have a go at making this dream a reality…

Begin!

My finished code, complete with docs, can be found in my vim configuration repo here: https://gitlab.com/nathamanath/vim_config/blob/eaaa36f9/after/ftplugin/elixir/folding.vim

This folding behaviour is to be applied to elixir files only, so the code goes in the ~/.vim/after/ftplugin/elixir directory.

In vim, an ftplugin is a ‘file type plugin’. A file type plugin is automatically loaded when a file of the matching file type is being edited.

For this to work you will need to have ftplugins enabled, which you can do by adding filetype plugin on to your .vimrc.

The first step is some configuration:

  setlocal foldmethod=expr
  setlocal foldexpr=elixir#folding#elixir_folds(v:lnum)

Options set with setlocal will apply only to files of the file type associated with the ftplugin.

Each line of the file is passed to the foldexpr function in turn, and it returns a string describing the fold level of the current line.

Now to define elixir#folding#elixir_folds/1… This flow diagram shows the logic required:

elixir#folding#elixir_folds/1 logic
  • Fold level starts at 0
  • Each line will be passed to elixir#foldling#elixir_folds/1 in turn
  • If a line initiates a swagger block increase the fold level
  • If a line closes a swagger block, then decrease the fold level
  • Otherwise, use the fold level of the previous line

Now looking at this in more detail:

First off, a helper function to classify a line as a swagger block initialisation or not:

  function! s:is_swagger_block_open(line)
    return match(a:line, '^\s*\(def\s\)\{\}swagger_.*\sdo$') >= 0
  endfunction

And with this, the first section of elixir_folds can be written…

  let s:open_blocks = 0

  function! elixir#folding#elixir_folds(lnum)
    let current_line = getline(a:lnum)

    if s:is_swagger_block_open(current_line)
      let s:open_blocks += 1
      return "a1"

      ...

a1 is returned to dynamically increase the fold level by 1. Its a bit cryptic, but its nice that vim has a built in means of dealing with this.

Obviously, you can nest blocks within your swagger blocks, meaning that an end isn’t always the end of a swagger block, To deal with this, I depend on the Elixir source code being indented properly according to mix format

The next flow diagram describes the process of finding the end of a swagger block, so that fold level can be reduced properly:

Scanning for matching swagger block opening
  • First, test if there are open swagger blocks
  • And if line is the end of any block
  • Get indentation level of current line
  • Check previous lines in turn to find an open swagger block with same indentation
  • If one is found, decrease fold level by 1
  • Otherwise, maintain fold level

For this, a few more helpers are required…

Firstly a function to classify a line as the end of a block:

  function! s:is_swagger_block_close(line)
    return match(a:line, '^\s*end\(,\)\{\}$') >= 0
  endfunction

One to get the indentation level of a line by its line number:

  function! s:indent_level(lnum)
    return indent(a:lnum) / &shiftwidth
  endfunction

And another to work back up the file and find a swagger block initialisation with indentation to match an end block:

  function! s:block_open_lnum(lnum, indent)
    let current_lnum = a:lnum - 1

    " work upwards until a line matches
    while current_lnum > 0
      let current_indent = s:indent_level(current_lnum)
      let current_line = getline(current_lnum)

      if s:is_swagger_block_open(current_line) && a:indent == current_indent
        return current_lnum
      end

      let current_lnum -=1
    endwhile

    " Otherwise go to line 1
    return 1
  endfunction

With these helper functions in place, I can continue working on the elixir#folding#elixir_folds/1 function…

    ...

    elseif s:open_blocks > 0 && s:is_swagger_block_close(current_line)
      let indent = s:indent_level(a:lnum)
      let open_lnum = s:block_open_lnum(a:lnum, indent)
      let open_indent_level = s:indent_level(open_lnum)

      if indent == open_indent_level
        let s:open_blocks += 1
        return "s1"
      else
        return "="
      end
    endif

    ...

s1 is returned to reduce fold level by 1, or = to maintain current fold level.

The remaining case to handle, is when a line neither opens nor closes a swagger block. For these lines, return = to maintain the current fold level.

    ...

    return "="
  endfunction

Trying it out

Now, when you open an elixir file containing swagger documentation, these blocks will be folded automatically.

Some useful key combinations for working with folds:

  • zi toggle folding on / off
  • zM will close all folds
  • zR will open all folds
  • za toggles a fold open / closed

With that, readability is restored, and documentation is inline. Pleasurable.

Conclusion

The nice thing about this, and the reason that I thought it was worth the write up, is that this can easily be applied to any chunks of code in any language. I hope that someone else finds this to be as useful as I have :)

This shows just how impressively customisable the vim editor can be, and I am sure that this is only the beginning of it. Along with a few snippets, this has made working with swagger docs in phoenix controllers much easier.

Vim script is odd, and many online resources (not listed below) are confusing to say the very least. Once I learned what to look for, the built in docs were way more useful than any of the stack overflow answers I initially tried to base this work on.

I have done very little in the way of vim script prior to this, so this is almost all new to me. It has been interesting learning how to make a more complex customisation to vim. I will be doing more with this.

References

tldr: Writing a custom folding expression for vim to make life easier managing phoenix_swagger documentation in Phoenix controllers.