diff options
author | Famiu Haque <famiuhaque@proton.me> | 2024-04-17 01:13:44 +0600 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-04-16 12:13:44 -0700 |
commit | 8e5c48b08dad54706500e353c58ffb91f2684dd3 (patch) | |
tree | ad297ac49606935943b376ed89c0f7c20671f08e /runtime/lua/vim/fs.lua | |
parent | 20b38677c22b0ff19ea54396c7718b5a8f410ed4 (diff) | |
download | rneovim-8e5c48b08dad54706500e353c58ffb91f2684dd3.tar.gz rneovim-8e5c48b08dad54706500e353c58ffb91f2684dd3.tar.bz2 rneovim-8e5c48b08dad54706500e353c58ffb91f2684dd3.zip |
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"
Diffstat (limited to 'runtime/lua/vim/fs.lua')
-rw-r--r-- | runtime/lua/vim/fs.lua | 195 |
1 files changed, 166 insertions, 29 deletions
diff --git a/runtime/lua/vim/fs.lua b/runtime/lua/vim/fs.lua index ad0d914ea2..65ad58c720 100644 --- a/runtime/lua/vim/fs.lua +++ b/runtime/lua/vim/fs.lua @@ -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 - ---- 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. +--- @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. "." 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('/+', '/') - end + -- 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 = '' - -- Ensure last slash is not truncated from root drive on Windows - if iswin and path:match('^%w:/$') then - return path + 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 - -- Remove trailing slashes - path = path:gsub('(.)/$', '%1') + -- 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) + + -- Change empty path to `.` + if path == '' then + path = '.' + end return path end |