Easy Per-Project Formatting in Neovim

// 5 min read

How to easily format every file according to project rules in Neovim with Nix

neovim nix

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:

  1. treefmt & treefmt-nix: Configures all formatters for a project declaratively in Nix and exposes a single unified treefmt binary.
  2. direnv (in Neovim): Automatically loads the project’s Nix devshell when opening a buffer, putting that repo’s specific treefmt executable into $PATH.
  3. 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:

Terminal window
use flake

In 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.nvim pipes the buffer content to treefmt via stdin while passing the filename. Treefmt uses the filename to determine which formatter to trigger according to the project’s treefmt.nix rules.
  • notify_no_formatters & notify_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:
    function(err, did_edit)
    if not err then
    vim.notify('Formatted file', nil, { title = 'Conform' })
    end
    end
    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! :)

Why This Workflow Rules

  • Zero Editor Bloat: You don’t need Mason, language-specific formatter plugins, or dozens of lines in your dotfiles mapping .rs to rustfmt, .nix to alejandra, .ts to 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 treefmt derivation.
  • 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.