Merge pull request #21633 from gpanders/editorconfig

Builtin EditorConfig support
This commit is contained in:
Gregory Anders 2023-01-03 11:38:20 -07:00 committed by GitHub
commit d56c603caf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 568 additions and 2 deletions

View File

@ -0,0 +1,89 @@
*editorconfig.txt* Nvim
NVIM REFERENCE MANUAL
EditorConfig integration *editorconfig*
Nvim natively supports EditorConfig. When a file is opened, Nvim searches
upward through all of the parent directories of that file looking for
".editorconfig" files. Each of these is parsed and any properties that match
the opened file are applied.
For more information on EditorConfig, see https://editorconfig.org/.
*g:editorconfig_enable*
EditorConfig integration can be disabled by adding >lua
vim.g.editorconfig_enable = false
<
to the user's |init.lua| file (or the Vimscript equivalent to |init.vim|).
*b:editorconfig*
When Nvim finds a valid .editorconfig file it will store the applied
properties in the buffer variable |b:editorconfig|.
*editorconfig-properties*
The following properties are supported by default:
*editorconfig_root*
root If "true", then stop searching for .editorconfig files
in parent directories. This property must be at the
top-level of the .editorconfig file (i.e. it must not
be within a glob section).
*editorconfig_charset*
charset One of "utf-8", "utf-8-bom", "latin1", "utf-16be", or
"utf-16le". Sets the 'fileencoding' and 'bomb'
options.
*editorconfig_end_of_line*
end_of_line One of "lf", "crlf", or "cr". These correspond to
setting 'fileformat' to "unix", "dos", or "mac",
respectively.
*editorconfig_indent_style*
indent_style One of "tab" or "space". Sets the 'expandtab' option.
*editorconfig_indent_size*
indent_size A number indicating the size of a single indent.
Alternatively, use the value "tab" to use the value of
the tab_width property. Sets the 'shiftwidth' and
'softtabstop'.
*editorconfig_insert_final_newline*
insert_final_newline "true" or "false" to ensure the file always has a
trailing newline as its last byte. Sets the
'fixendofline' and 'endofline' options.
*editorconfig_max_line_length*
max_line_length A number indicating the maximum length of a single
line. Sets the 'textwidth' option.
*editorconfig_tab_width*
tab_width The display size of a single tab character. Sets the
'tabstop' option.
*editorconfig_trim_trailing_whitespace*
trim_trailing_whitespace
When "true", trailing whitespace is automatically
removed when the buffer is written.
*editorconfig-custom-properties*
New properties can be added by adding a new entry to the "properties" table.
The table key is a property name and the value is a callback function which
accepts the number of the buffer to be modified, the value of the property
in the .editorconfig file, and (optionally) a table containing all of the
other properties and their values (useful for properties which depend on other
properties). The value is always a string and must be coerced if necessary.
Example: >lua
require('editorconfig').properties.foo = function(bufnr, val, opts)
if opts.charset and opts.charset ~= "utf-8" then
error("foo can only be set when charset is utf-8", 0)
end
vim.b[bufnr].foo = val
end
<
vim:tw=78:ts=8:noet:ft=help:norl:

View File

@ -50,6 +50,13 @@ NEW FEATURES *news-features*
The following new APIs or features were added.
• EditorConfig support is now builtin. This is enabled by default and happens
automatically. To disable it, users should add >lua
vim.g.editorconfig_enable = false
<
(or the Vimscript equivalent) to their |config| file.
• Added a |vim.lsp.codelens.clear()| function to clear codelenses.
• |vim.inspect_pos()|, |vim.show_pos()| and |:Inspect| allow a user to get or show items

View File

@ -0,0 +1,246 @@
local M = {}
M.properties = {}
--- Modified version of the builtin assert that does not include error position information
---
---@param v any Condition
---@param message string Error message to display if condition is false or nil
---@return any v if not false or nil, otherwise an error is displayed
---
---@private
local function assert(v, message)
return v or error(message, 0)
end
--- Show a warning message
---
---@param msg string Message to show
---
---@private
local function warn(msg, ...)
vim.notify(string.format(msg, ...), vim.log.levels.WARN, {
title = 'editorconfig',
})
end
function M.properties.charset(bufnr, val)
assert(
vim.tbl_contains({ 'utf-8', 'utf-8-bom', 'latin1', 'utf-16be', 'utf-16le' }, val),
'charset must be one of "utf-8", "utf-8-bom", "latin1", "utf-16be", or "utf-16le"'
)
if val == 'utf-8' or val == 'utf-8-bom' then
vim.bo[bufnr].fileencoding = 'utf-8'
vim.bo[bufnr].bomb = val == 'utf-8-bom'
elseif val == 'utf-16be' then
vim.bo[bufnr].fileencoding = 'utf-16'
else
vim.bo[bufnr].fileencoding = val
end
end
function M.properties.end_of_line(bufnr, val)
vim.bo[bufnr].fileformat = assert(
({ lf = 'unix', crlf = 'dos', cr = 'mac' })[val],
'end_of_line must be one of "lf", "crlf", or "cr"'
)
end
function M.properties.indent_style(bufnr, val, opts)
assert(val == 'tab' or val == 'space', 'indent_style must be either "tab" or "space"')
vim.bo[bufnr].expandtab = val == 'space'
if val == 'tab' and not opts.indent_size then
vim.bo[bufnr].shiftwidth = 0
vim.bo[bufnr].softtabstop = 0
end
end
function M.properties.indent_size(bufnr, val, opts)
if val == 'tab' then
vim.bo[bufnr].shiftwidth = 0
vim.bo[bufnr].softtabstop = 0
else
local n = assert(tonumber(val), 'indent_size must be a number')
vim.bo[bufnr].shiftwidth = n
vim.bo[bufnr].softtabstop = -1
if not opts.tab_width then
vim.bo[bufnr].tabstop = n
end
end
end
function M.properties.tab_width(bufnr, val)
vim.bo[bufnr].tabstop = assert(tonumber(val), 'tab_width must be a number')
end
function M.properties.max_line_length(bufnr, val)
local n = tonumber(val)
if n then
vim.bo[bufnr].textwidth = n
else
assert(val == 'off', 'max_line_length must be a number or "off"')
vim.bo[bufnr].textwidth = 0
end
end
function M.properties.trim_trailing_whitespace(bufnr, val)
assert(
val == 'true' or val == 'false',
'trim_trailing_whitespace must be either "true" or "false"'
)
if val == 'true' then
vim.api.nvim_create_autocmd('BufWritePre', {
group = 'editorconfig',
buffer = bufnr,
callback = function()
local view = vim.fn.winsaveview()
vim.api.nvim_command('silent! undojoin')
vim.api.nvim_command('silent keepjumps keeppatterns %s/\\s\\+$//e')
vim.fn.winrestview(view)
end,
})
else
vim.api.nvim_clear_autocmds({
event = 'BufWritePre',
group = 'editorconfig',
buffer = bufnr,
})
end
end
function M.properties.insert_final_newline(bufnr, val)
assert(val == 'true' or val == 'false', 'insert_final_newline must be either "true" or "false"')
vim.bo[bufnr].fixendofline = val == 'true'
vim.bo[bufnr].endofline = val == 'true'
end
--- Modified version of |glob2regpat()| that does not match path separators on *.
---
--- This function replaces single instances of * with the regex pattern [^/]*. However, the star in
--- the replacement pattern also gets interpreted by glob2regpat, so we insert a placeholder, pass
--- it through glob2regpat, then replace the placeholder with the actual regex pattern.
---
---@param glob string Glob to convert into a regular expression
---@return string Regular expression
---
---@private
local function glob2regpat(glob)
local placeholder = '@@PLACEHOLDER@@'
return (
string.gsub(
vim.fn.glob2regpat(
vim.fn.substitute(
string.gsub(glob, '{(%d+)%.%.(%d+)}', '[%1-%2]'),
'\\*\\@<!\\*\\*\\@!',
placeholder,
'g'
)
),
placeholder,
'[^/]*'
)
)
end
--- Parse a single line in an EditorConfig file
---
---@param line string Line
---@return string|nil If the line contains a pattern, the glob pattern
---@return string|nil If the line contains a key-value pair, the key
---@return string|nil If the line contains a key-value pair, the value
---
---@private
local function parse_line(line)
if line:find('^%s*[^ #;]') then
local glob = (line:match('%b[]') or ''):match('^%s*%[(.*)%]%s*$')
if glob then
return glob, nil, nil
end
local key, val = line:match('^%s*([^:= ][^:=]-)%s*[:=]%s*(.-)%s*$')
if key ~= nil and val ~= nil then
return nil, key:lower(), val:lower()
end
end
end
--- Parse options from an .editorconfig file
---
---@param filepath string File path of the file to apply EditorConfig settings to
---@param dir string Current directory
---@return table Table of options to apply to the given file
---
---@private
local function parse(filepath, dir)
local pat = nil
local opts = {}
local f = io.open(dir .. '/.editorconfig')
if f then
for line in f:lines() do
local glob, key, val = parse_line(line)
if glob then
glob = glob:find('/') and (dir .. '/' .. glob:gsub('^/', '')) or ('**/' .. glob)
local ok, regpat = pcall(glob2regpat, glob)
if ok then
pat = vim.regex(regpat)
else
pat = nil
warn('editorconfig: Error occurred while parsing glob pattern "%s": %s', glob, regpat)
end
elseif key ~= nil and val ~= nil then
if key == 'root' then
opts.root = val == 'true'
elseif pat and pat:match_str(filepath) then
opts[key] = val
end
end
end
f:close()
end
return opts
end
--- Configure the given buffer with options from an .editorconfig file
---
---@param bufnr number Buffer number to configure
---
---@private
function M.config(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
local path = vim.fs.normalize(vim.api.nvim_buf_get_name(bufnr))
if vim.bo[bufnr].buftype ~= '' or not vim.bo[bufnr].modifiable or path == '' then
return
end
local opts = {}
for parent in vim.fs.parents(path) do
for k, v in pairs(parse(path, parent)) do
if opts[k] == nil then
opts[k] = v
end
end
if opts.root then
break
end
end
local applied = {}
for opt, val in pairs(opts) do
if val ~= 'unset' then
local func = M.properties[opt]
if func then
local ok, err = pcall(func, bufnr, val, opts)
if ok then
applied[opt] = val
else
warn('editorconfig: invalid value for option %s: %s. %s', opt, val, err)
end
end
end
end
vim.b[bufnr].editorconfig = applied
end
return M

View File

@ -1378,13 +1378,13 @@ local filename = {
npmrc = 'dosini',
['/etc/yum.conf'] = 'dosini',
['.npmrc'] = 'dosini',
['.editorconfig'] = 'dosini',
['/etc/pacman.conf'] = 'confini',
['mpv.conf'] = 'confini',
dune = 'dune',
jbuild = 'dune',
['dune-workspace'] = 'dune',
['dune-project'] = 'dune',
['.editorconfig'] = 'editorconfig',
['elinks.conf'] = 'elinks',
['mix.lock'] = 'elixir',
['filter-rules'] = 'elmfilt',

View File

@ -0,0 +1,11 @@
if vim.g.editorconfig_enable == false or vim.g.editorconfig_enable == 0 then
return
end
local group = vim.api.nvim_create_augroup('editorconfig', {})
vim.api.nvim_create_autocmd({ 'BufNewFile', 'BufRead', 'BufFilePost' }, {
group = group,
callback = function(args)
require('editorconfig').config(args.buf)
end,
})

View File

@ -0,0 +1,18 @@
runtime! syntax/dosini.vim
unlet! b:current_syntax
syntax match editorconfigInvalidProperty "^\s*\zs\w\+\ze\s*="
syntax keyword editorconfigProperty root
lua<<
local props = {}
for k in pairs(require('editorconfig').properties) do
props[#props + 1] = k
end
vim.cmd(string.format('syntax keyword editorconfigProperty %s', table.concat(props, ' ')))
.
hi def link editorconfigInvalidProperty Error
hi def link editorconfigProperty dosiniLabel
let b:current_syntax = 'editorconfig'

View File

@ -162,7 +162,7 @@ let s:filename_checks = {
\ 'dnsmasq': ['/etc/dnsmasq.conf', '/etc/dnsmasq.d/file', 'any/etc/dnsmasq.conf', 'any/etc/dnsmasq.d/file'],
\ 'dockerfile': ['Containerfile', 'Dockerfile', 'dockerfile', 'file.Dockerfile', 'file.dockerfile', 'Dockerfile.debian', 'Containerfile.something'],
\ 'dosbatch': ['file.bat'],
\ 'dosini': ['.editorconfig', '/etc/yum.conf', 'file.ini', 'npmrc', '.npmrc', 'php.ini', 'php.ini-5', 'php.ini-file', '/etc/yum.repos.d/file', 'any/etc/yum.conf', 'any/etc/yum.repos.d/file', 'file.wrap'],
\ 'dosini': ['/etc/yum.conf', 'file.ini', 'npmrc', '.npmrc', 'php.ini', 'php.ini-5', 'php.ini-file', '/etc/yum.repos.d/file', 'any/etc/yum.conf', 'any/etc/yum.repos.d/file', 'file.wrap'],
\ 'dot': ['file.dot', 'file.gv'],
\ 'dracula': ['file.drac', 'file.drc', 'filelvs', 'filelpe', 'drac.file', 'lpe', 'lvs', 'some-lpe', 'some-lvs'],
\ 'dtd': ['file.dtd'],
@ -174,6 +174,7 @@ let s:filename_checks = {
\ 'dylanlid': ['file.lid'],
\ 'ecd': ['file.ecd'],
\ 'edif': ['file.edf', 'file.edif', 'file.edo'],
\ 'editorconfig': ['.editorconfig'],
\ 'eelixir': ['file.eex', 'file.leex'],
\ 'elinks': ['elinks.conf'],
\ 'elixir': ['file.ex', 'file.exs', 'mix.lock'],

View File

@ -0,0 +1,194 @@
local helpers = require('test.functional.helpers')(after_each)
local clear = helpers.clear
local command = helpers.command
local eq = helpers.eq
local pathsep = helpers.get_pathsep()
local curbufmeths = helpers.curbufmeths
local testdir = 'Xtest-editorconfig'
local function test_case(name, expected)
local filename = testdir .. pathsep .. name
command('edit ' .. filename)
for opt, val in pairs(expected) do
eq(val, curbufmeths.get_option(opt), name)
end
end
setup(function()
helpers.mkdir_p(testdir)
helpers.write_file(
testdir .. pathsep .. '.editorconfig',
[[
root = true
[3_space.txt]
indent_style = space
indent_size = 3
tab_width = 3
[4_space.py]
indent_style = space
indent_size = 4
tab_width = 8
[space.txt]
indent_style = space
indent_size = tab
[tab.txt]
indent_style = tab
[4_tab.txt]
indent_style = tab
indent_size = 4
tab_width = 4
[4_tab_width_of_8.txt]
indent_style = tab
indent_size = 4
tab_width = 8
[lf.txt]
end_of_line = lf
[crlf.txt]
end_of_line = crlf
[cr.txt]
end_of_line = cr
[utf-8.txt]
charset = utf-8
[utf-8-bom.txt]
charset = utf-8-bom
[utf-16be.txt]
charset = utf-16be
[utf-16le.txt]
charset = utf-16le
[latin1.txt]
charset = latin1
[with_newline.txt]
insert_final_newline = true
[without_newline.txt]
insert_final_newline = false
[trim.txt]
trim_trailing_whitespace = true
[no_trim.txt]
trim_trailing_whitespace = false
[max_line_length.txt]
max_line_length = 42
]]
)
end)
teardown(function()
helpers.rmdir(testdir)
end)
describe('editorconfig', function()
before_each(function()
-- Remove -u NONE so that plugins (i.e. editorconfig.lua) are loaded
clear({ args_rm = { '-u' } })
end)
it('sets indent options', function()
test_case('3_space.txt', {
expandtab = true,
shiftwidth = 3,
softtabstop = -1,
tabstop = 3,
})
test_case('4_space.py', {
expandtab = true,
shiftwidth = 4,
softtabstop = -1,
tabstop = 8,
})
test_case('space.txt', {
expandtab = true,
shiftwidth = 0,
softtabstop = 0,
})
test_case('tab.txt', {
expandtab = false,
shiftwidth = 0,
softtabstop = 0,
})
test_case('4_tab.txt', {
expandtab = false,
shiftwidth = 4,
softtabstop = -1,
tabstop = 4,
})
test_case('4_tab_width_of_8.txt', {
expandtab = false,
shiftwidth = 4,
softtabstop = -1,
tabstop = 8,
})
end)
it('sets end-of-line options', function()
test_case('lf.txt', { fileformat = 'unix' })
test_case('crlf.txt', { fileformat = 'dos' })
test_case('cr.txt', { fileformat = 'mac' })
end)
it('sets encoding options', function()
test_case('utf-8.txt', { fileencoding = 'utf-8', bomb = false })
test_case('utf-8-bom.txt', { fileencoding = 'utf-8', bomb = true })
test_case('utf-16be.txt', { fileencoding = 'utf-16', bomb = false })
test_case('utf-16le.txt', { fileencoding = 'utf-16le', bomb = false })
test_case('latin1.txt', { fileencoding = 'latin1', bomb = false })
end)
it('sets newline options', function()
test_case('with_newline.txt', { fixendofline = true, endofline = true })
test_case('without_newline.txt', { fixendofline = false, endofline = false })
end)
it('respects trim_trailing_whitespace', function()
local filename = testdir .. pathsep .. 'trim.txt'
-- luacheck: push ignore 613
local untrimmed = [[
This line ends in whitespace
So does this one
And this one
But not this one
]]
-- luacheck: pop
local trimmed = untrimmed:gsub('%s+\n', '\n')
helpers.write_file(filename, untrimmed)
command('edit ' .. filename)
command('write')
command('bdelete')
eq(trimmed, helpers.read_file(filename))
filename = testdir .. pathsep .. 'no_trim.txt'
helpers.write_file(filename, untrimmed)
command('edit ' .. filename)
command('write')
command('bdelete')
eq(untrimmed, helpers.read_file(filename))
end)
it('sets textwidth', function()
test_case('max_line_length.txt', { textwidth = 42 })
end)
end)