diff --git a/runtime/help/options.md b/runtime/help/options.md index 32375833b3..1300d9f80d 100644 --- a/runtime/help/options.md +++ b/runtime/help/options.md @@ -479,6 +479,7 @@ or disable them: * `diff`: integrates the `diffgutter` option with Git. If you are in a Git directory, the diff gutter will show changes with respect to the most recent Git commit rather than the diff since opening the file. +* `formatter`: provides extensible formatting for many languages. Any option you set in the editor will be saved to the file ~/.config/micro/settings.json so, in effect, your configuration file will be @@ -515,6 +516,7 @@ so that you can see what the formatting should look like. "fastdirty": false, "fileformat": "unix", "filetype": "unknown", + "formatter": true, "incsearch": true, "ftoptions": true, "ignorecase": true, diff --git a/runtime/help/plugins.md b/runtime/help/plugins.md index 746d2f4135..598b725793 100644 --- a/runtime/help/plugins.md +++ b/runtime/help/plugins.md @@ -424,6 +424,7 @@ There are 6 default plugins that come pre-installed with micro. These are * `diff`: integrates the `diffgutter` option with Git. If you are in a Git directory, the diff gutter will show changes with respect to the most recent Git commit rather than the diff since opening the file. +* `formatter`: provides extensible formatting for many languages. See `> help linter`, `> help comment`, and `> help status` for additional documentation specific to those plugins. diff --git a/runtime/plugins/formatter/formatter.lua b/runtime/plugins/formatter/formatter.lua new file mode 100644 index 0000000000..3ad50532ed --- /dev/null +++ b/runtime/plugins/formatter/formatter.lua @@ -0,0 +1,306 @@ +VERSION = '1.0.0' + +local micro = import('micro') +local config = import('micro/config') +local shell = import('micro/shell') + +local errors = import('errors') +local fmt = import('fmt') +local regexp = import('regexp') +local runtime = import('runtime') +local strings = import('strings') + +---@alias Error { Error: fun(): string } # Goland error + +---@class Buffer +---@field Path string +---@field AbsPath string +---@field FileType fun(): string +---@field ReOpen fun() + +---@class BufPane userdata # Micro BufPane +---@field Buf Buffer + +-- luacheck: globals toString +---@param value any +local function toString(value) + if type(value) == 'string' then + return value + elseif type(value) == 'table' then + return strings.Join(value, ' ') + end + return value +end + +-- luacheck: globals contains +---@param t table # Table to check. +---@param e any # Element to verify. +---@return boolean # If contains or not +local function contains(t, e) + for i = 1, #t do + if t[i] == e then + return true + end + end + return false +end + +-- luacheck: globals Format +---@class Format +---@field cmd string +---@field args string|string[] +---@field name string +---@field bind string +---@field onSave boolean +---@field filetypes string[] +---@field os string[] +---@field whitelist boolean +---@field domatch boolean +---@field callback fun(buf: Buffer): boolean +local Format = {} + +---create a valid formatter +---@param input table +---@return Format?, Error? +---@nodiscard +function Format:new(input) + ---@type Format + local f = {} + + if input.cmd == nil or type(input.cmd) ~= 'string' then + return input, errors.New('Invalid "cmd"') + elseif input.filetypes == nil or type(input.filetypes) ~= 'table' then + return input, errors.New('Invalid "filetypes"') + end + + f.cmd = input.cmd + f.filetypes = input.filetypes + + if not input.name then + ---@type string[] + local cmds = strings.Split(input.cmd, ' ') + f.name = fmt.Sprintf('%s', cmds[1]) + else + f.name = input.name + end + + f.bind = input.bind + f.args = toString(input.args) or '' + f.onSave = input.onSave + f.os = input.os + f.whitelist = input.whitelist or false + f.domatch = input.domatch or false + f.callback = input.callback + + self.__index = self + return setmetatable(f, self), nil +end + +---@return boolean +function Format:hasOS() + if self.os == nil then + return true + end + local has_os = contains(self.os, runtime.GOOS) + if (not has_os and self.whitelist) or (has_os and not self.whitelist) then + return false + end + + return true +end + +---@param buf Buffer +---@param filter fun(f: Format): boolean +---@return boolean +function Format:hasFormat(buf, filter) + if filter ~= nil and not filter(self) then + return false + end + + ---@type string + local filetype = buf:FileType() + ---@type string[] + local filetypes = self.filetypes + + for _, ft in ipairs(filetypes) do + if self.domatch then + if regexp.MatchString(ft, buf.AbsPath) then + return true + end + elseif ft == filetype then + return true + end + end + return false +end + +---@param buf Buffer +---@return boolean +function Format:hasCallback(buf) + if self.callback ~= nil and type(self.callback) == 'function' and not self.callback(buf) then + return false + end + return true +end + +---run a formatter on a given file +---@param buf Buffer +---@return Error? +function Format:run(buf) + ---@type string + local args = self.args:gsub('%%f', buf.Path) + ---@type string + local cmd = fmt.Sprintf('%s %s', self.cmd:gsub('%%f', buf.Path), args) + -- err: Error? + local _, err = shell.RunCommand(cmd) + + ---@type string + if err ~= nil then + return err + end +end + +---@type Format[] +-- luacheck: globals formatters +local formatters = {} + +-- luacheck: globals format +---format a bufpane +---@param bp BufPane +---@param args strign[] +---@param filter? fun(f: Format): boolean +---@return Error? +local function format(bp, args, filter) + if #formatters < 1 then + return + end + + local name = nil + if #args >= 1 then + name = args[1] + end + + ---@type string + local errs = '' + for _, f in ipairs(formatters) do + ---@cast filter fun(f: Format): boolean + if (name == nil or name == f.name) and f:hasFormat(bp.Buf, filter) and f:hasOS() and f:hasCallback(bp.Buf) then + local err = f:run(bp.Buf) + if err ~= nil then + errs = fmt.Sprintf('%s | %s', errs, f.name) + end + end + end + + bp.Buf:ReOpen() + + if errs ~= '' then + return micro.InfoBar():Error('💥 Error when using formatters: %s', errs) + else + micro.InfoBar():Message(fmt.Sprintf('🎬 File formatted successfully! %s ✨ 🍰 ✨', bp.Buf.Path)) + end +end + +---@param buf Buffer +---@return (string[], string[]) +local function formatComplete(buf) + local completions, suggestions = {}, {} + + ---@type string + local input = buf:GetArg() + + ---@type BufPane + local bp = micro.CurPane() + + for _, f in ipairs(formatters) do + -- i: integer + -- j: integer + local i, j = f.name:find(input, 1, true) + if i == 1 and f:hasFormat(bp.Buf) and f:hasOS() and f:hasCallback(bp.Buf) then + table.insert(suggestions, f.name) + table.insert(completions, f.name:sub(j + 1)) + end + end + + table.sort(completions) + table.sort(suggestions) + + return completions, suggestions +end + +-- luacheck: globals makeFormatter +---make a formatter +---@param cmd string +---@param filetypes string[] +---@param args string|string[] +---@param name string +---@param bind string +---@param onSave boolean +---@param os string[] +---@param whitelist boolean +---@param domatch boolean +---@param callback fun(buf: Buffer): boolean +---@return Error? +function makeFormatter(cmd, filetypes, args, name, bind, onSave, os, whitelist, domatch, callback) + -- f: Format + -- err: Error? + local f, err = Format:new({ + cmd = cmd, + filetypes = filetypes, + args = args, + name = name, + bind = bind, + onSave = onSave, + os = os, + whitelist = whitelist, + domatch = domatch, + callback = callback, + }) + if err ~= nil then + return err + end + table.insert(formatters, f) + + if f.bind then + config.TryBindKey(f.bind, 'command:format ' .. f.name, true) + end +end + +-- luacheck: globals setup +---initialize formatters +---@param formats Format[] +function setup(formats) + ---@type string + for _, f in ipairs(formats) do + ---@type Error? + makeFormatter(f.cmd, f.filetypes, f.args, f.name, f.bind, f.onSave, f.os, f.whitelist, f.domatch, f.callback) + end +end + +-- CALLBACK'S + +---runs formatters set to onSave +---@param bp BufPane +function onSave(bp) + if #formatters < 1 then + return true + end + + ---@type Error? + local err = format(bp, {}, function(f) + return f.onSave == true + end) + + if err ~= nil then + micro.InfoBar():Error(fmt.Sprintf('%v', err)) + else + micro.InfoBar():Message(fmt.Sprintf('🎬 Saved! %s ✨ 🍰 ✨', bp.Buf.Path)) + end + return true +end + +function init() + config.AddRuntimeFile('formatter', config.RTHelp, 'help/formatter.md') + config.MakeCommand('format', format, formatComplete) + config.TryBindKey('Alt-f', 'command:format', false) +end diff --git a/runtime/plugins/formatter/help/formatter.md b/runtime/plugins/formatter/help/formatter.md new file mode 100644 index 0000000000..aa022d3a88 --- /dev/null +++ b/runtime/plugins/formatter/help/formatter.md @@ -0,0 +1,386 @@ +# Formatter + +This plugin provides a way for the user to configure formatters for their codes. +These formatters can be defined by the `filetype`, by a `regex` in the `filepath`, +or by the `operating system`. +These formatters can be run when `saving the file`, by a `keybinding`, or by the +command `> format ` where `` is the name of the formatter + +Formatter settings can be for any type of file (javascript, go, python), but you +need to have the cli's you want to use as formatter installed. +For example, if you want to configure a formatter for python files and decide to +use [blue], you would need to have it installed on your machine. + +Here is an example with [blue]: + +```lua +function init() + formatter.setup({ + { cmd = 'blue %f', filetypes = { 'python' } }, + }) +end +``` + +## formatter.setup(formatters) + +The `formatter.setup` function is used to register your formatters, it receives +a table containing the information for each formatter. +And just like in the example above, you must call this function in the `init()` +callback of the `initlua` plugin. + +For more details about `initlua`, run `> help tutorial` and see the `Configuration with Lua` topic. +See `> help plugins` to learn more about lua plugins. + +## `> format` + +The `> format` command is available to format the file with all possible formatters. + +You can use the `Alt-f` key shortcut for this. + +You can run a single formatter using its `name` as an argument to the `format` command. + +```sh +> format +``` + +## Formatter + +### cmd + +`type`: **string**, `required` + +--- + +`cmd` is required and must be a string. + +Its value will be the executed command. + +```lua +function init() + formatter.setup({ + { + cmd = 'goimports -w %f', + filetypes = { 'go' }, + }, + }) +end +``` + +The symbol `%f` will be replaced by the file name at run time, +the same behavior will be applied to `args`. + +### filetypes + +`type`: **string[]**, `required` + +--- + +`filetypes` is required and must be a table of string. + +These are the types of files on which the formatter will be executed. + +```lua +function init() + formatter.setup({ + { + cmd = 'clang-format -i %f', + filetypes = { 'c', 'c++', 'csharp' }, + }, + }) +end +``` + +You can write patterns to use instead of exact types. +Just set the [`domatch`](#domatch) field to `true`, from then on every string +within `filetypes` will be a pattern. A [golang regular expression] to be more +specific. + +```lua +function init() + formatter.setup({ + { + cmd = 'raco fmt -i %f', + filetypes = { '\\.rkt$' }, + domatch = true, + }, + }) +end +``` + +### args + +`type` **string|string[]**, `default`: **''** + +--- + +List which arguments will be passed to `cmd` when the formatter is run. + +```lua +function init() + formatter.setup({ + { + cmd = 'rustfmt', + args = '+nightly %f', + filetypes = { 'rust' }, + }, + { + cmd = 'stylua %f', + args = { + '--column-width=120', + '--quote-style=ForceSingle', + '--line-endings=Unix', + '--indent-type=Spaces', + '--call-parentheses=Always', + '--indent-width=2', + }, + filetypes = { 'lua' }, + }, + }) +end +``` + +In the example above, both the rustfmt args and the stylua args are valid. + +The `args` is optional. +You can pass the arguments inside `cmd`. + +```lua +function init() + formatter.setup({ + { + cmd = 'zig fmt %f', + filetypes = { 'zig' }, + }, + }) +end +``` + +We can also write a formatter configuration file: + +```toml +# ~/.config/micro/stylua.toml + +column_width = 120 +line_endings = 'Unix' +indent_type = 'Spaces' +indent_width = 2 +quote_style = 'ForceSingle' +call_parentheses = 'Always' +collapse_simple_statement = 'Never' + +[sort_requires] +enabled = true +``` + +And link it in `init.lua`: + +```lua +local config = import('micro/config') + +local filepath = import('path/filepath') + +function init() + formatter.setup({ + { + cmd = 'stylua', + args = { '%f', '--config', filepath.Join(config.ConfigDir, 'stylua.toml') }, + filetypes = { 'lua' }, + }, + }) +end +``` + +We are using lua/go, so let your imagination go to the lua. + +### name + +`type`: **string?**, `optional` + +--- + +Define the name of the command to be executed in the command bar `(Ctrl-e)` with `> format `. + +If no `name` is specified, the first `cmd` string is used to define a name for the formatter. + +```lua +function init() + formatter.setup({ + { + cmd = 'python -m json.tool', + args = '--sort-keys --no-ensure-ascii --indent 4 %f %f', + filetypes = { 'json' }, + }, + }) +end +``` + +In the example above, the name of the formatter will be `python` and you use this +name to format it using the command `> format python` in the json files. + +If you want to define another name for the formatter, use the `name` field for that. + +```lua +function init() + formatter.setup({ + { + name = 'json-fmt', + cmd = 'python -m json.tool', + args = '--sort-keys --no-ensure-ascii --indent 4 %f %f', + filetypes = { 'json' }, + }, + }) +end +``` + +So we have the same command but it must be called by `> format json-fmt`. + +### bind + +`type`: **string**, `optional` + +--- + +Creates a keybinding for the formatter. + +```lua +function init() + formatter.setup({ + { + cmd = 'crystal tool format %f', + bind = 'Alt-l' + filetypes = { 'crystal' }, + }, + }) +end +``` + +So the `Alt-l` key shortcut will run this formatter. + +### onSave + +`type`: **boolean**, `default`: **false** + +--- + +If `true` the formatter will be executed when the file is saved. + +```lua +function init() + formatter.setup({ + { + cmd = 'gofmt -w %f', + onSave = true, + filetypes = { 'go' }, + }, + }) +end +``` + +### os + +`type`: **string[]**, `default`: **{}** + +--- + +It represents a list of operating systems on which this formatter is supported or not. + +```lua +function init() + formatter.setup({ + { + cmd = 'rubocop --fix-layout --safe --autocorrect %f', + filetypes = { 'ruby' }, + os = { 'linux' }, + }, + }) +end +``` + +What defines whether the `os` field is a list of compatible operating systems or +not is the [`whitelist`](#whitelist) field. + +```lua +function init() + formatter.setup({ + { + cmd = 'mix format %f', + filetypes = { 'elixir' }, + os = { 'windows' }, + whitelist = true, + }, + }) +end +``` + +Choices for `os`: + +- `android` +- `darwin` +- `dragonfly` +- `freebsd` +- `illumos` +- `ios` +- `js` +- `linux` +- `netbsd` +- `openbsd` +- `plan9` +- `solaris` +- `wasip1` +- `windows` + +### whitelist + +`type`: **boolean**, `default` **false** + +--- + +`whitelist` is of type boolean and by default its value is `false`. + +- If `true` all operating systems within the [`os`](#os) field are considered compatible + with the formatter. +- If `false` all operating systems within the [`os`](#os) field are considered not + compatible with the formatter. + +### domatch + +`type`: **boolean**, `default`: **false** + +--- + +`domatch` is of type boolean and by default its value is `false`. + +- If `true` the matches with the files will be done with the function + `regexp.MatchString(pattern, filename)` where `filename` would be the name of the + file and `pattern` would be the default defined in [`filetypes`](#filetypes). +- If `false` is an exact match with the file type. + +### callback + +`type`: **func(b Buffer): boolean**, `optional` + +--- + +Function to be called before executing the formatter, if it returns `false` the formatter +will be canceled. + +The type of `callback` would be `func(buf: Buffer): boolean` where `Buffer` would +be a [micro buffer]. + +```lua +function init() + formatter.setup({ + { + cmd = 'taplo fmt %f', + filetypes = { 'toml' }, + callback = function(buf) + return buf.Settings['foo'] == nil + end, + }, + }) +end +``` + +[micro buffer]: https://pkg.go.dev/github.com/zyedidia/micro/v2@v2.0.13/internal/buffer#Buffer +[blue]: https://blue.readthedocs.io +[golang regular expression]: https://zetcode.com/golang/regex/