plugin: formatter: Add plugin to format files

This commit is contained in:
taconi 2024-03-26 16:03:08 -03:00
parent 3903859970
commit 5e5f6b30e6
4 changed files with 695 additions and 0 deletions

View file

@ -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,

View file

@ -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.

View file

@ -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

View file

@ -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 <name>` where `<name>` 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 <name>
```
## 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 <name>`.
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/