diff --git a/.stylua.toml b/.stylua.toml new file mode 100644 index 0000000..3f79723 --- /dev/null +++ b/.stylua.toml @@ -0,0 +1,7 @@ +column_width = 120 +line_endings = "Unix" +indent_type = "Spaces" +indent_width = 4 +quote_style = "AutoPreferDouble" +call_parentheses = "Always" +collapse_simple_statement = "Never" diff --git a/README.md b/README.md index ec8d8e6..787af4e 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,16 @@ Automatically install formatters registered with [conform.nvim](https://github.com/stevearc/conform.nvim) via [Mason](https://github.com/williamboman/mason.nvim). +# Requirements + +- Neovim 0.11+ +- [mason.nvim](https://github.com/mason-org/mason.nvim) 2.x +- [conform.nvim](https://github.com/stevearc/conform.nvim) (9.x or newer) + # Install +With [lazy.nvim](https://github.com/folke/lazy.nvim): + ```lua require("lazy").setup({ "williamboman/mason.nvim", @@ -12,6 +20,16 @@ require("lazy").setup({ }) ``` +With Neovim's built-in package manager (`:h vim.pack`, Neovim 0.12+): + +```lua +vim.pack.add({ + { src = "https://github.com/mason-org/mason.nvim" }, + { src = "https://github.com/stevearc/conform.nvim" }, + { src = "https://github.com/zapling/mason-conform.nvim" }, +}) +``` + # Setup It's crucial to setup plugins in the following order: @@ -31,12 +49,40 @@ require("mason-conform").setup({ }) ``` +All the `formatters_by_ft` shapes supported by conform are understood: + +```lua +require("conform").setup({ + formatters_by_ft = { + -- Plain list + lua = { "stylua" }, + -- List with inline format options (`lsp_format`, `stop_after_first`, ...) + python = { "autopep8", lsp_format = "fallback" }, + -- Legacy nested list ("run the first available formatter") + markdown = { { "prettierd", "prettier" }, "cbfmt" }, + -- Function returning a list (conform "first available" recipe) + javascript = function(bufnr) + return { require("conform").get_formatter_info("prettier", bufnr).available and "prettier" or "prettierd" } + end, + }, +}) +``` + +Functions are evaluated without a buffer so that every candidate formatter can +be discovered and installed. A function that requires a buffer is skipped +instead of raising an error. + # Available formatters Only formatters that are available in the [mason registry](https://github.com/mason-org/mason-registry) can be downloaded automatically. If the formatter is available in the registry and it's not being downloaded, this plugin might be missing a `conform` => `mason` mapping in the file [lua/mason-conform/mapping.lua](lua/mason-conform/mapping.lua). +# Healthcheck + +Run `:checkhealth mason-conform` to verify that Neovim, `mason.nvim` and `conform.nvim` are set up +correctly, and to see which configured formatters cannot be installed automatically. + # License `mason-conform.nvim` is a blatant copy of [mason-nvim-lint](https://github.com/rshkarin/mason-nvim-lint) diff --git a/lua/mason-conform/auto_install.lua b/lua/mason-conform/auto_install.lua index d51e9dc..bbdafa3 100644 --- a/lua/mason-conform/auto_install.lua +++ b/lua/mason-conform/auto_install.lua @@ -1,42 +1,25 @@ -local registry = require("mason-registry") -local mapping = require("mason-conform.mapping") - local function auto_install() local config = require("mason-conform").config - local formatters_by_ft = require("conform").formatters_by_ft; + local mapping = require("mason-conform.mapping") + local install = require("mason-conform.install") - local formatters_to_install = {} - for _, formatters in pairs(formatters_by_ft) do - for _, formatter in pairs(formatters) do - -- Support case where the user has defined multiple formatters - -- for said filetype. E.g javascript = { { "prettierd", "prettier" } } - if type(formatter) == "table" then - for _, f in pairs(formatter) do - formatters_to_install[f] = 1 - end - else - formatters_to_install[formatter] = 1 + local ignored = {} + for _, name in ipairs(config.ignore_install) do + ignored[name] = true + end + + for _, name in ipairs(require("mason-conform.formatters").configured()) do + if not ignored[name] then + local package = mapping.conform_to_package[name] + if package ~= nil then + install.try_install(package) end end end - - -- Filter out formatters that the user wants to ignore - for _, formatter_to_ignore in pairs(config.ignore_install) do - formatters_to_install[formatter_to_ignore] = nil - end - - for conformFormatter, _ in pairs(formatters_to_install) do - local package = mapping.conform_to_package[conformFormatter] - if package ~= nil then - require("mason-conform.install").try_install(package) - end - end end -if registry.refresh then - return function() - registry.refresh(vim.schedule_wrap(auto_install)) - end -else - return auto_install +---Refresh the mason registry, then install every configured formatter that is +---missing. Returns immediately; installation happens on the main loop. +return function() + require("mason-registry").refresh(vim.schedule_wrap(auto_install)) end diff --git a/lua/mason-conform/formatters.lua b/lua/mason-conform/formatters.lua new file mode 100644 index 0000000..6798b9e --- /dev/null +++ b/lua/mason-conform/formatters.lua @@ -0,0 +1,63 @@ +local M = {} + +---Collect formatter names from a single `formatters_by_ft` entry. +--- +---A conform filetype formatter can be any of: +--- * a list of formatter names, optionally followed by string-keyed format +--- options (e.g. `{ "stylua", lsp_format = "fallback" }`) +--- * a function `fun(bufnr): string[]`, used by the "run the first available +--- formatter" recipe +--- * the legacy nested list `{ { "prettierd", "prettier" } }` +--- +---Only the array part of the table is traversed, so format options such as +---`lsp_format` and `stop_after_first` are ignored. +---@param value any +---@param out table +local function collect_entry(value, out) + local list = value + if type(list) == "function" then + -- A function may depend on a buffer and/or on run-time availability. + -- We don't have a buffer to pass here, so evaluate it best-effort and + -- silently skip it when it cannot be called without one. + local ok, result = pcall(list) + if not ok or type(result) ~= "table" then + return + end + list = result + end + + if type(list) ~= "table" then + return + end + + for _, formatter in ipairs(list) do + if type(formatter) == "table" then + -- Legacy "run the first available formatter" syntax. + collect_entry(formatter, out) + elseif type(formatter) == "string" then + out[formatter] = true + end + end +end + +---List every formatter configured in conform's `formatters_by_ft`. +--- +---Understands all `formatters_by_ft` shapes supported by conform, including +---functions returning a list and the legacy nested "run the first available" +---syntax. +---@return string[] names Sorted, de-duplicated formatter names. +function M.configured() + local out = {} + for _, entry in pairs(require("conform").formatters_by_ft or {}) do + collect_entry(entry, out) + end + + local names = {} + for name in pairs(out) do + names[#names + 1] = name + end + table.sort(names) + return names +end + +return M diff --git a/lua/mason-conform/health.lua b/lua/mason-conform/health.lua new file mode 100644 index 0000000..2343deb --- /dev/null +++ b/lua/mason-conform/health.lua @@ -0,0 +1,76 @@ +local M = {} + +---@return boolean +local function check_mason() + local ok, mason_version = pcall(require, "mason.version") + if not ok then + vim.health.error("mason.nvim was not found. Is it installed and loaded?") + return false + end + if mason_version.MAJOR_VERSION ~= 2 then + vim.health.error(("mason.nvim v2 is required (found v%d)."):format(mason_version.MAJOR_VERSION)) + return false + end + vim.health.ok("mason.nvim v2") + return true +end + +function M.check() + vim.health.start("mason-conform.nvim") + + if vim.fn.has("nvim-0.11") == 1 then + vim.health.ok("Neovim v0.11+") + else + vim.health.error("Neovim v0.11 or higher is required.") + end + + local mason_installed = check_mason() + + if mason_installed then + local registry_ok = pcall(require, "mason-registry") + if registry_ok then + vim.health.ok("mason registry is accessible") + else + vim.health.error("mason-registry could not be loaded.") + end + end + + local conform_ok = pcall(require, "conform") + if not conform_ok then + vim.health.error("conform.nvim was not found. Is it installed and loaded?") + return + end + vim.health.ok("conform.nvim") + + local formatters = require("mason-conform.formatters").configured() + if #formatters == 0 then + vim.health.warn("No formatters are configured in conform's formatters_by_ft, so there is nothing to install.") + return + end + vim.health.ok(("%d formatter(s) configured in formatters_by_ft"):format(#formatters)) + + local mapping = require("mason-conform.mapping") + local unmapped = {} + for _, name in ipairs(formatters) do + if mapping.conform_to_package[name] == nil then + unmapped[#unmapped + 1] = name + end + end + if #unmapped > 0 then + vim.health.info( + ("No mason package mapping for: %s. These will not be installed automatically."):format( + table.concat(unmapped, ", ") + ) + ) + end + + local ignored = {} + for _, name in ipairs(require("mason-conform").config.ignore_install) do + ignored[#ignored + 1] = name + end + if #ignored > 0 then + vim.health.info(("Ignored by 'ignore_install': %s"):format(table.concat(ignored, ", "))) + end +end + +return M diff --git a/lua/mason-conform/init.lua b/lua/mason-conform/init.lua index ba3a954..da904bf 100644 --- a/lua/mason-conform/init.lua +++ b/lua/mason-conform/init.lua @@ -1,13 +1,18 @@ local M = {} +---@class MasonConformConfig +---@field ignore_install string[] Formatters that should never be installed automatically. + +---@type MasonConformConfig M.config = { - ignore_install = {} + ignore_install = {}, } -function M.setup(cfg) - if cfg and cfg.ignore_install then - M.config.ignore_install = cfg.ignore_install - end +---@param opts? MasonConformConfig Options overriding the defaults. +function M.setup(opts) + vim.validate("opts", opts, "table", true) + + M.config = vim.tbl_deep_extend("force", { ignore_install = {} }, opts or {}) require("mason-conform.auto_install")() end diff --git a/lua/mason-conform/install.lua b/lua/mason-conform/install.lua index 57f5fa2..14167eb 100644 --- a/lua/mason-conform/install.lua +++ b/lua/mason-conform/install.lua @@ -1,31 +1,19 @@ -local registry = require "mason-registry" - local M = {} -local function resolve_package(mason_package_name) - local Optional = require "mason-core.optional" - - local ok, pkg = pcall(registry.get_package, mason_package_name) - if ok then - return Optional.of_nilable(pkg) - end -end - +---@param pkg Package The mason package to install. +---@param version? string Pin the installation to a specific version. local function install_package(pkg, version) - local linter_name = pkg.name + vim.notify(("[mason-conform] installing %s"):format(pkg.name)) - vim.notify(("[mason-conform] installing %s"):format(linter_name)) - - return pkg:install({ version = version }):once( + pkg:install({ version = version }):once( "closed", vim.schedule_wrap(function() if pkg:is_installed() then - vim.notify(("[mason-conform] %s was successfully installed"):format(linter_name)) + vim.notify(("[mason-conform] %s was successfully installed"):format(pkg.name)) else vim.notify( - ("[mason-conform] failed to install %s. Installation logs are available in :Mason and :MasonLog") - :format( - linter_name + ("[mason-conform] failed to install %s. Installation logs are available in :Mason and :MasonLog"):format( + pkg.name ), vim.log.levels.ERROR ) @@ -34,32 +22,30 @@ local function install_package(pkg, version) ) end +---Install the mason package if it exists in the registry and is not already +---installed or installing. +---@param mason_package_name string Mason package identifier, optionally `name@version`. function M.try_install(mason_package_name) - local Package = require "mason-core.package" + local Package = require("mason-core.package") + local registry = require("mason-registry") local package_name, version = Package.Parse(mason_package_name) - resolve_package(package_name) - :if_present( - function(pkg) - if not pkg:is_installed() then - if require("mason.version").MAJOR_VERSION == 2 then - if pkg:is_installing() then - return - end - end - install_package(pkg, version) - end - end + local ok, pkg = pcall(registry.get_package, package_name) + if not ok or pkg == nil then + vim.notify( + ("[mason-conform] Formatter %q is not available in the mason registry. Make sure it is a valid formatter name, or add it to 'ignore_install' to silence this warning."):format( + package_name + ), + vim.log.levels.WARN ) - :if_not_present(function() - vim.notify( - ("[mason-conform] Formatter %q is not a valid entry in ensure_installed. Make sure to only provide valid formatter names.") - :format( - package_name - ), - vim.log.levels.WARN - ) - end) + return + end + + if pkg:is_installed() or pkg:is_installing() then + return + end + + install_package(pkg, version) end return M diff --git a/lua/mason-conform/mapping.lua b/lua/mason-conform/mapping.lua index 304186d..45070a4 100644 --- a/lua/mason-conform/mapping.lua +++ b/lua/mason-conform/mapping.lua @@ -1,7 +1,8 @@ local M = {} --- conform formatter to mason package mapping --- https://mason-registry.dev/registry/list +---Conform formatter name -> mason package name. +---https://mason-registry.dev/registry/list +---@type table M.conform_to_package = { ["air"] = "air", ["alejandra"] = "alejandra", @@ -53,6 +54,7 @@ M.conform_to_package = { -- dioxus ["djlint"] = "djlint", ["docformatter"] = "docformatter", + ["dockerfmt"] = "dockerfmt", -- docstrfmt ["doctoc"] = "doctoc", ["dprint"] = "dprint", @@ -75,6 +77,7 @@ M.conform_to_package = { -- gawk ["gci"] = "gci", ["gdformat"] = "gdtoolkit", + ["gdscript-formatter"] = "gdscript-formatter", ["gersemi"] = "gersemi", -- ghokin -- gleam @@ -118,6 +121,7 @@ M.conform_to_package = { -- liquidsoap-prettier -- llf ["lua-format"] = "luaformatter", + ["luafmt"] = "luafmt", -- mago_format -- mago_lint ["markdown-toc"] = "markdown-toc", @@ -143,7 +147,12 @@ M.conform_to_package = { -- odinfmt ["opa_fmt"] = "opa", ["ormolu"] = "ormolu", + ["oxfmt"] = "oxfmt", + ["oxlint"] = "oxlint", -- packer_fmt + ["palantir-java-format"] = "palantir-java-format", + ["panache"] = "panache", + ["panache-fix"] = "panache", -- pangu -- perlimports -- perltidy @@ -160,9 +169,10 @@ M.conform_to_package = { ["purs-tidy"] = "purescript-tidy", -- pycln ["pyink"] = "pyink", + ["pymarkdownlnt"] = "pymarkdownlnt", ["pyproject-fmt"] = "pyproject-fmt", -- python-ly - -- pyupgrade + ["pyupgrade"] = "pyupgrade", ["reformat-gherkin"] = "reformat-gherkin", ["reorder-python-imports"] = "reorder-python-imports", -- rescript-format @@ -170,10 +180,12 @@ M.conform_to_package = { -- rstfmt ["rubocop"] = "rubocop", ["rubyfmt"] = "rubyfmt", + ["ruff"] = "ruff", ["ruff_fix"] = "ruff", ["ruff_format"] = "ruff", ["ruff_organize_imports"] = "ruff", ["rufo"] = "rufo", + ["rumdl"] = "rumdl", -- runic -- ["rustfmt"] = "rustfmt", Deprecated by mason ["rustywind"] = "rustywind", @@ -199,7 +211,7 @@ M.conform_to_package = { ["superhtml"] = "superhtml", -- swift -- swift_format - -- swiftformat + ["swiftformat"] = "swiftformat", ["swiftlint"] = "swiftlint", -- syntax_tree ["taplo"] = "taplo", @@ -216,7 +228,7 @@ M.conform_to_package = { ["twig-cs-fixer"] = "twig-cs-fixer", -- typespec ["typos"] = "typos", - -- typstyle + ["typstyle"] = "typstyle", -- ufmt -- uncrustify ["usort"] = "usort", diff --git a/tests/smoke_test.lua b/tests/smoke_test.lua new file mode 100644 index 0000000..2b2f3a5 --- /dev/null +++ b/tests/smoke_test.lua @@ -0,0 +1,182 @@ +-- Smoke test for mason-conform.nvim with stubbed mason/conform. +-- Run from the repository root: nvim -l tests/smoke_test.lua +local script = (arg and arg[0]) or "tests/smoke_test.lua" +local root = script:match("^(.*)/tests/") or "." +package.path = ("%s/lua/?.lua;%s/lua/?/init.lua;"):format(root, root) .. package.path + +local failures = {} +local function check(cond, msg) + if not cond then + failures[#failures + 1] = msg + print("FAIL: " .. msg) + else + print("ok: " .. msg) + end +end + +-- ---- stubs ------------------------------------------------------------- +local notifications = {} +local install_calls = {} +local installed = {} +local installing = {} + +package.preload["conform"] = function() + return { + formatters_by_ft = { + lua = { "stylua" }, + python = { "autopep8", lsp_format = "fallback", stop_after_first = true }, + markdown = { { "prettierd", "prettier" }, "cbfmt" }, + javascript = function() + error("needs a buffer") + end, + go = function() + return { "gofumpt" } + end, + }, + } +end + +package.preload["mason-registry"] = function() + local registry = {} + function registry.refresh(cb) + if cb then + cb(true, {}) + end + end + function registry.get_package(name) + if name == "not-in-registry" then + error(('Cannot find package "%q".'):format(name)) + end + local pkg = { name = name } + function pkg:is_installed() + return installed[name] or false + end + function pkg:is_installing() + return installing[name] or false + end + function pkg:install(opts) + install_calls[#install_calls + 1] = { name = name, version = opts and opts.version } + local handle = {} + function handle:once(event, cb2) + assert(event == "closed") + installed[name] = true + cb2() + end + return handle + end + return pkg + end + return registry +end + +package.preload["mason-core.package"] = function() + local Package = {} + function Package.Parse(identifier) + local name, version = identifier:match("^([^@]+)@?(.*)$") + if version == "" then + version = nil + end + return name, version + end + return Package +end + +package.preload["mason.version"] = function() + return { MAJOR_VERSION = 2, MINOR_VERSION = 3 } +end + +local original_notify = vim.notify +vim.notify = function(msg, level) + notifications[#notifications + 1] = { msg = msg, level = level or vim.log.levels.INFO } +end + +-- ---- tests ------------------------------------------------------------- + +-- setup() with defaults +local mc = require("mason-conform") +mc.setup() +vim.wait(200) +check(vim.deep_equal(mc.config, { ignore_install = {} }), "setup() without opts keeps defaults") + +local function installed_names() + local names = {} + for _, call in ipairs(install_calls) do + names[#names + 1] = call.name + end + table.sort(names) + return names +end + +check( + vim.deep_equal(installed_names(), { "autopep8", "cbfmt", "gofumpt", "prettier", "prettierd", "stylua" }), + "installs mapped formatters from every formatters_by_ft shape; skips broken function: " + .. table.concat(installed_names(), ",") +) + +-- setup() again with ignore_install +install_calls = {} +notifications = {} +installed = {} +mc.setup({ ignore_install = { "prettierd" } }) +vim.wait(200) +check( + vim.deep_equal(installed_names(), { "autopep8", "cbfmt", "gofumpt", "prettier", "stylua" }), + "ignore_install is respected: " .. table.concat(installed_names(), ",") +) +check(vim.deep_equal(mc.config.ignore_install, { "prettierd" }), "ignore_install stored") + +-- installed / installing packages are skipped +install_calls = {} +installed = {} +installing = {} +installed["stylua"] = true +installing["autopep8"] = true +mc.setup({ ignore_install = {} }) +vim.wait(200) +check( + vim.deep_equal(installed_names(), { "cbfmt", "gofumpt", "prettier", "prettierd" }), + "already installed/installing packages are skipped: " .. table.concat(installed_names(), ",") +) + +-- unknown mason package -> warning, no error +local install = require("mason-conform.install") +notifications = {} +local ok = pcall(install.try_install, "not-in-registry") +check(ok, "try_install on unknown package does not raise") +check(#notifications == 1 and notifications[1].level == vim.log.levels.WARN, "unknown package emits a warning") +check( + notifications[1] and notifications[1].msg:find("not%-in%-registry", 1, false) ~= nil, + "warning names the formatter" +) + +-- version pinning passes through Package.Parse +installed = {} +install_calls = {} +install.try_install("stylua@1.2.0") +check(install_calls[1] and install_calls[1].version == "1.2.0", "version suffix forwarded to mason") + +-- setup() validates its argument +ok = pcall(mc.setup, "not a table") +check(not ok, "setup() rejects non-table opts") + +-- health check runs +local health_ok, err = pcall(function() + require("mason-conform.health").check() +end) +check(health_ok, "health check runs without error" .. (health_ok and "" or (": " .. tostring(err)))) + +-- formatters module +local formatters = require("mason-conform.formatters").configured() +check( + vim.deep_equal(formatters, { "autopep8", "cbfmt", "gofumpt", "prettier", "prettierd", "stylua" }), + "formatters.configured() returns sorted unique names" +) + +vim.notify = original_notify + +print(("-"):rep(60)) +if #failures > 0 then + print(("%d FAILURE(S)"):format(#failures)) + os.exit(1) +end +print("all tests passed")