We can't find the internet
Something went wrong!
Folding elixir blocks in vim
- Created: 11/08/2018
- Last updated: 01/12/2024
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:
- 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:
- 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.