editorconfig.lua (11695B)
1 --- @brief 2 --- EditorConfig is like 'modeline' for an entire (recursive) directory. When a file is opened, 3 --- after running |ftplugin|s and |FileType| autocommands, the EditorConfig feature searches all 4 --- parent directories of that file for `.editorconfig` files, parses them, and applies their 5 --- properties. For more information see https://editorconfig.org/. 6 --- 7 --- Example `.editorconfig` file: 8 --- ```ini 9 --- root = true 10 --- 11 --- [*] 12 --- charset = utf-8 13 --- end_of_line = lf 14 --- indent_size = 4 15 --- indent_style = space 16 --- max_line_length = 42 17 --- trim_trailing_whitespace = true 18 --- 19 --- [*.{diff,md}] 20 --- trim_trailing_whitespace = false 21 --- ``` 22 23 --- @brief [g:editorconfig]() [b:editorconfig]() 24 --- 25 --- EditorConfig is enabled by default. To disable it, add to your config: 26 --- ```lua 27 --- vim.g.editorconfig = false 28 --- ``` 29 --- 30 --- (Vimscript: `let g:editorconfig = v:false`). It can also be disabled 31 --- per-buffer by setting the [b:editorconfig] buffer-local variable to `false`. 32 --- 33 --- Nvim stores the applied properties in [b:editorconfig] if it is not `false`. 34 35 --- @brief [editorconfig-custom-properties]() 36 --- 37 --- New properties can be added by adding a new entry to the "properties" table. 38 --- The table key is a property name and the value is a callback function which 39 --- accepts the number of the buffer to be modified, the value of the property 40 --- in the `.editorconfig` file, and (optionally) a table containing all of the 41 --- other properties and their values (useful for properties which depend on other 42 --- properties). The value is always a string and must be coerced if necessary. 43 --- Example: 44 --- 45 --- ```lua 46 --- 47 --- require('editorconfig').properties.foo = function(bufnr, val, opts) 48 --- if opts.charset and opts.charset ~= "utf-8" then 49 --- error("foo can only be set when charset is utf-8", 0) 50 --- end 51 --- vim.b[bufnr].foo = val 52 --- end 53 --- 54 --- ``` 55 56 --- @brief [editorconfig-properties]() 57 --- 58 --- The following properties are supported by default: 59 60 --- @type table<string,fun(bufnr: integer, val: string, opts?: table)> 61 local properties = {} 62 63 --- Modified version of the builtin assert that does not include error position information 64 --- 65 --- @param v any Condition 66 --- @param message string Error message to display if condition is false or nil 67 --- @return any v if not false or nil, otherwise an error is displayed 68 local function assert(v, message) 69 return v or error(message, 0) 70 end 71 72 --- Show a warning message 73 --- @param msg string Message to show 74 local function warn(msg, ...) 75 vim.notify_once(msg:format(...), vim.log.levels.WARN, { 76 title = 'editorconfig', 77 }) 78 end 79 80 --- If "true", then stop searching for `.editorconfig` files in parent 81 --- directories. This property must be at the top-level of the 82 --- `.editorconfig` file (i.e. it must not be within a glob section). 83 function properties.root() 84 -- Unused 85 end 86 87 --- One of `"utf-8"`, `"utf-8-bom"`, `"latin1"`, `"utf-16be"`, or `"utf-16le"`. 88 --- Sets the 'fileencoding' and 'bomb' options. 89 function properties.charset(bufnr, val) 90 assert( 91 vim.list_contains({ 'utf-8', 'utf-8-bom', 'latin1', 'utf-16be', 'utf-16le' }, val), 92 'charset must be one of "utf-8", "utf-8-bom", "latin1", "utf-16be", or "utf-16le"' 93 ) 94 if val == 'utf-8' or val == 'utf-8-bom' then 95 vim.bo[bufnr].fileencoding = 'utf-8' 96 vim.bo[bufnr].bomb = val == 'utf-8-bom' 97 elseif val == 'utf-16be' then 98 vim.bo[bufnr].fileencoding = 'utf-16' 99 else 100 vim.bo[bufnr].fileencoding = val 101 end 102 end 103 104 --- One of `"lf"`, `"crlf"`, or `"cr"`. 105 --- These correspond to setting 'fileformat' to "unix", "dos", or "mac", 106 --- respectively. 107 function properties.end_of_line(bufnr, val) 108 vim.bo[bufnr].fileformat = assert( 109 ({ lf = 'unix', crlf = 'dos', cr = 'mac' })[val], 110 'end_of_line must be one of "lf", "crlf", or "cr"' 111 ) 112 end 113 114 --- One of `"tab"` or `"space"`. Sets the 'expandtab' option. 115 function properties.indent_style(bufnr, val, opts) 116 assert(val == 'tab' or val == 'space', 'indent_style must be either "tab" or "space"') 117 vim.bo[bufnr].expandtab = val == 'space' 118 if val == 'tab' and not opts.indent_size then 119 vim.bo[bufnr].shiftwidth = 0 120 vim.bo[bufnr].softtabstop = 0 121 end 122 end 123 124 --- A number indicating the size of a single indent. Alternatively, use the 125 --- value "tab" to use the value of the tab_width property. Sets the 126 --- 'shiftwidth' and 'softtabstop' options. If this value is not "tab" and 127 --- the tab_width property is not set, 'tabstop' is also set to this value. 128 function properties.indent_size(bufnr, val, opts) 129 if val == 'tab' then 130 vim.bo[bufnr].shiftwidth = 0 131 vim.bo[bufnr].softtabstop = 0 132 else 133 local n = assert(tonumber(val), 'indent_size must be a number') 134 vim.bo[bufnr].shiftwidth = n 135 vim.bo[bufnr].softtabstop = -1 136 if not opts.tab_width then 137 vim.bo[bufnr].tabstop = n 138 end 139 end 140 end 141 142 --- The display size of a single tab character. Sets the 'tabstop' option. 143 function properties.tab_width(bufnr, val) 144 vim.bo[bufnr].tabstop = assert(tonumber(val), 'tab_width must be a number') 145 end 146 147 --- A number indicating the maximum length of a single 148 --- line. Sets the 'textwidth' option. 149 function properties.max_line_length(bufnr, val) 150 local n = tonumber(val) 151 if n then 152 vim.bo[bufnr].textwidth = n 153 else 154 assert(val == 'off', 'max_line_length must be a number or "off"') 155 vim.bo[bufnr].textwidth = 0 156 end 157 end 158 159 --- When `"true"`, trailing whitespace is automatically removed when the buffer is written. 160 function properties.trim_trailing_whitespace(bufnr, val) 161 assert( 162 val == 'true' or val == 'false', 163 'trim_trailing_whitespace must be either "true" or "false"' 164 ) 165 if val == 'true' then 166 vim.api.nvim_create_autocmd('BufWritePre', { 167 group = 'nvim.editorconfig', 168 buffer = bufnr, 169 callback = function() 170 local view = vim.fn.winsaveview() 171 vim.api.nvim_command('silent! undojoin') 172 vim.api.nvim_command('silent keepjumps keeppatterns %s/\\s\\+$//e') 173 vim.fn.winrestview(view) 174 end, 175 }) 176 else 177 vim.api.nvim_clear_autocmds({ 178 event = 'BufWritePre', 179 group = 'nvim.editorconfig', 180 buffer = bufnr, 181 }) 182 end 183 end 184 185 --- `"true"` or `"false"` to ensure the file always has a trailing newline as its last byte. 186 --- Sets the 'fixendofline' and 'endofline' options. 187 function properties.insert_final_newline(bufnr, val) 188 assert(val == 'true' or val == 'false', 'insert_final_newline must be either "true" or "false"') 189 vim.bo[bufnr].fixendofline = val == 'true' 190 191 -- 'endofline' can be read to detect if the file contains a final newline, 192 -- so only change 'endofline' right before writing the file 193 local endofline = val == 'true' 194 if vim.bo[bufnr].endofline ~= endofline then 195 vim.api.nvim_create_autocmd('BufWritePre', { 196 group = 'nvim.editorconfig', 197 buffer = bufnr, 198 once = true, 199 callback = function() 200 vim.bo[bufnr].endofline = endofline 201 end, 202 }) 203 end 204 end 205 206 --- A code of the format ss or ss-TT, where ss is an ISO 639 language code and TT is an ISO 3166 territory identifier. 207 --- Sets the 'spelllang' option. 208 function properties.spelling_language(bufnr, val) 209 local error_msg = 210 'spelling_language must be of the format ss or ss-TT, where ss is an ISO 639 language code and TT is an ISO 3166 territory identifier.' 211 212 assert(val:len() == 2 or val:len() == 5, error_msg) 213 214 local language_code = val:sub(1, 2):lower() 215 assert(language_code:match('%l%l'), error_msg) 216 if val:len() == 2 then 217 vim.bo[bufnr].spelllang = language_code 218 else 219 assert(val:sub(3, 3) == '-', error_msg) 220 221 local territory_code = val:sub(4, 5):lower() 222 assert(territory_code:match('%l%l'), error_msg) 223 vim.bo[bufnr].spelllang = language_code .. '_' .. territory_code 224 end 225 end 226 227 --- Modified version of [glob2regpat()] that does not match path separators on `*`. 228 --- 229 --- This function replaces single instances of `*` with the regex pattern `[^/]*`. 230 --- However, the star in the replacement pattern also gets interpreted by glob2regpat, 231 --- so we insert a placeholder, pass it through glob2regpat, then replace the 232 --- placeholder with the actual regex pattern. 233 --- 234 --- @param glob string Glob to convert into a regular expression 235 --- @return string regex Regular expression 236 local function glob2regpat(glob) 237 local placeholder = '@@PLACEHOLDER@@' 238 local glob1 = vim.fn.substitute( 239 glob:gsub('{(%d+)%.%.(%d+)}', '[%1-%2]'), 240 '\\*\\@<!\\*\\*\\@!', 241 placeholder, 242 'g' 243 ) 244 local regpat = vim.fn.glob2regpat(glob1) 245 return (regpat:gsub(placeholder, '[^/]*')) 246 end 247 248 --- Parse a single line in an EditorConfig file 249 --- @param line string Line 250 --- @return string? glob pattern if the line contains a pattern 251 --- @return string? key if the line contains a key-value pair 252 --- @return string? value if the line contains a key-value pair 253 local function parse_line(line) 254 if not line:find('^%s*[^ #;]') then 255 return 256 end 257 258 --- @type string? 259 local glob = line:match('^%s*%[(.*)%]%s*$') 260 if glob then 261 return glob 262 end 263 264 local key, val = line:match('^%s*([^:= ][^:=]-)%s*[:=]%s*(.-)%s*$') 265 if key ~= nil and val ~= nil then 266 return nil, key:lower(), val:lower() 267 end 268 end 269 270 --- Parse options from an `.editorconfig` file 271 --- @param filepath string File path of the file to apply EditorConfig settings to 272 --- @param dir string Current directory 273 --- @return table<string,string|boolean> Table of options to apply to the given file 274 local function parse(filepath, dir) 275 local pat --- @type vim.regex? 276 local opts = {} --- @type table<string,string|boolean> 277 local f = io.open(dir .. '/.editorconfig') 278 if f then 279 for line in f:lines() do 280 local glob, key, val = parse_line(line) 281 if glob then 282 glob = glob:find('/') and (dir .. '/' .. glob:gsub('^/', '')) or ('**/' .. glob) 283 local ok, regpat = pcall(glob2regpat, glob) 284 if ok then 285 pat = vim.regex(regpat) 286 else 287 pat = nil 288 warn('editorconfig: Error occurred while parsing glob pattern "%s": %s', glob, regpat) 289 end 290 elseif key ~= nil and val ~= nil then 291 if key == 'root' then 292 assert(val == 'true' or val == 'false', 'root must be either "true" or "false"') 293 opts.root = val == 'true' 294 elseif pat and pat:match_str(filepath) then 295 opts[key] = val 296 end 297 end 298 end 299 f:close() 300 end 301 return opts 302 end 303 304 local M = {} 305 306 -- Exposed for use in syntax/editorconfig.vim` 307 M.properties = properties 308 309 --- @private 310 --- Configure the given buffer with options from an `.editorconfig` file 311 --- @param bufnr integer Buffer number to configure 312 function M.config(bufnr) 313 bufnr = bufnr or vim.api.nvim_get_current_buf() 314 if not vim.api.nvim_buf_is_valid(bufnr) then 315 return 316 end 317 318 local path = vim.fs.normalize(vim.api.nvim_buf_get_name(bufnr)) 319 if vim.bo[bufnr].buftype ~= '' or not vim.bo[bufnr].modifiable or path == '' then 320 return 321 end 322 323 local opts = {} --- @type table<string,string|boolean> 324 for parent in vim.fs.parents(path) do 325 for k, v in pairs(parse(path, parent)) do 326 if opts[k] == nil then 327 opts[k] = v 328 end 329 end 330 331 if opts.root then 332 break 333 end 334 end 335 336 local applied = {} --- @type table<string,string|boolean> 337 for opt, val in pairs(opts) do 338 if val ~= 'unset' then 339 local func = M.properties[opt] 340 if func then 341 --- @type boolean, string? 342 local ok, err = pcall(func, bufnr, val, opts) 343 if ok then 344 applied[opt] = val 345 else 346 warn('editorconfig: invalid value for option %s: %s. %s', opt, val, err) 347 end 348 end 349 end 350 end 351 352 vim.b[bufnr].editorconfig = applied 353 end 354 355 return M