feat(lua): vim.fs.normalize() resolves ".", ".." #28203

Problem:
`vim.fs.normalize` does not resolve `.` and `..` components. This makes
no sense as the entire point of normalization is to remove redundancy
from the path. The path normalization functions in several other
languages (Java, Python, C++, etc.) also resolve `.` and `..`
components.

Reference:
- Python: https://docs.python.org/3/library/os.path.html#os.path.normpath
- Java: https://docs.oracle.com/javase/8/docs/api/java/nio/file/Path.html#normalize--
- C++: https://en.cppreference.com/w/cpp/filesystem/path/lexically_normal

Solution:
Resolve "." and ".." in `vim.fs.normalize`.

    Before:
    "~/foo/bar/../baz/./" => "~/foo/bar/../baz/."
    After:
    "~/foo/bar/../baz/./" => "~/foo/baz"
This commit is contained in:
Famiu Haque 2024-04-17 01:13:44 +06:00 committed by GitHub
parent 20b38677c2
commit 8e5c48b08d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 292 additions and 55 deletions

View File

@ -2964,26 +2964,40 @@ vim.fs.joinpath({...}) *vim.fs.joinpath()*
vim.fs.normalize({path}, {opts}) *vim.fs.normalize()* vim.fs.normalize({path}, {opts}) *vim.fs.normalize()*
Normalize a path to a standard format. A tilde (~) character at the Normalize a path to a standard format. A tilde (~) character at the
beginning of the path is expanded to the user's home directory and beginning of the path is expanded to the user's home directory and
environment variables are also expanded. environment variables are also expanded. "." and ".." components are also
resolved, except when the path is relative and trying to resolve it would
result in an absolute path.
• "." as the only part in a relative path:
• "." => "."
• "././" => "."
• ".." when it leads outside the current directory
• "foo/../../bar" => "../bar"
• "../../foo" => "../../foo"
• ".." in the root directory returns the root directory.
• "/../../" => "/"
On Windows, backslash (\) characters are converted to forward slashes (/). On Windows, backslash (\) characters are converted to forward slashes (/).
Examples: >lua Examples: >lua
vim.fs.normalize('C:\\\\Users\\\\jdoe') [[C:\Users\jdoe]] => "C:/Users/jdoe"
-- On Windows: 'C:/Users/jdoe' "~/src/neovim" => "/home/jdoe/src/neovim"
"$XDG_CONFIG_HOME/nvim/init.vim" => "/Users/jdoe/.config/nvim/init.vim"
vim.fs.normalize('~/src/neovim') "~/src/nvim/api/../tui/./tui.c" => "/home/jdoe/src/nvim/tui/tui.c"
-- '/home/jdoe/src/neovim' "./foo/bar" => "foo/bar"
"foo/../../../bar" => "../../bar"
vim.fs.normalize('$XDG_CONFIG_HOME/nvim/init.vim') "/home/jdoe/../../../bar" => "/bar"
-- '/Users/jdoe/.config/nvim/init.vim' "C:foo/../../baz" => "C:../baz"
"C:/foo/../../baz" => "C:/baz"
[[\\?\UNC\server\share\foo\..\..\..\bar]] => "//?/UNC/server/share/bar"
< <
Parameters: ~ Parameters: ~
• {path} (`string`) Path to normalize • {path} (`string`) Path to normalize
• {opts} (`table?`) A table with the following fields: • {opts} (`table?`) A table with the following fields:
• {expand_env} (`boolean`, default: `true`) Expand environment • {expand_env}? (`boolean`, default: `true`) Expand
variables. environment variables.
• {win}? (`boolean`, default: `true` in Windows, `false`
otherwise) Path is a Windows path.
Return: ~ Return: ~
(`string`) Normalized path (`string`) Normalized path

View File

@ -334,30 +334,147 @@ function M.find(names, opts)
return matches return matches
end end
--- Split a Windows path into a prefix and a body, such that the body can be processed like a POSIX
--- path. The path must use forward slashes as path separator.
---
--- Does not check if the path is a valid Windows path. Invalid paths will give invalid results.
---
--- Examples:
--- - `//./C:/foo/bar` -> `//./C:`, `/foo/bar`
--- - `//?/UNC/server/share/foo/bar` -> `//?/UNC/server/share`, `/foo/bar`
--- - `//./system07/C$/foo/bar` -> `//./system07`, `/C$/foo/bar`
--- - `C:/foo/bar` -> `C:`, `/foo/bar`
--- - `C:foo/bar` -> `C:`, `foo/bar`
---
--- @param path string Path to split.
--- @return string, string, boolean : prefix, body, whether path is invalid.
local function split_windows_path(path)
local prefix = ''
--- Match pattern. If there is a match, move the matched pattern from the path to the prefix.
--- Returns the matched pattern.
---
--- @param pattern string Pattern to match.
--- @return string|nil Matched pattern
local function match_to_prefix(pattern)
local match = path:match(pattern)
if match then
prefix = prefix .. match --[[ @as string ]]
path = path:sub(#match + 1)
end
return match
end
local function process_unc_path()
return match_to_prefix('[^/]+/+[^/]+/+')
end
if match_to_prefix('^//[?.]/') then
-- Device paths
local device = match_to_prefix('[^/]+/+')
-- Return early if device pattern doesn't match, or if device is UNC and it's not a valid path
if not device or (device:match('^UNC/+$') and not process_unc_path()) then
return prefix, path, false
end
elseif match_to_prefix('^//') then
-- Process UNC path, return early if it's invalid
if not process_unc_path() then
return prefix, path, false
end
elseif path:match('^%w:') then
-- Drive paths
prefix, path = path:sub(1, 2), path:sub(3)
end
-- If there are slashes at the end of the prefix, move them to the start of the body. This is to
-- ensure that the body is treated as an absolute path. For paths like C:foo/bar, there are no
-- slashes at the end of the prefix, so it will be treated as a relative path, as it should be.
local trailing_slash = prefix:match('/+$')
if trailing_slash then
prefix = prefix:sub(1, -1 - #trailing_slash)
path = trailing_slash .. path --[[ @as string ]]
end
return prefix, path, true
end
--- Resolve `.` and `..` components in a POSIX-style path. This also removes extraneous slashes.
--- `..` is not resolved if the path is relative and resolving it requires the path to be absolute.
--- If a relative path resolves to the current directory, an empty string is returned.
---
--- @see M.normalize()
--- @param path string Path to resolve.
--- @return string Resolved path.
local function path_resolve_dot(path)
local is_path_absolute = vim.startswith(path, '/')
-- Split the path into components and process them
local path_components = vim.split(path, '/')
local new_path_components = {}
for _, component in ipairs(path_components) do
if component == '.' or component == '' then -- luacheck: ignore 542
-- Skip `.` components and empty components
elseif component == '..' then
if #new_path_components > 0 and new_path_components[#new_path_components] ~= '..' then
-- For `..`, remove the last component if we're still inside the current directory, except
-- when the last component is `..` itself
table.remove(new_path_components)
elseif is_path_absolute then -- luacheck: ignore 542
-- Reached the root directory in absolute path, do nothing
else
-- Reached current directory in relative path, add `..` to the path
table.insert(new_path_components, component)
end
else
table.insert(new_path_components, component)
end
end
return (is_path_absolute and '/' or '') .. table.concat(new_path_components, '/')
end
--- @class vim.fs.normalize.Opts --- @class vim.fs.normalize.Opts
--- @inlinedoc --- @inlinedoc
--- ---
--- Expand environment variables. --- Expand environment variables.
--- (default: `true`) --- (default: `true`)
--- @field expand_env boolean --- @field expand_env? boolean
---
--- Path is a Windows path.
--- (default: `true` in Windows, `false` otherwise)
--- @field win? boolean
--- Normalize a path to a standard format. A tilde (~) character at the --- Normalize a path to a standard format. A tilde (~) character at the beginning of the path is
--- beginning of the path is expanded to the user's home directory and --- expanded to the user's home directory and environment variables are also expanded. "." and ".."
--- environment variables are also expanded. --- components are also resolved, except when the path is relative and trying to resolve it would
--- result in an absolute path.
--- - "." as the only part in a relative path:
--- - "." => "."
--- - "././" => "."
--- - ".." when it leads outside the current directory
--- - "foo/../../bar" => "../bar"
--- - "../../foo" => "../../foo"
--- - ".." in the root directory returns the root directory.
--- - "/../../" => "/"
--- ---
--- On Windows, backslash (\) characters are converted to forward slashes (/). --- On Windows, backslash (\) characters are converted to forward slashes (/).
--- ---
--- Examples: --- Examples:
---
--- ```lua --- ```lua
--- vim.fs.normalize('C:\\\\Users\\\\jdoe') --- [[C:\Users\jdoe]] => "C:/Users/jdoe"
--- -- On Windows: 'C:/Users/jdoe' --- "~/src/neovim" => "/home/jdoe/src/neovim"
--- --- "$XDG_CONFIG_HOME/nvim/init.vim" => "/Users/jdoe/.config/nvim/init.vim"
--- vim.fs.normalize('~/src/neovim') --- "~/src/nvim/api/../tui/./tui.c" => "/home/jdoe/src/nvim/tui/tui.c"
--- -- '/home/jdoe/src/neovim' --- "./foo/bar" => "foo/bar"
--- --- "foo/../../../bar" => "../../bar"
--- vim.fs.normalize('$XDG_CONFIG_HOME/nvim/init.vim') --- "/home/jdoe/../../../bar" => "/bar"
--- -- '/Users/jdoe/.config/nvim/init.vim' --- "C:foo/../../baz" => "C:../baz"
--- "C:/foo/../../baz" => "C:/baz"
--- [[\\?\UNC\server\share\foo\..\..\..\bar]] => "//?/UNC/server/share/bar"
--- ``` --- ```
--- ---
---@param path (string) Path to normalize ---@param path (string) Path to normalize
@ -369,12 +486,21 @@ function M.normalize(path, opts)
vim.validate({ vim.validate({
path = { path, { 'string' } }, path = { path, { 'string' } },
expand_env = { opts.expand_env, { 'boolean' }, true }, expand_env = { opts.expand_env, { 'boolean' }, true },
win = { opts.win, { 'boolean' }, true },
}) })
local win = opts.win == nil and iswin or not not opts.win
local os_sep_local = win and '\\' or '/'
-- Empty path is already normalized
if path == '' then
return ''
end
-- Expand ~ to users home directory -- Expand ~ to users home directory
if vim.startswith(path, '~') then if vim.startswith(path, '~') then
local home = vim.uv.os_homedir() or '~' local home = vim.uv.os_homedir() or '~'
if home:sub(-1) == os_sep then if home:sub(-1) == os_sep_local then
home = home:sub(1, -2) home = home:sub(1, -2)
end end
path = home .. path:sub(2) path = home .. path:sub(2)
@ -386,24 +512,35 @@ function M.normalize(path, opts)
end end
-- Convert path separator to `/` -- Convert path separator to `/`
path = path:gsub(os_sep, '/') path = path:gsub(os_sep_local, '/')
-- Don't modify leading double slash as those have implementation-defined behavior according to -- Check for double slashes at the start of the path because they have special meaning
-- POSIX. They are also valid UNC paths. Three or more leading slashes are however collapsed to local double_slash = vim.startswith(path, '//') and not vim.startswith(path, '///')
-- a single slash. local prefix = ''
if vim.startswith(path, '//') and not vim.startswith(path, '///') then
path = '/' .. path:gsub('/+', '/') if win then
else local is_valid --- @type boolean
path = path:gsub('/+', '/') -- Split Windows paths into prefix and body to make processing easier
prefix, path, is_valid = split_windows_path(path)
-- If path is not valid, return it as-is
if not is_valid then
return prefix .. path
end
-- Remove extraneous slashes from the prefix
prefix = prefix:gsub('/+', '/')
end end
-- Ensure last slash is not truncated from root drive on Windows -- Resolve `.` and `..` components and remove extraneous slashes from path, then recombine prefix
if iswin and path:match('^%w:/$') then -- and path. Preserve leading double slashes as they indicate UNC paths and DOS device paths in
return path -- Windows and have implementation-defined behavior in POSIX.
end path = (double_slash and '/' or '') .. prefix .. path_resolve_dot(path)
-- Remove trailing slashes -- Change empty path to `.`
path = path:gsub('(.)/$', '%1') if path == '' then
path = '.'
end
return path return path
end end

View File

@ -308,26 +308,112 @@ describe('vim.fs', function()
) )
end) end)
it('works with UNC paths', function() -- Opts required for testing posix paths and win paths
eq('//foo', vim.fs.normalize('//foo')) -- UNC path local posix_opts = is_os('win') and { win = false } or {}
eq('//foo/bar', vim.fs.normalize('//foo//bar////')) -- UNC path local win_opts = is_os('win') and {} or { win = true }
eq('/foo', vim.fs.normalize('///foo')) -- Not a UNC path
eq('/', vim.fs.normalize('//')) -- Not a UNC path it('preserves leading double slashes in POSIX paths', function()
eq('/', vim.fs.normalize('///')) -- Not a UNC path eq('//foo', vim.fs.normalize('//foo', posix_opts))
eq('/foo/bar', vim.fs.normalize('/foo//bar////')) -- Not a UNC path eq('//foo/bar', vim.fs.normalize('//foo//bar////', posix_opts))
eq('/foo', vim.fs.normalize('///foo', posix_opts))
eq('//', vim.fs.normalize('//', posix_opts))
eq('/', vim.fs.normalize('///', posix_opts))
eq('/foo/bar', vim.fs.normalize('/foo//bar////', posix_opts))
end) end)
if is_os('win') then it('allows backslashes on unix-based os', function()
it('Last slash is not truncated from root drive', function() eq('/home/user/hello\\world', vim.fs.normalize('/home/user/hello\\world', posix_opts))
eq('C:/', vim.fs.normalize('C:/')) end)
it('preserves / after drive letters', function()
eq('C:/', vim.fs.normalize([[C:\]], win_opts))
end)
it('works with UNC and DOS device paths', function()
eq('//server/share/foo/bar', vim.fs.normalize([[\\server\\share\\\foo\bar\\\]], win_opts))
eq('//system07/C$/', vim.fs.normalize([[\\system07\C$\\\\]], win_opts))
eq('//./C:/foo/bar', vim.fs.normalize([[\\.\\C:\foo\\\\bar]], win_opts))
eq('//?/C:/foo/bar', vim.fs.normalize([[\\?\C:\\\foo\bar\\\\]], win_opts))
eq(
'//?/UNC/server/share/foo/bar',
vim.fs.normalize([[\\?\UNC\server\\\share\\\\foo\\\bar]], win_opts)
)
eq('//./BootPartition/foo/bar', vim.fs.normalize([[\\.\BootPartition\\foo\bar]], win_opts))
eq(
'//./Volume{12345678-1234-1234-1234-1234567890AB}/foo/bar',
vim.fs.normalize([[\\.\Volume{12345678-1234-1234-1234-1234567890AB}\\\foo\bar\\]], win_opts)
)
end)
it('handles invalid UNC and DOS device paths', function()
eq('//server/share', vim.fs.normalize([[\\server\share]], win_opts))
eq('//server/', vim.fs.normalize([[\\server\]], win_opts))
eq('//./UNC/server/share', vim.fs.normalize([[\\.\UNC\server\share]], win_opts))
eq('//?/UNC/server/', vim.fs.normalize([[\\?\UNC\server\]], win_opts))
eq('//?/UNC/server/..', vim.fs.normalize([[\\?\UNC\server\..]], win_opts))
eq('//./', vim.fs.normalize([[\\.\]], win_opts))
eq('//./foo', vim.fs.normalize([[\\.\foo]], win_opts))
eq('//./BootPartition', vim.fs.normalize([[\\.\BootPartition]], win_opts))
end)
it('converts backward slashes', function()
eq('C:/Users/jdoe', vim.fs.normalize([[C:\Users\jdoe]], win_opts))
end)
describe('. and .. component resolving', function()
it('works', function()
-- Windows paths
eq('C:/Users', vim.fs.normalize([[C:\Users\jdoe\Downloads\.\..\..\]], win_opts))
eq('C:/Users/jdoe', vim.fs.normalize([[C:\Users\jdoe\Downloads\.\..\.\.\]], win_opts))
eq('C:/', vim.fs.normalize('C:/Users/jdoe/Downloads/./../../../', win_opts))
eq('C:foo', vim.fs.normalize([[C:foo\bar\.\..\.]], win_opts))
-- POSIX paths
eq('/home', vim.fs.normalize('/home/jdoe/Downloads/./../..', posix_opts))
eq('/home/jdoe', vim.fs.normalize('/home/jdoe/Downloads/./../././', posix_opts))
eq('/', vim.fs.normalize('/home/jdoe/Downloads/./../../../', posix_opts))
-- OS-agnostic relative paths
eq('foo/bar/baz', vim.fs.normalize('foo/bar/foobar/../baz/./'))
eq('foo/bar', vim.fs.normalize('foo/bar/foobar/../baz/./../../bar/./.'))
end) end)
it('converts backward slashes', function()
eq('C:/Users/jdoe', vim.fs.normalize('C:\\Users\\jdoe')) it('works when relative path reaches current directory', function()
eq('C:', vim.fs.normalize('C:foo/bar/../../.', win_opts))
eq('.', vim.fs.normalize('.'))
eq('.', vim.fs.normalize('././././'))
eq('.', vim.fs.normalize('foo/bar/../../.'))
end) end)
else
it('allows backslashes on unix-based os', function() it('works when relative path goes outside current directory', function()
eq('/home/user/hello\\world', vim.fs.normalize('/home/user/hello\\world')) eq('../../foo/bar', vim.fs.normalize('../../foo/bar'))
eq('../foo', vim.fs.normalize('foo/bar/../../../foo'))
eq('C:../foo', vim.fs.normalize('C:../foo', win_opts))
eq('C:../../foo/bar', vim.fs.normalize('C:foo/../../../foo/bar', win_opts))
end) end)
end
it('.. in root directory resolves to itself', function()
eq('C:/', vim.fs.normalize('C:/../../', win_opts))
eq('C:/foo', vim.fs.normalize('C:/foo/../../foo', win_opts))
eq('//server/share/', vim.fs.normalize([[\\server\share\..\..]], win_opts))
eq('//server/share/foo', vim.fs.normalize([[\\server\\share\foo\..\..\foo]], win_opts))
eq('//./C:/', vim.fs.normalize([[\\.\C:\..\..]], win_opts))
eq('//?/C:/foo', vim.fs.normalize([[\\?\C:\..\..\foo]], win_opts))
eq('//./UNC/server/share/', vim.fs.normalize([[\\.\UNC\\server\share\..\..\]], win_opts))
eq(
'//?/UNC/server/share/foo',
vim.fs.normalize([[\\?\UNC\server\\share\..\..\foo]], win_opts)
)
eq('//?/BootPartition/', vim.fs.normalize([[\\?\BootPartition\..\..]], win_opts))
eq('//./BootPartition/foo', vim.fs.normalize([[\\.\BootPartition\..\..\foo]], win_opts))
eq('/', vim.fs.normalize('/../../', posix_opts))
eq('/foo', vim.fs.normalize('/foo/../../foo', posix_opts))
end)
end)
end) end)
end) end)