From dc5d313d66c620699a926aeefaf20bb0ec0d653e Mon Sep 17 00:00:00 2001 From: Yochem van Rosmalen Date: Fri, 27 Feb 2026 23:45:07 +0100 Subject: [PATCH] fix(vim.fs): joinpath() should ignore empty items #38077 Problem: vim.fs.joinpath treats empty string as a path segment (it adds a path separator for each empty item): print(vim.fs.joinpath('', 'after/lsp', '')) -- '/after/lsp/' print(vim.fs.joinpath('', '')) -- '/' Especially problematic if the empty segment is the first segment, as that converts the path to an absolute path. Solution: Ignore empty (length of 0) path segments. Benchmark: local function test(func) local t = vim.uv.hrtime() for _ = 1, 100000, 1 do func('', 'this/is', 'a/very/long/path', '', 'it', 'really', 'is') end print(math.floor((vim.uv.hrtime() - t) / 1e6), 'ms') end - with Iter():filter() --> 370 ms - building new segments table --> 208 ms - with vim.tbl_filter --> 232 ms - Instead of gsub split on `/` in all parts --> 1870 ms --- runtime/doc/lua.txt | 3 ++- runtime/lua/vim/fs.lua | 19 ++++++++++++++----- test/functional/lua/fs_spec.lua | 6 ++++++ 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/runtime/doc/lua.txt b/runtime/doc/lua.txt index 283afcc9f3..df4e078acc 100644 --- a/runtime/doc/lua.txt +++ b/runtime/doc/lua.txt @@ -2592,10 +2592,11 @@ vim.fs.joinpath({...}) *vim.fs.joinpath()* Concatenates partial paths (one absolute or relative path followed by zero or more relative paths). Slashes are normalized: redundant slashes are removed, and (on Windows) backslashes are replaced with forward-slashes. - Paths are not expanded/resolved. + Empty segments are removed. Paths are not expanded/resolved. Examples: • "foo/", "/bar" => "foo/bar" + • "", "after/plugin" => "after/plugin" • Windows: "a\foo\", "\bar" => "a/foo/bar" Attributes: ~ diff --git a/runtime/lua/vim/fs.lua b/runtime/lua/vim/fs.lua index 6e590178d2..dc26790d14 100644 --- a/runtime/lua/vim/fs.lua +++ b/runtime/lua/vim/fs.lua @@ -116,21 +116,30 @@ end --- Concatenates partial paths (one absolute or relative path followed by zero or more relative --- paths). Slashes are normalized: redundant slashes are removed, and (on Windows) backslashes are ---- replaced with forward-slashes. Paths are not expanded/resolved. +--- replaced with forward-slashes. Empty segments are removed. Paths are not expanded/resolved. --- --- Examples: --- - "foo/", "/bar" => "foo/bar" +--- - "", "after/plugin" => "after/plugin" --- - Windows: "a\foo\", "\bar" => "a/foo/bar" --- ---@since 12 ---@param ... string ---@return string function M.joinpath(...) - local path = table.concat({ ... }, '/') - if iswin then - path = path:gsub('\\', '/') + local n = select('#', ...) + ---@type string[] + local segments = {} + for i = 1, n do + local s = select(i, ...) + if s and #s > 0 then + segments[#segments + 1] = s + end end - return (path:gsub('//+', '/')) + + local path = table.concat(segments, '/') + + return (path:gsub(iswin and '[/\\][/\\]*' or '//+', '/')) end --- @class vim.fs.dir.Opts diff --git a/test/functional/lua/fs_spec.lua b/test/functional/lua/fs_spec.lua index 017d158584..8ffdef3566 100644 --- a/test/functional/lua/fs_spec.lua +++ b/test/functional/lua/fs_spec.lua @@ -470,6 +470,12 @@ describe('vim.fs', function() eq('foo/bar/baz/zub/', vim.fs.joinpath([[foo]], [[//bar////baz]], [[zub/]])) end end) + it('handles empty segments', function() + eq('foo/bar', vim.fs.joinpath('', 'foo', '', 'bar', '')) + eq('foo/bar', vim.fs.joinpath('', '', 'foo', 'bar', '', '')) + eq('', vim.fs.joinpath('')) + eq('', vim.fs.joinpath('', '', '', '')) + end) end) describe('normalize()', function()