Make nixd module completion to work anywhere (with Flakes)

If you want the TL;DR, go to the bottom of the post (search for "final result").

I recently switched from nil to nixd as my LSP of choice for Nix. I was curious in nixd for a long time since the fact that it can eval Nix code means it can offer much more powerful completion than the nil's static analysis, however it used to be difficult to setup. Nowadays it is much easier, basically doing the right thing as long as you have NIX_PATH setup, and you get both package and NixOS modules completion.

Getting Home-Manager modules though needs some setup. The recommended way to setup accordingly to the official documentation is to use the following for Flake based configurations (using neovim configuration here, but it should be easy to adapt to other editors):

{
  nixpkgs = {
    expr = "import <nixpkgs> { }",
  },
  options = {
    nixos = {
      expr = '(builtins.getFlake ("git+file://" + toString ./.)).nixosConfigurations.miku-nixos.options',
    },
    home_manager = {
      expr = '(builtins.getFlake ("git+file://" + toString ./.)).homeConfigurations.home-linux.options',
    },
  },
  -- ...
}

This works, but it should be pretty clear the limitations of using ./.: this will only work if you open your editor in your Nix configuration repository. For any other repository, nixosConfigurations.miku-nixos or homeConfigurations.home-linux will not exist and the completion will not work.

It may look like this is easy to fix if you have specialArgs (or extraSpecialArgs in Home-Manager) set to have your Flakes inputs, but:

# By the way, ${self} does not exist in the Flake output by default, you need
# to explicit add `inherit self` to your outputs:
# https://discourse.nixos.org/t/who-is-self-in-flake-outputs/31859/4
nix-repl> (builtins.getFlake "git+file://${self}").nixosConfigurations.miku-linux.options
error:
        while calling the 'getFlake' builtin
         at «string»:1:2:
            1| (builtins.getFlake "git+file://${self}")
             |  ^

        while evaluating the argument passed to builtins.getFlake

       error: the string 'git+file:///nix/store/avr1lcmznj8ghynh5vj1kakgfdf0zrxx-source' is not allowed to refer to a store path (such as 'avr1lcmznj8ghynh5vj1kakgfdf0zrxx-source')

Well, it was worth a try. Another option would be to:

(builtins.getFlake "github:thiagokokada/nix-configs").nixosConfigurations.miku-linux.options
# Or even something like this
# However, using ${rev} means this wouldn't work in dirty Flake repos, since
# ${rev} is not set in those cases
(builtins.getFlake "github:thiagokokada/nix-configs/${rev}").nixosConfigurations.miku-linux.options

But while it works, it is slow, because it needs network to evaluate (and it is impure, since there is no flake.lock).

The default configuration for nixd makes NixOS completion work even outside of my configuration repo, and it is fast. How? I decided to take a look at the nixd source code and found this (formatted here for legibility):

(
  let
    pkgs = import <nixpkgs> { };
  in
  (pkgs.lib.evalModules {
    modules = (import <nixpkgs/nixos/modules/module-list.nix>) ++ [
      ({ ... }: { nixpkgs.hostPlatform = builtins.currentSystem; })
    ];
  })
).options

Interesting, so they're manually loading the modules using evalModules. As I said above, it depends in NIX_PATH being correctly set. Can we fix this to use our Flake inputs instead? After some tries in the Nix REPL, I got the following:

(
  let
    pkgs = import "${inputs.nixpkgs}" { };
  in
  (pkgs.lib.evalModules {
    modules = (import "${inputs.nixpkgs}/nixos/modules/module-list.nix") ++ [
      ({ ... }: { nixpkgs.hostPlatform = builtins.currentSystem; })
    ];
  })
).options

So we can adapt this to the neovim configuration:

{
  options = {
    nixos = {
      expr = '(let pkgs = import "${inputs.nixpkgs}" { }; in (pkgs.lib.evalModules { modules =  (import "${inputs.nixpkgs}/nixos/modules/module-list.nix") ++ [ ({...}: { nixpkgs.hostPlatform = builtins.currentSystem;} ) ] ; })).options',
    },
  },
}

This was easy. But the main issue is Home-Manager. How can we fix it? I needed to take a look at the Home-Manager source code to find the answer:

(
  let
    pkgs = import "${inputs.nixpkgs}" { };
    lib = import "${inputs.home-manager}/modules/lib/stdlib-extended.nix" pkgs.lib;
  in
  (lib.evalModules {
    modules = (import "${inputs.home-manager}/modules/modules.nix") {
      inherit lib pkgs;
      check = false;
    };
  })
).options

The interesting part is: Home-Manager has its own extension of the module system (including evalModules). This includes e.g.: extra types used in Home-Manager only. Also, we need to disable checks, otherwise we will hit some validations (e.g.: missing stateVersion). I am not sure if this causes any issue for module completion yet, I may set it in the future.

And for the final result:

{
  nixpkgs = {
    expr = 'import "${flake.inputs.nixpkgs}" { }',
  },
  options = {
    nixos = {
      expr = '(let pkgs = import "${inputs.nixpkgs}" { }; in (pkgs.lib.evalModules { modules =  (import "${inputs.nixpkgs}/nixos/modules/module-list.nix") ++ [ ({...}: { nixpkgs.hostPlatform = builtins.currentSystem;} ) ] ; })).options',
    },
    home_manager = {
      expr = '(let pkgs = import "${inputs.nixpkgs}" { }; lib = import "${inputs.home-manager}/modules/lib/stdlib-extended.nix" pkgs.lib; in (lib.evalModules { modules =  (import "${inputs.home-manager}/modules/modules.nix") { inherit lib pkgs; check = false; }; })).options',
    },
  },
}

Yes, it is quite a mouthful, but it makes module completion work in any repository, as long as you're using Flakes. And it is fast, since it doesn't need any network access. Since we are already here, let's define nixpkgs to not depend in the NIX_PATH being set too.