Custom default formatter rules in NeoVim with conform.nvim

Published: | Updated: | 6 min read

Learn how to setup global formatting rules in NeoVim. These rules can be utilized as your default, fallback rules whenever you are editing a file that is not in a project.

Automatic code formatting is a staple feature in all modern IDEs. All code gets automatically reformatted or prettified according to specific rules whenever you trigger the reformat (often on save). Commonly, these rules are held in project-specific config files to provide different formatting styles depending on the project.

Thanks to the plugin conform.nvim, NeoVim is also capable of interfacing with various formatters which will pick up the rules of your project and format your code that way. But what if you edit a file without a project attached?
Some major IDEs allow specifying global, default rules which are applied whenever you edit a standalone file. This is a very useful feature if you just want to edit a lone scripting file without a project and have some proper code formatting for it applied.

Unfortunately, conform.nvim does not offer any direct support for such custom default rules. If you activate your formatter in a standalone file, it will simply use the default rules which are set by the formatter. However, with a little bit of Lua tinkering, we can easily make it use our personalized rules as a default!

How to have default rules: Utilizing command-line flags

The key solution to realize default rules for conform.nvim formatters is the usage of command-line flags. In particular, the flags which specify what config file the formatter should use (e.g. --config).
Since conform.nvim is just a wrapper that calls the specific formatter binaries, we can tell it to use a specific config file flag when it does so. We can realize this within its options with the key prepend_args:

opts.prettier = {
    prepend_args = function()
        return {
            "--config",
            "/path/to/global/config/.prettierrc "
        }
    end
}

This is the fundamental idea behind realizing default rules, however, it still leaves us with one issue:
How do we make sure that the formatter still respects project-specific rules if the flag overwrites it?

In order to solve this, we will have to set the command-line flag conditionally depending on whether any project config file is present. But to do that, we will first need to define a few pieces of data for every formatter that we want to use with conform.nvim. Let’s look into it.

Defining formatter data

The best way to set up our command-line flag injection is by making a Lua table that keeps track of all data associated with a particular formatter.
I utilize the popular NeoVim plugin manager lazy.nvim which allows appending to the opts table of every plugin via an import statement:

return {
	"stevearc/conform.nvim",
	import = "plugins.lsp.formatters",
	config = function(_, opts)
        # Iterate over 'opts' here!
    end
}

The snippet above does the following: For every Lua file in the lsp/formatters directory, if the returned table in that file includes opts as a sub-table then that sub-table will be merged with all other into opts.

Take a look at the example file: lsp/formatters/prettier.lua:

return {
	"stevearc/conform.nvim",
    opts = {
        prettier = {...}
    }
}

Assume now there are more files with the same structure for shfmt and eslint. The final opts for usage in our config function will then be:

opts =  {
    prettier = {...}
    shfmt = {...}
    eslint_d = {...} # Our setup is not unique to formatters only!
}

This setup will act as our basic architecture for defining information unique to every formatter program. But what data do we define?
In order to realize conditional command-line flags we need to provide three pieces of data:

  • The syntax of the command-line flag (e.g. -C, --config etc.)
  • The path to the file holding our default rules: /path/to/.prettierrc
  • A set of every config file name the formatter may accept. This is necessary so we can recognize any project-specific file

A filled out example of this is given here for prettier:

return {
	"stevearc/conform.nvim",
    opts = {
        prettier = {
			config_command = "--config",
			config_names = {
				".prettierrc",
				".prettierrc.json",
				".prettierrc.yml",
				".prettierrc.yaml",
				".prettierrc.json5",
				".prettierrc.js",
				".editorconfig",
			},
			config_path = ".prettierrc.json",
        }
    }
}

With all that set up, we can now implement the logic inside our config function.

Adding conditional default rules

In order to distinguish whether our default rules should take over or if project-specific rules should be applied, we simply attempt to find any specified config file while going up the directory tree, starting from the opened file. This is where we use our definition of config_names from earlier. The upward file searching can be accomplished with some Vim functionality:

function M.has_local_config(location, config_names)
	local has = false
	for i in pairs(config_names) do
		local found = vim.fs.find(config_names[i], { upward = true, location })
		if not (next(found) == nil) then
			has = true
			break
		end
	end
	return has
end

Now all that is left to do is to act depending on the condition. If the there exists a local config file, we prepend nothing to the arguments of the function. Else, we do prepend the specific command-line flag and the path to our default rules for that particular formatter. This is where our definition of config_command and config_path is relevant:

config = function(_, opts)
    for formatter, formatter_settings in pairs(opts) do
        opts[formatter] = {
            prepend_args = function(_, ctx)
                if utils.has_local_config(ctx.filename, formatter_settings.config_names) then
                    return {}
                else
                    return {
                        formatter_settings.config_command,
                        configs_location .. formatter_settings.config_path,
                    }
                end
            end,
        }
    end

Voila! With all of this setup we have successfully implemented custom default rules for NeoVim!
You can checkout the full source code for this implementation within my NeoVim dotfiles here.

Wrapping up

While this situation works very well and is quite simple, there are a few caveats worth mentioning.

  • Every formatter we use must now be fully defined if we want it to work with our implementation. This requires a little bit of effort upfront whenever you add a new formatter.
  • Inline formatter rules may be ignored. Our condition check depends on the (non)existence of config files inside the project. However, if some files utilize inline rules in the form of source code comments, our implementation will not know about them. This is not really problematic, however, given few projects would rely entirely on inline rules without having any config files.
  • It must be assumed that all-encompassing config files like editorconfig may contain formatting rules. Thus, even if an .editorconfig file in a project defines no formatting rules, it will still affect our condition outcome simply because it could contain formatting rules.
  • As with all things, performance may be an issue. The upward searching of a config file may take a not negligible amount of time; delaying the speed it takes for the formatter to be ready to be used.