Repository: nvim-lua/plenary.nvim (lua)

Issue:
Path:normalize() breaks when path contains ...
As an example, a NextJS application can have files named in this format `pages/api/user/[...ids].ts`. Normalizing this path causes it to come back prefixed with the root (`/`). This is causing issues in telescope where it can't open files with this naming style.

See: https://github.com/nvim-telescope/telescope.nvim/issues/1171

I think this is because of this line: https://github.com/nvim-lua/plenary.nvim/blob/6bb0e09030a7c2af19bf288088ca815228de9429/lua/plenary/path.lua#L86

I have a possible fix and test (other tests still pass) which I'll create PR for

Pull request description:
This PR is an attempt to fix #250 which came from this issue in telescope: https://github.com/nvim-telescope/telescope.nvim/issues/1171

The issue is that when a file contains `...`, it ends up prefixed with a `/` (probably different on windows but I can't test that). This is a valid filename in frameworks like NextJS ([example](https://nextjs.org/docs/routing/introduction#dynamic-route-segments))

The change I made to `path.lua` fixes the issue apart from when the paths also has `..` segments. I could use some help figuring that part out. I think it's to do with `path.root()` but I'm not too sure why the behaviour is affected by the filename as I'm on linux and from what I can tell, that will always return `/`?

### Todo
- [x] Properly normalize files containing `...`, i.e. `dir1/[...test].ts`
- [x] Properly normalize files containing both `...` and `..` segments, i.e. `dir1/dir2/../[...test].ts`

Patch:
diff --git a/lua/plenary/path.lua b/lua/plenary/path.lua
index 9eb0b175..36522cb3 100644
--- a/lua/plenary/path.lua
+++ b/lua/plenary/path.lua
@@ -76,29 +76,50 @@ local is_uri = function(filename)
   return string.match(filename, "^%w+://") ~= nil
 end
 
-local function _normalize_path(filename)
+local is_absolute = function(filename, sep)
+  if sep == "\\" then
+    return string.match(filename, "^[A-Z]:\\.*$")
+  end
+  return string.sub(filename, 1, 1) == sep
+end
+
+local function _normalize_path(filename, cwd)
   if is_uri(filename) then
     return filename
   end
 
   local out_file = filename
 
-  local has = string.find(filename, "..", 1, true)
+  local has = string.find(filename, path.sep .. "..", 1, true) or string.find(filename, ".." .. path.sep, 1, true)
 
   if has then
     local parts = _split_by_separator(filename)
 
     local idx = 1
+    local initial_up_count = 0
+
     repeat
       if parts[idx] == ".." then
+        if idx == 1 then
+          initial_up_count = initial_up_count + 1
+        end
         table.remove(parts, idx)
         table.remove(parts, idx - 1)
-        idx = idx - 2
+        if idx > 1 then
+          idx = idx - 2
+        else
+          idx = idx - 1
+        end
       end
       idx = idx + 1
     until idx > #parts
 
-    out_file = path.root(filename) .. table.concat(parts, path.sep)
+    local prefix = ""
+    if is_absolute(filename, path.sep) or #_split_by_separator(cwd) == initial_up_count then
+      prefix = path.root(filename)
+    end
+
+    out_file = prefix .. table.concat(parts, path.sep)
   end
 
   return out_file
@@ -251,9 +272,9 @@ end
 
 function Path:absolute()
   if self:is_absolute() then
-    return _normalize_path(self.filename)
+    return _normalize_path(self.filename, self._cwd)
   else
-    return _normalize_path(self._absolute or table.concat({ self._cwd, self.filename }, self._sep))
+    return _normalize_path(self._absolute or table.concat({ self._cwd, self.filename }, self._sep), self._cwd)
   end
 end
 
@@ -321,7 +342,7 @@ function Path:normalize(cwd)
   -- Substitute home directory w/ "~"
   self.filename = self.filename:gsub("^" .. path.home, "~", 1)
 
-  return _normalize_path(self.filename)
+  return _normalize_path(self.filename, self._cwd)
 end
 
 local function shorten_len(filename, len)
@@ -532,10 +553,7 @@ function Path:is_dir()
 end
 
 function Path:is_absolute()
-  if self._sep == "\\" then
-    return string.match(self.filename, "^[A-Z]:\\.*$")
-  end
-  return string.sub(self.filename, 1, 1) == self._sep
+  return is_absolute(self.filename, self._sep)
 end
 -- }}}
 


Test patch:
diff --git a/tests/plenary/path_spec.lua b/tests/plenary/path_spec.lua
index a4da2147..2980f2a9 100644
--- a/tests/plenary/path_spec.lua
+++ b/tests/plenary/path_spec.lua
@@ -185,6 +185,26 @@ describe("Path", function()
       local final = Path:new(orig):normalize()
       assert.are.same(final, "/lua/plenary/path.lua")
     end)
+
+    it("can normalize relative paths", function()
+      assert.are.same(Path:new("lua/plenary/path.lua"):normalize(), "lua/plenary/path.lua")
+    end)
+
+    it("can normalize relative paths containing ..", function()
+      assert.are.same(Path:new("lua/plenary/path.lua/../path.lua"):normalize(), "lua/plenary/path.lua")
+    end)
+
+    it("can normalize relative paths with initial ..", function()
+      local p = Path:new "../lua/plenary/path.lua"
+      p._cwd = "/tmp/lua"
+      assert.are.same("lua/plenary/path.lua", p:normalize())
+    end)
+
+    it("can normalize relative paths to absolute when initial .. count matches cwd parts", function()
+      local p = Path:new "../../tmp/lua/plenary/path.lua"
+      p._cwd = "/tmp/lua"
+      assert.are.same("/tmp/lua/plenary/path.lua", p:normalize())
+    end)
   end)
 
   describe(":shorten", function()