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
3 changed files with 292 additions and 55 deletions

View File

@@ -334,30 +334,147 @@ function M.find(names, opts)
return matches
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
--- @inlinedoc
---
--- Expand environment variables.
--- (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
--- beginning of the path is expanded to the user's home directory and
--- environment variables are also expanded.
--- 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 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 (/).
---
--- Examples:
---
--- ```lua
--- vim.fs.normalize('C:\\\\Users\\\\jdoe')
--- -- On Windows: 'C:/Users/jdoe'
---
--- vim.fs.normalize('~/src/neovim')
--- -- '/home/jdoe/src/neovim'
---
--- vim.fs.normalize('$XDG_CONFIG_HOME/nvim/init.vim')
--- -- '/Users/jdoe/.config/nvim/init.vim'
--- [[C:\Users\jdoe]] => "C:/Users/jdoe"
--- "~/src/neovim" => "/home/jdoe/src/neovim"
--- "$XDG_CONFIG_HOME/nvim/init.vim" => "/Users/jdoe/.config/nvim/init.vim"
--- "~/src/nvim/api/../tui/./tui.c" => "/home/jdoe/src/nvim/tui/tui.c"
--- "./foo/bar" => "foo/bar"
--- "foo/../../../bar" => "../../bar"
--- "/home/jdoe/../../../bar" => "/bar"
--- "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
@@ -369,12 +486,21 @@ function M.normalize(path, opts)
vim.validate({
path = { path, { 'string' } },
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
if vim.startswith(path, '~') then
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)
end
path = home .. path:sub(2)
@@ -386,24 +512,35 @@ function M.normalize(path, opts)
end
-- 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
-- POSIX. They are also valid UNC paths. Three or more leading slashes are however collapsed to
-- a single slash.
if vim.startswith(path, '//') and not vim.startswith(path, '///') then
path = '/' .. path:gsub('/+', '/')
else
path = path:gsub('/+', '/')
-- Check for double slashes at the start of the path because they have special meaning
local double_slash = vim.startswith(path, '//') and not vim.startswith(path, '///')
local prefix = ''
if win then
local is_valid --- @type boolean
-- 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
-- Ensure last slash is not truncated from root drive on Windows
if iswin and path:match('^%w:/$') then
return path
end
-- Resolve `.` and `..` components and remove extraneous slashes from path, then recombine prefix
-- and path. Preserve leading double slashes as they indicate UNC paths and DOS device paths in
-- Windows and have implementation-defined behavior in POSIX.
path = (double_slash and '/' or '') .. prefix .. path_resolve_dot(path)
-- Remove trailing slashes
path = path:gsub('(.)/$', '%1')
-- Change empty path to `.`
if path == '' then
path = '.'
end
return path
end