From ac4e5460b1a4b1bd9bbfe6d948f0c368593b74f9 Mon Sep 17 00:00:00 2001
From: Barrett Ruth
Date: Sat, 25 Jul 2026 17:59:35 -0500
Subject: [PATCH] feat(vim.fs): dir(opts.normalize)
---
runtime/doc/lua.txt | 6 +++++-
runtime/lua/vim/fs.lua | 13 +++++++++++--
test/functional/lua/fs_spec.lua | 26 ++++++++++++++++++++++++++
3 files changed, 42 insertions(+), 3 deletions(-)
diff --git a/runtime/doc/lua.txt b/runtime/doc/lua.txt
index 8a5cb3a327..ad4eb169ea 100644
--- a/runtime/doc/lua.txt
+++ b/runtime/doc/lua.txt
@@ -2639,7 +2639,7 @@ vim.fs.dir({path}, {opts}) *vim.fs.dir()*
Parameters: ~
• {path} (`string`) Directory to iterate over, normalized via
- |vim.fs.normalize()|.
+ |vim.fs.normalize()| unless `opts.normalize=false`.
• {opts} (`table?`) Optional keyword arguments:
• {depth}? (`integer`, default: `1`) How deep to traverse.
• {err}? (`boolean`, default: `false`) Report errors via the
@@ -2647,6 +2647,10 @@ vim.fs.dir({path}, {opts}) *vim.fs.dir()*
skipping.
• {follow}? (`boolean`, default: `false`) Follow symbolic
links.
+ • {normalize}? (`boolean`, default: `true`) Normalize {path}
+ via |vim.fs.normalize()|. Set `false` to use {path}
+ literally, e.g. to list a directory whose name contains `~`
+ or `$`.
• {skip}? (`fun(dir_name: string): boolean`) Predicate to
control traversal. Return false to stop searching the
current directory. Only useful when depth > 1 Return an
diff --git a/runtime/lua/vim/fs.lua b/runtime/lua/vim/fs.lua
index fbcb46d77d..57e0da4650 100644
--- a/runtime/lua/vim/fs.lua
+++ b/runtime/lua/vim/fs.lua
@@ -190,6 +190,11 @@ end
--- Follow symbolic links.
--- (default: `false`)
--- @field follow? boolean
+---
+--- Normalize {path} via |vim.fs.normalize()|. Set `false` to use {path} literally, e.g. to list a
+--- directory whose name contains `~` or `$`.
+--- (default: `true`)
+--- @field normalize? boolean
--- Gets an iterator over items found in `path` (normalized via |vim.fs.normalize()|).
---
@@ -204,7 +209,8 @@ end
--- ```
---
---@since 10
----@param path (string) Directory to iterate over, normalized via |vim.fs.normalize()|.
+---@param path (string) Directory to iterate over, normalized via |vim.fs.normalize()| unless
+--- `opts.normalize=false`.
---@param opts? vim.fs.dir.Opts Optional keyword arguments:
---@return fun(): string?, string?, string? # Iterator over items in {path}, yielding (name, type, err):
--- - name: Basename of the item relative to {path}.
@@ -219,8 +225,11 @@ function M.dir(path, opts)
vim.validate('err', opts.err, 'boolean', true)
vim.validate('follow', opts.follow, 'boolean', true)
vim.validate('skip', opts.skip, 'function', true)
+ vim.validate('normalize', opts.normalize, 'boolean', true)
- path = M.normalize(path)
+ if opts.normalize ~= false then
+ path = M.normalize(path)
+ end
local rootfs, rooterr = uv.fs_scandir(path)
diff --git a/test/functional/lua/fs_spec.lua b/test/functional/lua/fs_spec.lua
index db8ff2a9c0..5a328303e6 100644
--- a/test/functional/lua/fs_spec.lua
+++ b/test/functional/lua/fs_spec.lua
@@ -344,6 +344,32 @@ describe('vim.fs', function()
-- nil: with depth=2 we don't scan testdir/a/noaccess.
eq(nil, result['a/noaccess'])
end)
+
+ it('opts.normalize=false uses {path} literally', function()
+ mkdir('testdir')
+ mkdir('testdir/$XTEST_FS_DIR')
+ mkdir('testdir/expanded')
+ t.write_file('testdir/$XTEST_FS_DIR/literal.txt', '')
+ t.write_file('testdir/expanded/expanded.txt', '')
+ finally(function()
+ rmdir('testdir')
+ end)
+
+ eq(
+ { { ['expanded.txt'] = 'file' }, { ['literal.txt'] = 'file' } },
+ exec_lua(function()
+ vim.uv.os_setenv('XTEST_FS_DIR', 'expanded')
+ local out = {} ---@type table[]
+ for i, normalize in ipairs({ true, false }) do
+ out[i] = {}
+ for name, etype in vim.fs.dir('testdir/$XTEST_FS_DIR', { normalize = normalize }) do
+ out[i][name] = etype
+ end
+ end
+ return out
+ end)
+ )
+ end)
end)
describe('find()', function()