Ruff Python formatting in Vim without plugins

The simplest way to integrate Ruff’s Python code formatter into Vim, no plugins or language servers needed. Supports both formatting code and sorting imports, and formatting either the entire file or just part of it. The same technique can be used to integrate any command line formatter for any file type.

There are lots of ways to add Ruff to Vim: Ruff’s own docs recommend using the vim-lsp plugin (which adds Language Server Protocol support to Vim), coc-pyright, or efm-langserver. There’s a standalone plugin just for Ruff called vim-ruff. Ruff can be added to the ALE plugin as a “fixer”. Etc.

But if all you want from Ruff is code formatting (not linting) there’s a simpler way to get that without any plugins or language servers - just add this to your vimrc file:

" ~/.vimrc
augroup vimrc_equalprgs
  autocmd!
  autocmd FileType python setlocal equalprg=ruff\ format\ -
augroup END

You can now use Vim’s = command (see :help =) to format Python code with Ruff. For example:

How this works:

  1. autocmd FileType python … configures a command to be executed whenever entering a Python file, so we can enable Ruff formatting in Python files only, without affecting what = does in other types of file.

  2. Wrapping the autocmd in an augroup (and with autocmd! as the group’s first line) is a common vimrc pattern that prevents the autocmd from being registered multiple times if you source your vimrc multiple times in a session: it causes the command to be cleared and re-created each time instead.

  3. setlocal sets an option only for the current buffer, without affecting any other (possibly non-Python) files Vim might have open.

  4. Vim’s equalprg option specifices an external program for the = command to use for formatting.

  5. equalprg programs must accept the text to be formatted on stdin and return the formatted text on stdout. ruff format - is a ruff command that does exactly that.

Reformatting the whole file

Surprisingly, Vim doesn’t have a simple command to reformat an entire file. You have to do gg to move the cursor to the start of the file, then = for reformat, then for the motion argument: G to move to the end of the file. You’re moving the cursor to the start of the file then asking Vim to reformat from the cursor to the end of the file, hence reformatting the entire file. So the full command to reformat the whole file is gg=G.

Your cursor will end up at the bottom of the file: you’ll have to do Ctrl + o twice to use Vim’s jump list to return to your original cursor position. Alternatively you can place a mark named f by prepending mf to the command, then restore the cursor to position f by appending `f to the end of the command: mfgg=G`f.

That’s a pretty long command! The following vimrc snippet adds a :Format command (that you can type at Vim’s command line) to reformat the entire file, along with a :Fmt alias and a keyboard shortcut <leader>f (\f by default). The :Format command also uses keepjumps so that reformatting doesn’t modify your jumplist:

" ~/.vimrc
command! Format keepjumps normal! mfgg=G`f
command! Fmt Format
nnoremap <leader>f :Format<CR>

Sorting imports

ruff format doesn’t sort imports. To sort imports you have to use ruff check. Specifically, ruff check --select I001 --fix-only - is the full command to have Ruff sort any imports in the Python code (provided on stdin) without doing any other linter checks or complaining about any violations.

If we want to both format code and sort imports in Vim we need an equalprg that calls both ruff format and ruff check. Create a ~/.vim/equalprgs/python.py file with these contents:

#!/usr/bin/env python3
import sys
import subprocess

input_code = sys.stdin.read()

# Format the code with Ruff.
ruff_format = subprocess.run(
    ['ruff', 'format', '-'],
    input=input_code,
    capture_output=True,
    text=True,
    check=True,
)

# Sort any imports in the code.
ruff_check = subprocess.run(
    ['ruff', 'check', '--select', 'I001', '--fix-only', '-'],
    input=ruff_format.stdout,
    capture_output=True,
    text=True,
    check=True,
)

# Send the formatted and sorted code back to Vim.
print(ruff_check.stdout, end='')

Change the equalprg setting in your vimrc to call the new Python script:

# ~/.vimrc
augroup vimrc_equalprgs
  autocmd!
  autocmd FileType python setlocal equalprg=~/.vim/equalprgs/python.py
augroup END

Now formatting code in Vim will also sort the imports! You can even select some imports and hit = to just sort those imports without affecting the rest of the file.

Reformatting indented sections of code

There’s a problem with using the = command to format just part of a file: a subsection of a file may no longer be valid Python code when taken out of the context of the rest of the file.

For example consider this function:

def sum(a, b):
    result = a + b
    print("Returning result: %i" % result)
    return result

If you were to just select the body of this function (everything after the first line) and try to format it with = you’d get an error from Ruff. The reason is that you asked Ruff to format this code:

    result = a + b
    print("Returning result: %i" % result)
    return result

Lines of code indented for no reason aren’t valid Python.

We can fix this by enhancing our equalprg script to remove any common indentation before passing the code to Ruff:

#!/usr/bin/env python3
import sys
import subprocess
import textwrap

input_code = sys.stdin.read()

if not input_code:
    sys.exit()

# Remove any common indentation from the code.
dedented_code = textwrap.dedent(input_code)

# Format the code with Ruff.
ruff_format = subprocess.run(
    ['ruff', 'format', '-'],
    input=dedented_code,
    capture_output=True,
    text=True,
    check=True,
)

# Sort any imports in the code.
ruff_check = subprocess.run(
    ['ruff', 'check', '--select', 'I001', '--fix-only', '-'],
    input=ruff_format.stdout,
    capture_output=True,
    text=True,
    check=True,
)

# Figure out what common indentation (if any) was removed and put it back.
removed_indent = ""

for input_line, dedented_line in zip(input_code.splitlines(), dedented_code.splitlines()):
    if input_line and not input_line.isspace():
        removed_indent = input_line[:len(input_line) - len(dedented_line)]
        break

reindented_code = textwrap.indent(ruff_check.stdout, removed_indent)

# Send the formatted, sorted, and re-indented code back to Vim.
print(reindented_code, end='')

Now you can use = to format just the body of a function, class, etc, or just a single line or set of lines within a block.

Of course, there’s lots of other ways for a subset of a Python file to not be valid on its own, besides just indentation. If you try to format just the signature of a method, or the opening line of a loop, etc, you’ll get an error from Ruff. At least some of these cases could be fixed by further enhancing our script, but it’s probably not worth the trouble. black-machiatto is a script that attempts to do this for the Black code formatter.

equalprg versus formatprg

Vim actually has two commands for formatting text: as well as the = command that we’ve been using (customizable via equalprg) there’s also a gq command (customizable via formatprg). Vim’s docs don’t make it super clear why both commands exist or what each should be used for, and many people use gq and formatprg rather than = and equalprg to call external formatters like Ruff.

I think = and equalprg are more appropriate for calling external code formatters.

= sort of formats code by default: if no equalprg is set the default, internal behavior tries to fix the indentation of Python code (and seems to understand a little of Python syntax to do so). So = seems like the appropriate command to customize with smarter code formatting from Ruff.

gq, on the other hand, blindly hard-wraps text to textwidth by default, if no formatprg is set. The default behavior of gq isn’t appropriate for Python code: it’ll break your code by blindly joining lines together. It’s meant for hard-wrapping plain or prose text.

gq‘s default behavior can be useful when editing Python files: it’s perfect for hard-wrapping comments and multi-line strings (including docstrings) which code formatters like Ruff and Black usually don’t touch: select a comment or string and hit gq to re-wrap it (or use gqip to rewrap the current paragraph, gqq to wrap the current line, 2gqq to wrap the current line and the one below it, etc). I don’t want to lose this useful functionality by hijacking gq to call Ruff.

So customizing = to call Ruff and keeping gq‘s default behavior for when you want to re-wrap a comment or docstring seems right to me.

Incidentally, if you want higher-quality hard-wrapping from gq you can set formatprg to call out to par, a standalone text-wrapping program. There’s an old episode of Vimcasts about it.

Conclusion

I said this was going to be the simplest way to add Ruff formatting to Vim and I’ve ended up writing a 40-line Python script. So it goes. But for that we’re able to format not just entire files but also subsets of code within files, which most code formatting plugins can’t do. We’ve got import sorting as well as formatting. Our equalprg-based approach can be used to integrate any command line formatting programs for any file types we want in the same way, without having to search for plugins. And we also have gq for hard-wrapping comments and docstrings.