Scheme Code formatting in Vim
2023-09-17 @rrobin
This is how I format scheme code in Vim. Admittedly my setup is only useful if you follow the GUIX style, but maybe the notes on Vim are useful too.
A quick intro to code formatting in Vim
Vim provides a few commands to format your code. I think the most common methods are to rely on (gq) and =. In some cases people use LSP provided formatters, but I don't cover it here.
={motion}
'=' filters lines through an external program (defined in 'equalprg'). The purpose of the command is to indent lines.
If 'equalprg' is not defined, then Vim will fallback to an internal implementation. This fallback will depend on other options, in particular if 'lisp', 'indentexpr' or 'cindent'.
If 'lisp' is set, the default in scheme, then 'indentexpr' will be completely ignored. This can be changed by changing 'lispoptions'.
gq[motion]
gq - formats lines that a motion moves over. This uses either an external program, custom expression, or an internal implementation.
'formatprg' is an option that holds the path of a program, used to format code with the (gq) operator. The program takes input on stdin and returns output on stdout.
'formatexpr' holds a vim expression used to format code (usually a function call) with the (gq) operator. This option takes precedence over 'formatprg'. Input is defined by the following variables
- v:lnum holds the first line to be formatted
- v:count holds the number of lines
Formatting scheme in GUIX
My main target is to format code according to the Guix rules. Guix provides a tool for this called guix style, that rewrites a file in place:
guix style --whole-file filename.scm
This clashes a bit with the way that Vim formats code, since it expects to process the entire file, while Vim can try to format a range of lines, for a buffer without a file.
Here is an example function for a formatexpr. Note that you should only set this in a buffer option (e.g. via autocommands for scheme files):
function! s:guixStyle()
let tmpfile = tempname()
" Unlike the usual formatexpr we ignore the actual motion range
" because guix style needs a full file to be formatted
"let startl = v:lnum
"let endl = v:lnum + v:count - 1
let startl = 1 " deletebufline does not accept 0 here
let endl = "$"
let oldlines = getline(startl, endl)
if writefile(oldlines, tmpfile, "s") <> 0
echohl ErrorMsg
echom "Failed to setup tmp file for guix style"
echohl None
return 0
endif
let out = systemlist(['guix', 'style', '--whole-file', tmpfile], '')
if v:shell_error
echohl ErrorMsg
echom "Failed to run guix style:"
for line in out
echom " "..line
endfor
echom "Could not format scheme"
echohl None
else
let newlines = readfile(tmpfile)
call deletebufline("", startl, endl)
call setline(startl, newlines)
endif
return 0
endfunction
setlocal formatexpr=s:guixStyle()
The code is relatively straightforward, it copies buffer contents into a temporary file and calls guix style, replacing buffer lines on success. There are a few caveats though:
- it always formats the entire file, not the motion text
- it will not format code with unbalanced parens, leaving it as is
- guix style clobbers guile scripts called via other hash lines
- in this particular code, the systemlist call might be neovim specific and require fixing for Vim proper
That still leaves us with '='. What happens when we try to indent scheme code in Vim? According to the documentation if options 'lisp' and 'autoindent' are set then:
When <Enter> is typed in insert mode set the indent for the next line to Lisp standards (well, sort of).
so it is likely that 'equalprg' is not used (since 'lisp' is on). This means that most other options are ignored.
In particular the default settings for lisp indentation seem to always indent code to align with the operator (based on 'lispwords'), rather than a fixed number of spaces (2 in guix style).
This is not what I want, so I've adjusted this with a custom 'indentexpr'.
function! s:schemeIndent(line)
if a:line <= 1
return 0
endif
let prevline = a:line-1
let previndent = indent(prevline)
let openp = count(getline(prevline), "(")
let closep = count(getline(prevline), ")")
if openp == closep
return previndent
elseif openp > closep
return previndent + shiftwidth()
else
return previndent - shiftwidth()
endif
endfunction
setlocal indentexpr=s:schemeIndent(v:lnum)
setlocal expandtab
setlocal shiftwidth=2
setlocal tabstop=2
setlocal softtabstop=2
setlocal lispoptions=expr:1
However this is a pretty naive implementation that works by counting parens and relying on the indentation of the previous line.