plugin: formatter: Add plugin to format files
This commit is contained in:
parent
3903859970
commit
5e5f6b30e6
4 changed files with 695 additions and 0 deletions
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
|
|
306
runtime/plugins/formatter/formatter.lua
Normal file
306
runtime/plugins/formatter/formatter.lua
Normal 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
|
386
runtime/plugins/formatter/help/formatter.md
Normal file
386
runtime/plugins/formatter/help/formatter.md
Normal 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/
|
Loading…
Reference in a new issue