plugins/lint: add auto-install support

This commit is contained in:
Austin Horstman
2026-04-24 12:00:37 +00:00
parent f63fae1335
commit ee8269e46f
4 changed files with 449 additions and 36 deletions
+71
View File
@@ -0,0 +1,71 @@
{
pkgs,
lib,
...
}:
let
inherit (builtins)
attrValues
concatMap
isString
tryEval
;
inherit (import ./packages.nix { inherit pkgs lib; }) linter-packages states;
evalOr =
fallback: value:
let
result = tryEval value;
in
if result.success then result.value else fallback;
getTopLevelPackageByName = name: evalOr states.broken (pkgs.${name} or null);
in
{
collectLinters = lintersByFt: lib.unique (lib.flatten (attrValues lintersByFt));
getPackageOrStateByName =
{ definedLinters, overrides }:
name:
let
maybePackage =
if builtins.hasAttr name overrides then
overrides.${name}
else
lib.findFirst (value: value != null) states.unknown (
[
(linter-packages.${name} or null)
(getTopLevelPackageByName name)
]
++ lib.optional (builtins.elem name definedLinters) states.userDefined
);
in
if isString maybePackage then
{
wrong = {
inherit name;
reason = maybePackage;
};
}
else
{ right = maybePackage; };
mkWarnsFromStates =
opts: list:
let
mkWarn =
{ name, reason }:
lib.nixvim.mkWarnings "lint" [
{
when = true;
message = ''
You have enabled the '${name}' linter, but nixvim cannot automatically install its package because ${reason}.
Set '${opts.autoInstall.overrides}.${name}' to the appropriate package to install it automatically,
or set it to `null` to silence this warning. You can also disable all warnings related to packages
not installed by `autoInstall` with '${opts.autoInstall.enableWarnings}'.
'';
}
];
in
concatMap mkWarn list;
}
+97 -36
View File
@@ -1,4 +1,8 @@
{ lib, ... }:
{
lib,
pkgs,
...
}:
let
inherit (lib) mkOption types;
inherit (lib.nixvim) defaultNullOpts toLuaObject;
@@ -95,7 +99,7 @@ let
freeformType = with types; attrsOf anything;
options = builtins.mapAttrs (
optionName:
_:
(
{
mandatory ? false,
@@ -136,6 +140,37 @@ lib.nixvim.plugins.mkNeovimPlugin {
maintainers = [ lib.maintainers.HeitorAugustoLN ];
extraOptions = {
autoInstall = {
enable = lib.mkEnableOption ''
automatic installation of linters listed in `lintersByFt`
'';
enableWarnings = lib.mkOption {
default = true;
example = false;
description = "Whether to enable warnings.";
type = lib.types.bool;
};
overrides = lib.mkOption {
type = with types; attrsOf (nullOr package);
default = { };
example = lib.literalExpression ''
{
"jsonlint" = null;
"my-linter" = pkgs.writeShellApplication {
name = "my-linter";
text = "exec my-linter \"$@\"";
};
}
'';
description = ''
Attribute set of linter names to nix packages.
You can set a linter to `null` to disable auto-installing its package.
'';
};
};
autoCmd =
let
defaultEvent = "BufWritePost";
@@ -212,40 +247,66 @@ lib.nixvim.plugins.mkNeovimPlugin {
'';
};
extraConfig = cfg: {
autoCmd = lib.optionals (cfg.autoCmd != null) [ cfg.autoCmd ];
plugins.lint.luaConfig.content = ''
local __lint = require('lint')
''
+ (lib.optionalString (cfg.lintersByFt != null) ''
__lint.linters_by_ft = ${toLuaObject cfg.lintersByFt}
'')
+ (lib.optionalString (cfg.customLinters != null) (
lib.concatLines (
lib.mapAttrsToList (
customLinter: linterConfig:
let
linterConfig' =
if builtins.isString linterConfig then lib.nixvim.mkRaw linterConfig else linterConfig;
in
''__lint.linters["${customLinter}"] = ${toLuaObject linterConfig'}''
) cfg.customLinters
)
))
+ (lib.optionalString (cfg.linters != null) (
lib.concatLines (
lib.flatten (
extraConfig =
cfg: opts:
let
inherit (cfg.autoInstall) enable enableWarnings;
inherit (import ./auto-install.nix { inherit pkgs lib; })
collectLinters
getPackageOrStateByName
mkWarnsFromStates
;
configuredLinters = if cfg.linters == null then { } else cfg.linters;
customLinters = if cfg.customLinters == null then { } else cfg.customLinters;
definedLinters = builtins.attrNames configuredLinters ++ builtins.attrNames customLinters;
getPackageOrStateByNameWith = getPackageOrStateByName {
inherit definedLinters;
inherit (cfg.autoInstall) overrides;
};
selectedLinters = lib.optionals (cfg.lintersByFt != null) (collectLinters cfg.lintersByFt);
packagesAndStates = lib.foldAttrs (item: acc: [ item ] ++ acc) [ ] (
map getPackageOrStateByNameWith selectedLinters
);
in
{
autoCmd = lib.optionals (cfg.autoCmd != null) [ cfg.autoCmd ];
warnings = lib.mkIf (enable && enableWarnings) (
mkWarnsFromStates opts (packagesAndStates.wrong or [ ])
);
extraPackages = lib.mkIf enable (packagesAndStates.right or [ ]);
plugins.lint.luaConfig.content = ''
local __lint = require('lint')
''
+ (lib.optionalString (cfg.lintersByFt != null) ''
__lint.linters_by_ft = ${toLuaObject cfg.lintersByFt}
'')
+ (lib.optionalString (cfg.customLinters != null) (
lib.concatLines (
lib.mapAttrsToList (
linter: linterConfig:
lib.mapAttrsToList (
propName: propValue:
lib.optionalString (
propValue != null
) ''__lint.linters["${linter}"].${propName} = ${toLuaObject propValue}''
) linterConfig
) cfg.linters
customLinter: linterConfig:
let
linterConfig' =
if builtins.isString linterConfig then lib.nixvim.mkRaw linterConfig else linterConfig;
in
''__lint.linters["${customLinter}"] = ${toLuaObject linterConfig'}''
) cfg.customLinters
)
)
));
};
))
+ (lib.optionalString (cfg.linters != null) (
lib.concatLines (
lib.flatten (
lib.mapAttrsToList (
linter: linterConfig:
lib.mapAttrsToList (
propName: propValue:
lib.optionalString (
propValue != null
) ''__lint.linters["${linter}"].${propName} = ${toLuaObject propValue}''
) linterConfig
) cfg.linters
)
)
));
};
}
+82
View File
@@ -0,0 +1,82 @@
{
lib,
pkgs,
}:
let
inherit (builtins)
tryEval
;
inherit (pkgs) stdenv;
toPackage = loc: lib.getAttrFromPath (lib.toList loc) pkgs;
states = {
broken = "it is currently broken in nixpkgs";
darwinOnly = p: if stdenv.hostPlatform.isDarwin then p else "it is only available on Darwin";
linuxOnly = p: if stdenv.hostPlatform.isLinux then p else "it is only available on Linux";
unpackaged = "it is not packaged in nixpkgs";
unknown = "nixvim does not know which package provides it";
userDefined = "it is a user-defined linter";
};
evalOr =
fallback: value:
let
result = tryEval value;
in
if result.success then result.value else fallback;
getTopLevelPackageByName = name: evalOr states.broken (pkgs.${name} or null);
resolvePackageState = name: lib.defaultTo states.unpackaged (getTopLevelPackageByName name);
resolvePackage =
name: loc:
let
default = resolvePackageState name;
in
if loc == null then default else evalOr default (toPackage loc);
resolvePackages = packaged: lib.mapAttrs resolvePackage packaged;
resolveNames =
names: builtins.listToAttrs (map (name: lib.nameValuePair name (resolvePackageState name)) names);
efmlsPackages = import ../efmls-configs/packages.nix lib;
noneLsPackages = import ../none-ls/packages.nix lib;
in
{
inherit states;
linter-packages =
# These upstream package maps can contain stale "unpackaged" markers and broken aliases.
# Resolve them against the current nixpkgs first so auto-install warns instead of exploding.
resolveNames efmlsPackages.unpackaged
// resolvePackages efmlsPackages.packaged
// resolvePackages noneLsPackages.packaged
// {
biomejs = pkgs.biome;
buf_lint = pkgs.buf;
clazy = states.linuxOnly pkgs.clazy;
clangtidy = pkgs."clang-tools";
cmakelint = pkgs."cmake-format";
dmypy = pkgs.mypy;
inherit (pkgs.luaPackages) fennel;
ghdl = states.linuxOnly pkgs.ghdl;
golangcilint = pkgs."golangci-lint";
json_tool = pkgs.python3;
ls_lint = pkgs."ls-lint";
mago_analyze = pkgs.mago;
mago_lint = pkgs.mago;
opa_check = pkgs."open-policy-agent";
inherit (pkgs.phpPackages) phpinsights;
inherit (pkgs) phpstan;
pony = pkgs.ponyc;
inherit (pkgs.python3Packages) pycodestyle;
inherit (pkgs.python3Packages) pydocstyle;
rstlint = pkgs.python3Packages."restructuredtext-lint";
systemd-analyze = states.linuxOnly pkgs.systemd;
tofu = pkgs.opentofu;
vacuum = states.linuxOnly pkgs.vacuum;
};
}
@@ -1,8 +1,207 @@
{ lib, pkgs, ... }:
{
empty = {
plugins.lint.enable = true;
};
all-linters =
let
inherit (import ../../../../../plugins/by-name/lint/auto-install.nix { inherit lib pkgs; })
getPackageOrStateByName
;
resolvedLinters = map (name: {
inherit name;
result = getPackageOrStateByName {
definedLinters = [ ];
overrides = { };
} name;
}) (lib.importJSON ../../../../../generated/lint-linters.json);
installableLinters = map (x: x.name) (
builtins.filter (
x:
x.result ? right
&& lib.meta.availableOn pkgs.stdenv.hostPlatform x.result.right
&& !(x.result.right.meta.broken or false)
) resolvedLinters
);
badPackageLinters = builtins.filter (
x:
x.result ? right
&& (
!lib.meta.availableOn pkgs.stdenv.hostPlatform x.result.right
|| (x.result.right.meta.broken or false)
)
) resolvedLinters;
in
{
plugins.lint = {
enable = true;
autoInstall = {
enable = true;
enableWarnings = false;
};
lintersByFt."*" = installableLinters;
};
test = {
buildNixvim = false;
warnings = expect: [ (expect "count" 0) ];
};
assertions = [
{
assertion = badPackageLinters == [ ];
message =
"The following documented nvim-lint linters resolve to broken or unavailable packages and need an explicit state in plugins/by-name/lint/packages.nix: "
+ lib.concatMapStringsSep ", " (
x: "${x.name} -> ${x.result.right.pname or x.result.right.name or "drv"}"
) badPackageLinters;
}
];
};
auto-install =
{ config, ... }:
{
plugins.lint = {
enable = true;
autoInstall.enable = true;
lintersByFt = {
json = [ "jsonlint" ];
terraform = [
"tflint"
"tofu"
];
};
};
test = {
buildNixvim = false;
warnings = expect: [
(expect "count" 1)
(expect "any" "jsonlint")
(expect "any" "not packaged in nixpkgs")
];
};
assertions = [
{
assertion = lib.elem pkgs.tflint config.extraPackages;
message = "Expected `tflint` to be in `extraPackages`.";
}
{
assertion = lib.elem pkgs.opentofu config.extraPackages;
message = "Expected `opentofu` to be in `extraPackages` for the `tofu` linter.";
}
];
};
linux-only-auto-install =
{ config, ... }:
let
inherit (pkgs.stdenv) isLinux;
in
{
plugins.lint = {
enable = true;
autoInstall.enable = true;
lintersByFt.systemd = [ "systemd-analyze" ];
};
test = {
buildNixvim = false;
warnings =
expect:
if isLinux then
[ (expect "count" 0) ]
else
[
(expect "count" 1)
(expect "any" "only available on Linux")
];
};
assertions = [
{
assertion = isLinux == lib.elem pkgs.systemd config.extraPackages;
message =
if isLinux then
"Expected `systemd` to be in `extraPackages` on Linux."
else
"Expected `systemd` not to be in `extraPackages` on non-Linux systems.";
}
];
};
auto-install-renamed-package =
{ config, ... }:
{
plugins.lint = {
enable = true;
autoInstall.enable = true;
lintersByFt.fennel = [ "fennel" ];
};
test = {
buildNixvim = false;
warnings = expect: [ (expect "count" 0) ];
};
assertions = [
{
assertion = lib.elem pkgs.luaPackages.fennel config.extraPackages;
message = "Expected `luaPackages.fennel` to be in `extraPackages` for the `fennel` linter.";
}
];
};
auto-install-broken-alias =
{ config, ... }:
{
plugins.lint = {
enable = true;
autoInstall.enable = true;
lintersByFt.php = [ "phpstan" ];
};
test = {
buildNixvim = false;
warnings = expect: [ (expect "count" 0) ];
};
assertions = [
{
assertion = lib.elem pkgs.phpstan config.extraPackages;
message = "Expected `phpstan` to be in `extraPackages` for the `phpstan` linter.";
}
];
};
auto-install-null-override =
{ config, ... }:
{
plugins.lint = {
enable = true;
autoInstall = {
enable = true;
overrides.jsonlint = null;
};
lintersByFt.json = [ "jsonlint" ];
};
test = {
buildNixvim = false;
warnings = expect: [ (expect "count" 0) ];
};
assertions = [
{
assertion = config.extraPackages == [ ];
message = "Expected `null` overrides to skip auto-installing the linter package.";
}
];
};
example = {
plugins.lint = {
enable = true;