From 72a63346d866c320bbf77c90db0574952bd1c076 Mon Sep 17 00:00:00 2001 From: Yochem van Rosmalen Date: Fri, 20 Mar 2026 10:08:00 +0100 Subject: [PATCH] feat(stdlib): vim.fs.ext() returns file extension #36997 Problem: Checking the extension of a file is done often, e.g. in Nvim's codebase for differentiating Lua and Vimscript files in the runtime. The current way to do this in Lua is (1) a Lua pattern match, which has pitfalls such as not considering filenames starting with a dot, or (2) fnamemodify() which is both hard to discover and hard to use / read if not very familiar with the possible modifiers. vim.fs.ext() returns the file extension including the leading dot of the extension. Similar to the "file extension" implementation of many other stdlibs (including fnamemodify(file, ":e")), a leading dot doesn't indicate the start of the extension. E.g.: the .git folder in a repository doesn't have the extension .git, but it simply has no extension, similar to a folder named git or any other filename without dot(s). --- runtime/doc/lsp.txt | 2 +- runtime/doc/lua.txt | 24 +++++++++++++++++++++++- runtime/doc/news.txt | 1 + runtime/lua/vim/fs.lua | 25 ++++++++++++++++++++++++- runtime/lua/vim/health.lua | 2 +- runtime/lua/vim/lsp.lua | 2 +- test/functional/lua/fs_spec.lua | 6 ++++++ 7 files changed, 57 insertions(+), 5 deletions(-) diff --git a/runtime/doc/lsp.txt b/runtime/doc/lsp.txt index 938e2df3b0..d47d4f6ded 100644 --- a/runtime/doc/lsp.txt +++ b/runtime/doc/lsp.txt @@ -1038,7 +1038,7 @@ enable({name}, {enable}) *vim.lsp.enable()* config to activate: >lua vim.lsp.config('lua_ls', { root_dir = function(bufnr, on_dir) - if not vim.fn.bufname(bufnr):match('%.txt$') then + if vim.fs.ext(vim.fn.bufname(bufnr)) ~= 'txt' then on_dir(vim.fn.getcwd()) end end diff --git a/runtime/doc/lua.txt b/runtime/doc/lua.txt index d5ce2c2209..5ae83df462 100644 --- a/runtime/doc/lua.txt +++ b/runtime/doc/lua.txt @@ -2537,6 +2537,28 @@ vim.fs.dirname({file}) *vim.fs.dirname()* Return: ~ (`string?`) Parent directory of {file} +vim.fs.ext({file}, {opts}) *vim.fs.ext()* + Return the file's last extension, if any. + + Similar to |fnamemodify()| with the |::e| modifier. The extension does not + include a leading period. + + Examples: >lua + vim.fs.ext('archive.tar.gz') -- 'gz' + vim.fs.ext('~/.git') -- '' + vim.fs.ext('plugin/myplug.lua') -- 'lua' +< + + Attributes: ~ + Since: 0.12.0 + + Parameters: ~ + • {file} (`string`) Path + • {opts} (`table?`) Reserved for future use + + Return: ~ + (`string`) Extension of {file} + vim.fs.find({names}, {opts}) *vim.fs.find()* Find files or directories (or other items as specified by `opts.type`) in the given path. @@ -2744,7 +2766,7 @@ vim.fs.root({source}, {marker}) *vim.fs.root()* -- Find the parent directory containing any file with a .csproj extension vim.fs.root(0, function(name, path) - return name:match('%.csproj$') ~= nil + return vim.fs.ext(name) == 'csproj' end) -- Find the first ancestor directory containing EITHER "stylua.toml" or ".luarc.json"; if diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index bf0363d4a4..1f8cd421d7 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -354,6 +354,7 @@ LUA • |vim.json.encode()| has an `sort_keys` option. • |Range:is_empty()| to check if a |vim.Range| is empty. • |vim.json.decode()| has an `skip_comments` option. +• |vim.fs.ext()| returns the last extension of a file. OPTIONS diff --git a/runtime/lua/vim/fs.lua b/runtime/lua/vim/fs.lua index 7eadf8cb78..41e08eeeb2 100644 --- a/runtime/lua/vim/fs.lua +++ b/runtime/lua/vim/fs.lua @@ -409,7 +409,7 @@ end --- --- -- Find the parent directory containing any file with a .csproj extension --- vim.fs.root(0, function(name, path) ---- return name:match('%.csproj$') ~= nil +--- return vim.fs.ext(name) == 'csproj' --- end) --- --- -- Find the first ancestor directory containing EITHER "stylua.toml" or ".luarc.json"; if @@ -835,4 +835,27 @@ function M.relpath(base, target, opts) return vim.startswith(target, base) and target:sub(#base + 1) or nil end +--- Return the file's last extension, if any. +--- +--- Similar to |fnamemodify()| with the |::e| modifier. The extension does not include a leading +--- period. +--- +--- Examples: +--- +--- ```lua +--- vim.fs.ext('archive.tar.gz') -- 'gz' +--- vim.fs.ext('~/.git') -- '' +--- vim.fs.ext('plugin/myplug.lua') -- 'lua' +--- ``` +--- +---@since 14 +---@param file string Path +---@param opts table? Reserved for future use +---@return string Extension of {file} +function M.ext(file, opts) + vim.validate('file', file, 'string') + vim.validate('opts', opts, 'table', true) + return vim.fn.fnamemodify(file, ':e') +end + return M diff --git a/runtime/lua/vim/health.lua b/runtime/lua/vim/health.lua index e22e72c589..d8f9eb156c 100644 --- a/runtime/lua/vim/health.lua +++ b/runtime/lua/vim/health.lua @@ -311,7 +311,7 @@ function M.error(msg, ...) end local path2name = function(path) - if path:match('%.lua$') then + if vim.fs.ext(path) == 'lua' then -- Lua: transform "../lua/vim/lsp/health.lua" into "vim.lsp" -- Get full path, make sure all slashes are '/' diff --git a/runtime/lua/vim/lsp.lua b/runtime/lua/vim/lsp.lua index 3b6f4b4ee2..30ce7eabd8 100644 --- a/runtime/lua/vim/lsp.lua +++ b/runtime/lua/vim/lsp.lua @@ -585,7 +585,7 @@ end --- ```lua --- vim.lsp.config('lua_ls', { --- root_dir = function(bufnr, on_dir) ---- if not vim.fn.bufname(bufnr):match('%.txt$') then +--- if vim.fs.ext(vim.fn.bufname(bufnr)) ~= 'txt' then --- on_dir(vim.fn.getcwd()) --- end --- end diff --git a/test/functional/lua/fs_spec.lua b/test/functional/lua/fs_spec.lua index 4d93ce053b..de6e9f1350 100644 --- a/test/functional/lua/fs_spec.lua +++ b/test/functional/lua/fs_spec.lua @@ -753,4 +753,10 @@ describe('vim.fs', function() assert_rm_symlinked_dir({ recursive = true, force = true }) end) end) + + describe('ext()', function() + it('works', function() + -- See test/functional/vimscript/fnamemodify_spec.lua + end) + end) end)