editorconfig.lua 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. local M = {}
  2. --- @type table<string,fun(bufnr: integer, val: string, opts?: table)>
  3. M.properties = {}
  4. --- Modified version of the builtin assert that does not include error position information
  5. ---
  6. ---@param v any Condition
  7. ---@param message string Error message to display if condition is false or nil
  8. ---@return any v if not false or nil, otherwise an error is displayed
  9. ---
  10. ---@private
  11. local function assert(v, message)
  12. return v or error(message, 0)
  13. end
  14. --- Show a warning message
  15. ---
  16. ---@param msg string Message to show
  17. ---
  18. ---@private
  19. local function warn(msg, ...)
  20. vim.notify_once(string.format(msg, ...), vim.log.levels.WARN, {
  21. title = 'editorconfig',
  22. })
  23. end
  24. function M.properties.charset(bufnr, val)
  25. assert(
  26. vim.tbl_contains({ 'utf-8', 'utf-8-bom', 'latin1', 'utf-16be', 'utf-16le' }, val),
  27. 'charset must be one of "utf-8", "utf-8-bom", "latin1", "utf-16be", or "utf-16le"'
  28. )
  29. if val == 'utf-8' or val == 'utf-8-bom' then
  30. vim.bo[bufnr].fileencoding = 'utf-8'
  31. vim.bo[bufnr].bomb = val == 'utf-8-bom'
  32. elseif val == 'utf-16be' then
  33. vim.bo[bufnr].fileencoding = 'utf-16'
  34. else
  35. vim.bo[bufnr].fileencoding = val
  36. end
  37. end
  38. function M.properties.end_of_line(bufnr, val)
  39. vim.bo[bufnr].fileformat = assert(
  40. ({ lf = 'unix', crlf = 'dos', cr = 'mac' })[val],
  41. 'end_of_line must be one of "lf", "crlf", or "cr"'
  42. )
  43. end
  44. function M.properties.indent_style(bufnr, val, opts)
  45. assert(val == 'tab' or val == 'space', 'indent_style must be either "tab" or "space"')
  46. vim.bo[bufnr].expandtab = val == 'space'
  47. if val == 'tab' and not opts.indent_size then
  48. vim.bo[bufnr].shiftwidth = 0
  49. vim.bo[bufnr].softtabstop = 0
  50. end
  51. end
  52. function M.properties.indent_size(bufnr, val, opts)
  53. if val == 'tab' then
  54. vim.bo[bufnr].shiftwidth = 0
  55. vim.bo[bufnr].softtabstop = 0
  56. else
  57. local n = assert(tonumber(val), 'indent_size must be a number')
  58. vim.bo[bufnr].shiftwidth = n
  59. vim.bo[bufnr].softtabstop = -1
  60. if not opts.tab_width then
  61. vim.bo[bufnr].tabstop = n
  62. end
  63. end
  64. end
  65. function M.properties.tab_width(bufnr, val)
  66. vim.bo[bufnr].tabstop = assert(tonumber(val), 'tab_width must be a number')
  67. end
  68. function M.properties.max_line_length(bufnr, val)
  69. local n = tonumber(val)
  70. if n then
  71. vim.bo[bufnr].textwidth = n
  72. else
  73. assert(val == 'off', 'max_line_length must be a number or "off"')
  74. vim.bo[bufnr].textwidth = 0
  75. end
  76. end
  77. function M.properties.trim_trailing_whitespace(bufnr, val)
  78. assert(
  79. val == 'true' or val == 'false',
  80. 'trim_trailing_whitespace must be either "true" or "false"'
  81. )
  82. if val == 'true' then
  83. vim.api.nvim_create_autocmd('BufWritePre', {
  84. group = 'editorconfig',
  85. buffer = bufnr,
  86. callback = function()
  87. local view = vim.fn.winsaveview()
  88. vim.api.nvim_command('silent! undojoin')
  89. vim.api.nvim_command('silent keepjumps keeppatterns %s/\\s\\+$//e')
  90. vim.fn.winrestview(view)
  91. end,
  92. })
  93. else
  94. vim.api.nvim_clear_autocmds({
  95. event = 'BufWritePre',
  96. group = 'editorconfig',
  97. buffer = bufnr,
  98. })
  99. end
  100. end
  101. function M.properties.insert_final_newline(bufnr, val)
  102. assert(val == 'true' or val == 'false', 'insert_final_newline must be either "true" or "false"')
  103. vim.bo[bufnr].fixendofline = val == 'true'
  104. -- 'endofline' can be read to detect if the file contains a final newline,
  105. -- so only change 'endofline' right before writing the file
  106. local endofline = val == 'true'
  107. if vim.bo[bufnr].endofline ~= endofline then
  108. vim.api.nvim_create_autocmd('BufWritePre', {
  109. group = 'editorconfig',
  110. buffer = bufnr,
  111. once = true,
  112. callback = function()
  113. vim.bo[bufnr].endofline = endofline
  114. end,
  115. })
  116. end
  117. end
  118. --- Modified version of |glob2regpat()| that does not match path separators on *.
  119. ---
  120. --- This function replaces single instances of * with the regex pattern [^/]*. However, the star in
  121. --- the replacement pattern also gets interpreted by glob2regpat, so we insert a placeholder, pass
  122. --- it through glob2regpat, then replace the placeholder with the actual regex pattern.
  123. ---
  124. ---@param glob string Glob to convert into a regular expression
  125. ---@return string Regular expression
  126. ---
  127. ---@private
  128. local function glob2regpat(glob)
  129. local placeholder = '@@PLACEHOLDER@@'
  130. return (
  131. string.gsub(
  132. vim.fn.glob2regpat(
  133. vim.fn.substitute(
  134. string.gsub(glob, '{(%d+)%.%.(%d+)}', '[%1-%2]'),
  135. '\\*\\@<!\\*\\*\\@!',
  136. placeholder,
  137. 'g'
  138. )
  139. ),
  140. placeholder,
  141. '[^/]*'
  142. )
  143. )
  144. end
  145. --- Parse a single line in an EditorConfig file
  146. ---
  147. ---@param line string Line
  148. ---@return string|nil If the line contains a pattern, the glob pattern
  149. ---@return string|nil If the line contains a key-value pair, the key
  150. ---@return string|nil If the line contains a key-value pair, the value
  151. ---
  152. ---@private
  153. local function parse_line(line)
  154. if line:find('^%s*[^ #;]') then
  155. local glob = (line:match('%b[]') or ''):match('^%s*%[(.*)%]%s*$')
  156. if glob then
  157. return glob, nil, nil
  158. end
  159. local key, val = line:match('^%s*([^:= ][^:=]-)%s*[:=]%s*(.-)%s*$')
  160. if key ~= nil and val ~= nil then
  161. return nil, key:lower(), val:lower()
  162. end
  163. end
  164. end
  165. --- Parse options from an .editorconfig file
  166. ---
  167. ---@param filepath string File path of the file to apply EditorConfig settings to
  168. ---@param dir string Current directory
  169. ---@return table<string,string|boolean> Table of options to apply to the given file
  170. ---
  171. ---@private
  172. local function parse(filepath, dir)
  173. local pat --- @type vim.regex?
  174. local opts = {} --- @type table<string,string|boolean>
  175. local f = io.open(dir .. '/.editorconfig')
  176. if f then
  177. for line in f:lines() do
  178. local glob, key, val = parse_line(line)
  179. if glob then
  180. glob = glob:find('/') and (dir .. '/' .. glob:gsub('^/', '')) or ('**/' .. glob)
  181. local ok, regpat = pcall(glob2regpat, glob)
  182. if ok then
  183. pat = vim.regex(regpat)
  184. else
  185. pat = nil
  186. warn('editorconfig: Error occurred while parsing glob pattern "%s": %s', glob, regpat)
  187. end
  188. elseif key ~= nil and val ~= nil then
  189. if key == 'root' then
  190. assert(val == 'true' or val == 'false', 'root must be either "true" or "false"')
  191. opts.root = val == 'true'
  192. elseif pat and pat:match_str(filepath) then
  193. opts[key] = val
  194. end
  195. end
  196. end
  197. f:close()
  198. end
  199. return opts
  200. end
  201. --- Configure the given buffer with options from an .editorconfig file
  202. ---
  203. ---@param bufnr integer Buffer number to configure
  204. ---
  205. ---@private
  206. function M.config(bufnr)
  207. bufnr = bufnr or vim.api.nvim_get_current_buf()
  208. if not vim.api.nvim_buf_is_valid(bufnr) then
  209. return
  210. end
  211. local path = vim.fs.normalize(vim.api.nvim_buf_get_name(bufnr))
  212. if vim.bo[bufnr].buftype ~= '' or not vim.bo[bufnr].modifiable or path == '' then
  213. return
  214. end
  215. local opts = {} --- @type table<string,string|boolean>
  216. for parent in vim.fs.parents(path) do
  217. for k, v in pairs(parse(path, parent)) do
  218. if opts[k] == nil then
  219. opts[k] = v
  220. end
  221. end
  222. if opts.root then
  223. break
  224. end
  225. end
  226. local applied = {} --- @type table<string,string|boolean>
  227. for opt, val in pairs(opts) do
  228. if val ~= 'unset' then
  229. local func = M.properties[opt]
  230. if func then
  231. local ok, err = pcall(func, bufnr, val, opts)
  232. if ok then
  233. applied[opt] = val
  234. else
  235. warn('editorconfig: invalid value for option %s: %s. %s', opt, val, err)
  236. end
  237. end
  238. end
  239. end
  240. vim.b[bufnr].editorconfig = applied
  241. end
  242. return M