Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ __pycache__

.direnv/
.testenv/
oil_test_*/
venv/
doc/tags
scripts/nvim_doc_tools
Expand Down
38 changes: 28 additions & 10 deletions lua/oil/fs.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@ local M = {}

local uv = vim.uv or vim.loop

---@type boolean
M.is_windows = uv.os_uname().version:match("Windows")
M._initialize_environment = function()
---@type boolean
M.is_windows = uv.os_uname().version:match("Windows")

M.is_mac = uv.os_uname().sysname == "Darwin"
M.is_mac = uv.os_uname().sysname == "Darwin"

M.is_linux = not M.is_windows and not M.is_mac
M.is_linux = not M.is_windows and not M.is_mac

---@type string
M.sep = M.is_windows and "\\" or "/"
---@type string
M.sep = M.is_windows and "\\" or "/"
end
M._initialize_environment()

---@param ... string
M.join = function(...)
Expand Down Expand Up @@ -81,11 +84,25 @@ M.is_subpath = function(root, candidate)
return candidate_starts_with_sep or root_ends_with_sep
end

---@param path string
---@return string
local function normalized_path_seperators(path)
local leading_slashes, rem = path:match("^([\\/]*)(.*)$")
local normalized_rem = rem:gsub("[\\/]+", M.sep)
local normalized_leading = ""
if #leading_slashes >= 2 then
normalized_leading = M.sep .. M.sep
elseif #leading_slashes == 1 then
normalized_leading = M.sep
end
return string.format("%s%s", normalized_leading, normalized_rem)
end
Comment on lines +87 to +99
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make use of vim.fs.normalize for any of this functionality? It seems to do some similar things (preserving leading double slashes) and I'd like to rely on the Neovim libs whenever possible.

Copy link
Copy Markdown
Author

@crwebb85 crwebb85 Dec 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make use of vim.fs.normalize for any of this functionality?

In nightly it looks like it would work. I still need to try it out in the oil code but from some quick testing I think it would work, However, I don't think it would work if you want to continue supporting Neovim 0.8+. There were a lot of improvements to vim.fs.normalize in 2024 and one improvement in 2025 that we may need for it to function correctly.

https://github.com/neovim/neovim/blob/b1ae775de618e3e87954a88d533ec17bbef41cdf/runtime/lua/vim/fs.lua#L209-L231
https://github.com/neovim/neovim/blob/0ef27180e31671a043b28547da327cd52f1a87c4/runtime/lua/vim/fs.lua#L306-L352
https://github.com/neovim/neovim/blob/0c995c0efba092f149fc314a43327db7d105e1ad/runtime/lua/vim/fs.lua#L504-L612
https://github.com/neovim/neovim/blob/c1c007813884075a970198fbba918d568428d739/runtime/lua/vim/fs.lua#L587-L688
https://github.com/neovim/neovim/blob/5370b7a2e0a0484c9005cb5a727dffa5ef13b1ed/runtime/lua/vim/fs.lua#L596-L697

I could also do the normal if has version use vim.fs.normalize else use a copy of nightly's version if that sounds better.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neovim 0.9 came out almost 3 years ago, so I'm fine to drop support for 0.8 now. I only kept it this long because there was no strong motivation to remove it, but now there is!


---@param path string
---@return string
M.posix_to_os_path = function(path)
if M.is_windows then
if vim.startswith(path, "/") then
if vim.startswith(path, "/") and not vim.startswith(path, "//") then
local drive = path:match("^/(%a+)")
local rem = path:sub(drive:len() + 2)
return string.format("%s:%s", drive, rem:gsub("/", "\\"))
Expand All @@ -102,11 +119,12 @@ end
---@return string
M.os_to_posix_path = function(path)
if M.is_windows then
if M.is_absolute(path) then
local drive, rem = path:match("^([^:]+):\\(.*)$")
local normalized_path = normalized_path_seperators(path)
if M.is_absolute(normalized_path) then
local drive, rem = normalized_path:match("^([^:]+):\\(.*)$")
return string.format("/%s/%s", drive:upper(), rem:gsub("\\", "/"))
else
local newpath = path:gsub("\\", "/")
local newpath = normalized_path:gsub("\\", "/")
return newpath
end
else
Expand Down
46 changes: 46 additions & 0 deletions run_tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env pwsh
$ErrorActionPreference = "Stop"

# Create required directories
$dirs = @(
".testenv/config/nvim",
".testenv/data/nvim",
".testenv/state/nvim",
".testenv/run/nvim",
".testenv/cache/nvim"
)
foreach ($dir in $dirs) {
New-Item -ItemType Directory -Force -Path $dir | Out-Null
}

# Plugin directory
$PLUGINS = ".testenv/data/nvim-data/site/pack/plugins/start"

# Ensure plenary.nvim is present
$plenaryPath = Join-Path $PLUGINS "plenary.nvim"
if (-Not (Test-Path $plenaryPath)) {
git clone --depth=1 https://github.com/nvim-lua/plenary.nvim.git $plenaryPath
} else {
Push-Location $plenaryPath
git pull
Pop-Location
}

# Set environment variables
$env:XDG_CONFIG_HOME = ".testenv/config"
$env:XDG_DATA_HOME = ".testenv/data"
$env:XDG_STATE_HOME = ".testenv/state"
$env:XDG_RUNTIME_DIR = ".testenv/run"
$env:XDG_CACHE_HOME = ".testenv/cache"

# Run Neovim tests
$nvimArgs = @(
"--headless",
"-u", "./tests/minimal_init.lua",
"-c", "PlenaryBustedDirectory $($args[0] ?? 'tests') { minimal_init = './tests/minimal_init.lua' }"
)

nvim @nvimArgs

Write-Host "Success"

43 changes: 36 additions & 7 deletions tests/files_spec.lua
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
require("plenary.async").tests.add_to_env()
local TmpDir = require("tests.tmpdir")
local files = require("oil.adapters.files")
local fs = require("oil.fs")
local test_util = require("tests.test_util")

a.describe("files adapter", function()
Expand All @@ -15,13 +16,25 @@ a.describe("files adapter", function()
test_util.reset_editor()
end)

a.it("tmpdir creates files and asserts they exist", function()
tmpdir:create({ "a.txt", "foo/b.txt", "foo/c.txt", "bar/" })
a.it("tmpdir creates file and asserts it exists", function()
tmpdir:create({ "a.txt" })
tmpdir:assert_fs({
["a.txt"] = "a.txt",
})
end)

a.it("tmpdir creates directory and asserts it exists", function()
tmpdir:create({ "bar/" })
tmpdir:assert_fs({
["bar/"] = true,
})
end)

a.it("tmpdir creates directory and files and asserts they exist", function()
tmpdir:create({ "foo/b.txt", "foo/c.txt" })
tmpdir:assert_fs({
["foo/b.txt"] = "foo/b.txt",
["foo/c.txt"] = "foo/c.txt",
["bar/"] = true,
})
end)

Expand Down Expand Up @@ -147,16 +160,32 @@ a.describe("files adapter", function()
})
end)

a.it("Editing a new oil://path/ creates an oil buffer", function()
a.it("Editing a new unnormalized oil://path/ creates an oil buffer", function()
local tmpdir_url = "oil://" .. vim.fn.fnamemodify(tmpdir.path, ":p") .. "/"
vim.cmd.edit({ args = { tmpdir_url } })
test_util.wait_oil_ready()
local new_url = "oil://" .. vim.fn.fnamemodify(tmpdir.path, ":p") .. "newdir"
vim.cmd.edit({ args = { new_url } })
local unnormalized_url = "oil://" .. vim.fn.fnamemodify(tmpdir.path, ":p") .. "newdir"
local normalized_url = "oil://"
.. fs.os_to_posix_path(vim.fn.fnamemodify(tmpdir.path, ":p"))
.. "newdir/"
vim.cmd.edit({ args = { unnormalized_url } })
test_util.wait_oil_ready()
assert.equals("oil", vim.bo.filetype)
-- The normalization will add a '/'
assert.equals(new_url .. "/", vim.api.nvim_buf_get_name(0))
assert.equals(normalized_url, vim.api.nvim_buf_get_name(0))
end)

a.it("Editing a new normalized oil://path/ creates an oil buffer", function()
local tmpdir_url = "oil://" .. vim.fn.fnamemodify(tmpdir.path, ":p") .. "/"
vim.cmd.edit({ args = { tmpdir_url } })
test_util.wait_oil_ready()
local new_url = "oil://"
.. fs.os_to_posix_path(vim.fn.fnamemodify(tmpdir.path, ":p"))
.. "newdir/"
vim.cmd.edit({ args = { new_url } })
test_util.wait_oil_ready()
assert.equals("oil", vim.bo.filetype)
assert.equals(new_url, vim.api.nvim_buf_get_name(0))
end)

a.it("Editing a new oil://file.rb creates a normal buffer", function()
Expand Down
62 changes: 62 additions & 0 deletions tests/fs_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
require("plenary.async").tests.add_to_env()
local fs = require("oil.fs")

local function set_env_windows()
fs.is_windows = true
fs.is_mac = false
fs.is_linux = false
fs.sep = "\\"
end

local function set_env_linux()
fs.is_windows = false
fs.is_mac = false
fs.is_linux = true
fs.sep = "/"
end

a.describe("File system", function()
after_each(function()
fs._initialize_environment()
end)

a.it("converts linux path to posix", function()
set_env_linux()
assert.equals("/a/b/c", fs.os_to_posix_path("/a/b/c"))
end)

a.it("converts Windows local path to posix", function()
set_env_windows()
assert.equals("/C/a/b/c", fs.os_to_posix_path("C:\\a\\b\\c"))
end)

a.it("converts Windows local path with extra backslash path seperators to posix", function()
set_env_windows()
assert.equals("/C/a/b/c", fs.os_to_posix_path("C:\\\\a\\b\\c"))
end)

a.it("converts Windows local path with forward slashes to posix", function()
set_env_windows()
assert.equals("/C/a/b/c", fs.os_to_posix_path("C:/a/b/c"))
end)

a.it("converts Windows UNC path to posix", function()
set_env_windows()
assert.equals("//a/b/c", fs.os_to_posix_path("\\\\a\\b\\c"))
end)

a.it("converts posix to linux path", function()
set_env_linux()
assert.equals("/a/b/c", fs.posix_to_os_path("/a/b/c"))
end)

a.it("converts posix to Windows local path", function()
set_env_windows()
assert.equals("C:\\a\\b\\c", fs.posix_to_os_path("/C/a/b/c"))
end)

a.it("converts posix to Windows UNC path", function()
set_env_windows()
assert.equals("\\\\a\\b\\c", fs.posix_to_os_path("//a/b/c"))
end)
end)
14 changes: 11 additions & 3 deletions tests/tmpdir.lua
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,15 @@ end
---@param paths string[]
function TmpDir:create(paths)
for _, path in ipairs(paths) do
local pieces = vim.split(path, fs.sep)
local pieces = vim.split(path, "[\\/]")
local partial_path = self.path
for i, piece in ipairs(pieces) do
partial_path = fs.join(partial_path, piece)
if i == #pieces and not vim.endswith(partial_path, fs.sep) then
if
i == #pieces
and not vim.endswith(partial_path, "/")
and not vim.endswith(partial_path, "\\")
then
await(touch, 2, partial_path)
elseif not exists(partial_path) then
vim.loop.fs_mkdir(partial_path, 493)
Expand Down Expand Up @@ -90,7 +94,7 @@ end
local assert_fs = function(root, paths)
local unlisted_dirs = {}
for k in pairs(paths) do
local pieces = vim.split(k, "/")
local pieces = vim.split(k, "[/\\]")
local partial_path = ""
for i, piece in ipairs(pieces) do
partial_path = partial_path .. piece .. "/"
Expand All @@ -110,11 +114,15 @@ local assert_fs = function(root, paths)
if entry.type == "directory" then
shortpath = shortpath .. "/"
end
shortpath = shortpath:gsub("\\", "/")
local expected_content = paths[shortpath]
paths[shortpath] = nil
assert.truthy(expected_content, string.format("Unexpected entry '%s'", shortpath))
if entry.type == "file" then
local data = read_file(fullpath)
if data ~= nil then
data = data:gsub("\\", "/")
end
assert.equals(
expected_content,
data,
Expand Down
32 changes: 21 additions & 11 deletions tests/url_spec.lua
Original file line number Diff line number Diff line change
@@ -1,25 +1,35 @@
local oil = require("oil")
local util = require("oil.util")

local uv = vim.uv or vim.loop

describe("url", function()
it("get_url_for_path", function()
local cases = {
{ "", "oil://" .. util.addslash(vim.fn.getcwd()) },
{ "term://~/oil.nvim//52953:/bin/sh", "oil://" .. vim.loop.os_homedir() .. "/oil.nvim/" },
{ "/foo/bar.txt", "oil:///foo/", "bar.txt" },
{ "", "oil://" .. util.addslash(vim.fn.getcwd()), skip_on_windows = true },
{
"term://~/oil.nvim//52953:/bin/sh",
"oil://" .. vim.loop.os_homedir() .. "/oil.nvim/",
skip_on_windows = true,
},
{ "/foo/bar.txt", "oil:///foo/", "bar.txt", skip_on_windows = true },
{ "oil:///foo/bar.txt", "oil:///foo/", "bar.txt" },
{ "oil:///", "oil:///" },
{ "oil-ssh://user@hostname:8888//bar.txt", "oil-ssh://user@hostname:8888//", "bar.txt" },
{ "oil-ssh://user@hostname:8888//", "oil-ssh://user@hostname:8888//" },
}
for _, case in ipairs(cases) do
local input, expected, expected_basename = unpack(case)
local output, basename = oil.get_buffer_parent_url(input, true)
assert.equals(expected, output, string.format('Parent url for path "%s" failed', input))
assert.equals(
expected_basename,
basename,
string.format('Basename for path "%s" failed', input)
)
local is_skip = case.skip_on_windows and uv.os_uname().version:match("Windows")
if not is_skip then
local input, expected, expected_basename = unpack(case)
local output, basename = oil.get_buffer_parent_url(input, true)
assert.equals(expected, output, string.format('Parent url for path "%s" failed', input))
assert.equals(
expected_basename,
basename,
string.format('Basename for path "%s" failed', input)
)
end
end
end)
end)
Loading