If you write code across different languages and repositories, formatting in Neovim can quickly turn into a maintenance nightmare.
One repo uses Prettier with tabs. Another uses Prettier with 2 spaces. A third repo recently migrated to Biome. Then you have Rust projects needing rustfmt, Nix files formatted with alejandra (or nixfmt), Python with ruff, and Lua with stylua.
Before you know it, your Neovim config contains a massive matrix of formatters, fallback chains, Mason auto-installers, and filetype hooks. And whenever you contribute to a project with slightly different tooling, you either have to tweak your personal editor config or risk fighting with the repo’s CI.
There is a much cleaner way: delegate formatting entirely to the project, and teach Neovim to just run whatever the project prescribes.
Here is how I set this up using treefmt-nix, direnv, and conform.nvim.
The Idea: The Project Owns the Formatting
Instead of your editor deciding how to format files, the repository should define its own formatters and rules.
The setup consists of three simple pieces:
- treefmt & treefmt-nix: Configures all formatters for a project declaratively in Nix and exposes a single unified
treefmtbinary. - direnv (in Neovim): Automatically loads the project’s Nix devshell when opening a buffer, putting that repo’s specific
treefmtexecutable into$PATH. - conform.nvim: Neovim’s formatting plugin, configured with a single wildcard rule: pipe every filetype through
treefmt.
With this trifecta, your Neovim config needs zero language-specific formatter knowledge.
1. Project Setup: treefmt-nix
In each project, we use treefmt-nix to declare which formatters should run on which files. For example, in a devshell definition:
treefmtWrapper = treefmt.mkWrapper pkgs { projectRootFile = "flake.nix"; programs = { alejandra.enable = true; prettier.enable = true; }; settings.formatter.prettier.includes = [ "*.ts" "*.tsx" "*.json" "*.mdx" ];};When you add treefmtWrapper to your devshell’s packages, you get a treefmt command in your environment that already knows about Alejandra and Prettier, without needing to install anything globally.
Best of all: the exact same treefmt runs in git pre-commit hooks (e.g. via lefthook) and in CI. Total parity across your entire workflow :D
2. Editor Integration: direnv
To let Neovim know about the project’s devshell, we use direnv together with an .envrc:
use flakeIn Neovim, install a direnv plugin (like direnv.vim). As soon as you open a file in the project, direnv evaluates the .envrc and hooks the devshell’s environment into the current Neovim session.
This means treefmt, along with all project-specific tools, is now directly available on $PATH inside your editor buffer.
3. The Neovim Config: conform.nvim
Now comes the fun part. Since treefmt knows which formatter belongs to which file based on extension and path, Neovim doesn’t have to guess or map filetypes anymore.
We tell conform.nvim to run treefmt for all filetypes using the * wildcard.
Here is my config in Nix (since I use nixvim):
{utils, ...}:with utils; { plugins.conform-nvim = { enable = true; settings = { notify_no_formatters = true; notify_on_error = true; formatters_by_ft = { "*" = ["treefmt"]; }; default_format_opts.async = true; formatters."treefmt" = { command = "treefmt"; args = ["--stdin" "$FILENAME"]; stdin = true; }; }; };
keymaps = [ # add callback to display notification (to know that something has changed) ( kms "n" "<leader>cf" "<cmd>lua require('conform').format({ timeout_ms = 10000, }, function(err, did_edit) if not err then vim.notify('Formatted file', nil, { title = 'Conform' }) end end)<CR>" "Format current buffer" ) ];}How this works:
formatters_by_ft."*" = ["treefmt"]: This catches every buffer you open. No per-language lists needed!args = ["--stdin", "$FILENAME"]:conform.nvimpipes the buffer content totreefmtvia stdin while passing the filename. Treefmt uses the filename to determine which formatter to trigger according to the project’streefmt.nixrules.notify_no_formatters¬ify_on_error: Alerts you if something goes wrong, but keeps quiet when things are working smoothly.- Notification callback on
<leader>cf: The formatting call includes a small callback:Whenever formatting succeeds, a small notification pops up confirming the file was formatted. Or rather, treefmt was run, it might also have changed nothing, which is also success! :)function(err, did_edit)if not err thenvim.notify('Formatted file', nil, { title = 'Conform' })endend
Why This Workflow Rules
- Zero Editor Bloat: You don’t need Mason, language-specific formatter plugins, or dozens of lines in your dotfiles mapping
.rsto rustfmt,.nixto alejandra,.tsto prettier, etc. - Zero Config Drift: Whatever formatting rules your project uses (and whatever versions are pinned in
flake.lock), your editor will execute faithfully. - CI & Local Parity: If it formats cleanly in Neovim, it will format cleanly in pre-commit hooks and CI, because it’s literally running the identical
treefmtderivation. - Graceful No-ops: If you’re working in a project without a devshell or a file that treefmt doesn’t handle, conform handles it gracefully without breaking your editing flow.