Skip to content

Commit

Permalink
feat(base): add methods to calculate relativity
Browse files Browse the repository at this point in the history
  • Loading branch information
pysan3 committed Feb 16, 2024
1 parent c2bc242 commit d92854b
Show file tree
Hide file tree
Showing 4 changed files with 245 additions and 5 deletions.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ assert(foo == Path(folder, "foo.txt")) -- Unpack any of them
-- Create siblings (just like `./<foo>/../bar.txt`)
local bar = foo .. "bar.txt"
assert(tostring(bar) == "folder/bar.txt")

-- Calculate relativily
assert(foo:is_relative_to(Path("./folder")))
assert(not foo:is_relative_to(Path("./different folder")))
assert(foo:relative_to(folder) == Path("foo.txt"))
```

### Path object is stored with `string[]`.
Expand Down
7 changes: 6 additions & 1 deletion README.norg
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description:
authors: takuto
categories:
created: 2023-11-14
updated: 2024-01-31T20:12:30+0900
updated: 2024-02-16T11:36:41+0900
version: 1.1.1
@end

Expand Down Expand Up @@ -106,6 +106,11 @@ version: 1.1.1
-- Create siblings (just like `./<foo>/../bar.txt`)
local bar = foo .. "bar.txt"
assert(tostring(bar) == "folder/bar.txt")

-- Calculate relativily
assert(foo:is_relative_to(Path("folder")))
assert(not foo:is_relative_to(Path("./different folder")))
assert(foo:relative_to(folder) == Path("foo.txt"))
@end

*** Path object is stored with `string[]`.
Expand Down
86 changes: 82 additions & 4 deletions lua/pathlib/base.lua
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,75 @@ function Path:is_relative()
return not self:is_absolute()
end

---Compute a version of this path relative to the path represented by other.
---If it’s impossible, nil is returned and `self.error_msg` is modified.
---When `walk_up == false` (the default), the path must start with other.
---When the argument is true, `../` entries may be added to form the relative path
---but this function DOES NOT check the actual filesystem for file existence.
---In all other cases, such as the paths referencing different drives, nil is returned and `self.error_msg` is modified.
---If only one of `self` or `other` is relative, return nil and `self.error_msg` is modified.
---```lua
--->>> p = Path("/etc/passwd")
--->>> p:relative_to(Path("/"))
---Path.new("etc/passwd")
--->>> p:relative_to(Path("/usr"))
---nil; p.error_msg = "'%s' is not in the subpath of '%s'."
--->>> p:relative_to(Path("C:/foo"))
---nil; p.error_msg = "'%s' is not on the same disk as '%s'."
--->>> p:relative_to(Path("./foo"))
---nil; p.error_msg = "Only one path is relative: '%s', '%s'."
---```
---@param other PathlibPath
---@param walk_up boolean|nil # If true, uses `../` to make relative path.
function Path:relative_to(other, walk_up)
if self:is_absolute() and other:is_absolute() then
if self._drive_name ~= other._drive_name then
self.error_msg = string.format("'%s' is not on the same disk as '%s'.", self, other)
return nil
end
elseif self:is_relative() and other:is_relative() then
else
self.error_msg = string.format("Only one path is relative: '%s', '%s'.", self, other)
return nil
end
if not walk_up and not self:is_relative_to(other) then
self.error_msg = string.format("'%s' is not in the subpath of '%s'.", self, other)
return nil
end
local result = self:deep_copy()
result._raw_paths:clear()
result._drive_name = ""
local index = other:len()
while index > 0 and self._raw_paths[index] ~= other._raw_paths[index] do
result._raw_paths:append("..")
index = index - 1
end
for i = index + 1, self:len() do
if self._raw_paths[i] and self._raw_paths[i]:len() > 0 then
result._raw_paths:append(self._raw_paths[i])
end
end
self:__clean_paths_list()
return result
end

---Return whether or not this path is relative to the `other` path.
---This is a wrapper of `vim.startswith(tostring(self), tostring(other))` and nothing else.
---It neither accesses the filesystem nor treats “..” segments specially.
---Use `self:absolute()` or `self:to_absolute()` beforehand if needed.
---`other` may be a string, but MUST use the same path separators.
---```lua
--->>> p = Path("/etc/passwd")
--->>> p:is_relative_to("/etc") -- Must be [[\etc]] on Windows.
---true
--->>> p:is_relative_to(Path("/usr"))
---false
---```
---@param other PathlibPath|PathlibString
function Path:is_relative_to(other)
return vim.startswith(tostring(self), tostring(other))
end

function Path:as_posix()
if not utils.tables.is_type_of(self, const.path_module_enum.PathlibWindows) then
return self:tostring()
Expand All @@ -290,19 +359,28 @@ function Path:as_posix()
return (self:tostring():gsub(self.sep_str .. "+", require("pathlib.posix").sep_str))
end

function Path:absolute()
---Returns a new path object with absolute path.
---Use `self:to_absolute()` instead to modify the object itself which does not need a deepcopy.
---If `self` is already an absolute path, returns itself.
---@param cwd PathlibPath|nil # If passed, this is used instead of `vim.fn.getcwd()`.
---@return PathlibPath
function Path:absolute(cwd)
if self:is_absolute() then
return self
else
return self.new(vim.fn.getcwd(), self)
return self.new(cwd or vim.fn.getcwd(), self)
end
end

function Path:to_absolute()
---Modifies itself to point to an absolute path.
---Use `self:absolute()` instead to return a new path object without modifying self.
---If `self` is already an absolute path, does nothing.
---@param cwd PathlibPath|nil # If passed, this is used instead of `vim.fn.getcwd()`.
function Path:to_absolute(cwd)
if self:is_absolute() then
return
end
local new = self.new(vim.fn.getcwd(), self)
local new = self.new(cwd or vim.fn.getcwd(), self)
self._raw_paths:clear()
self:copy_all_from(new)
end
Expand Down
152 changes: 152 additions & 0 deletions spec/path_relative_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
describe("Test Relative Path Detection and Generation", function()
local Posix = require("pathlib.posix")
local Windows = require("pathlib.windows")
local relp = Posix.new("./folder/foo.txt")
local parent_relp = Posix.new("./folder")
local absp = Posix.new("/etc/passwd")
local parent_absp = Posix.new("/etc")
local relw = Windows.new("./folder/foo.txt")
local parent_relw = Windows.new("./folder")
local absw = Windows.new("C:/foo/bar.txt")
local parent_absw = Windows.new("C:/foo")

describe("is_relative", function()
it("()", function()
assert.is_true(relp:is_relative())
assert.is_not_true(absp:is_relative())
assert.is_not_true(Posix.cwd():is_relative())
end)
end)

describe("is_absolute", function()
it("()", function()
assert.is_not_true(relp:is_absolute())
assert.is_true(absp:is_absolute())
assert.is_true(Posix.cwd():is_absolute())
end)
end)

describe("is_relative_to", function()
it("vs parent", function()
local parent = relp:parent()
assert.is_not_nil(parent)
assert.is_true(parent and relp:is_relative_to(parent))
end)
it("vs string posix", function()
assert.is_true(relp:is_relative_to("folder"))
assert.is_true(relp:is_relative_to("folder/"))
assert.is_not_true(relp:is_relative_to([[folder\]]))
end)
it("vs string windows", function()
assert.is_true(relw:is_relative_to("folder"))
assert.is_not_true(relw:is_relative_to("folder/"))
assert.is_true(relw:is_relative_to([[folder\]]))
end)
it("vs string posix absolute", function()
assert.is_true(absp:is_relative_to("/etc"))
assert.is_not_true(absp:is_relative_to("/usr"))
end)
it("vs string windows absolute", function()
assert.is_true(absw:is_relative_to([[C:\]]))
assert.is_not_true(absw:is_relative_to([[C:/]]))
assert.is_true(absw:is_relative_to([[C:\foo\]]))
assert.is_not_true(absw:is_relative_to([[C:/foo/]]))
end)
it("vs parent posix absolute", function()
assert.is_true(absp:is_relative_to(absp:parent())) ---@diagnostic disable-line
assert.is_true(absp:is_relative_to(absp:parent_string())) ---@diagnostic disable-line
end)
it("vs parent windows absolute", function()
assert.is_true(absw:is_relative_to(absw:parent())) ---@diagnostic disable-line
assert.is_true(absw:is_relative_to(absw:parent_string())) ---@diagnostic disable-line
end)
end)

describe("relative_to", function()
it("works", function()
assert.is_not_nil(relp:relative_to(parent_relp))
assert.is_not_nil(absp:relative_to(parent_absp))
assert.is_not_nil(relw:relative_to(parent_relw))
assert.is_not_nil(absw:relative_to(parent_absw))
end)
it("return is relative", function()
assert.is_true(relp:relative_to(parent_relp):is_relative()) ---@diagnostic disable-line
assert.is_true(absp:relative_to(parent_absp):is_relative()) ---@diagnostic disable-line
assert.is_true(relw:relative_to(parent_relw):is_relative()) ---@diagnostic disable-line
assert.is_true(absw:relative_to(parent_absw):is_relative()) ---@diagnostic disable-line
end)
it("validate return value", function()
assert.are.equal("foo.txt", relp:relative_to(parent_relp):tostring()) ---@diagnostic disable-line
assert.are.equal(relp:basename(), relp:relative_to(parent_relp):tostring()) ---@diagnostic disable-line
assert.are.equal(relp.new(relp:basename()), relp:relative_to(parent_relp)) ---@diagnostic disable-line
assert.are.equal(absp:basename(), absp:relative_to(parent_absp):tostring()) ---@diagnostic disable-line
assert.are.equal(absp.new(absp:basename()), absp:relative_to(parent_absp)) ---@diagnostic disable-line
assert.are.equal(relw:basename(), relw:relative_to(parent_relw):tostring()) ---@diagnostic disable-line
assert.are.equal(relw.new(relw:basename()), relw:relative_to(parent_relw)) ---@diagnostic disable-line
assert.are.equal(absw:basename(), absw:relative_to(parent_absw):tostring()) ---@diagnostic disable-line
assert.are.equal(absw.new(absw:basename()), absw:relative_to(parent_absw)) ---@diagnostic disable-line
end)
end)

describe("relative_to invalid parents", function()
local errs = {
NOT_SUBPATH = "not in the subpath of",
ONE_IS_REL = "one path is relative",
ANOTHER_DISK = "not on the same disk",
}
local tests = {
{ absp, "/usr", errs.NOT_SUBPATH },
{ absp, relp, errs.ONE_IS_REL },
{ relp, absp, errs.ONE_IS_REL },
{ relp, "bar", errs.NOT_SUBPATH },
{ relp, "baz/baz", errs.NOT_SUBPATH },
{ absw, relw, errs.ONE_IS_REL },
{ relw, absw, errs.ONE_IS_REL },
{ absw, "D:/foo", errs.ANOTHER_DISK },
{ absw, "C:/bar", errs.NOT_SUBPATH },
{ relw, "bar", errs.NOT_SUBPATH },
}
for _, t in ipairs(tests) do
local dst = t[1].new(t[2])
it(string.format([[no walk_up: %s - %s -> %s]], t[1], dst, t[3]), function()
local res = t[1]:relative_to(dst)
assert.is_nil(res)
assert.is_not_nil(string.find(t[1].error_msg, t[3]))
end)
it(string.format([[walk_up: %s - %s -> %s]], t[1], dst, t[3]), function()
local res = t[1]:relative_to(dst, true)
if t[3] == errs.NOT_SUBPATH then
assert.is_not_nil(res)
else
assert.is_nil(res)
assert.is_not_nil(string.find(t[1].error_msg, t[3]))
end
end)
end
end)

describe("relative_to walk_up", function()
local tests = {
{ absp, "/usr", 1 },
{ relp, "bar", 1 },
{ relp, "baz/baz", 2 },
{ absw, "C:/bar", 1 },
{ relw, "bar", 1 },
}
for _, t in ipairs(tests) do
local dst = t[1].new(t[2])
local walkups = string.rep(".." .. t[1].sep_str, t[3])
it(string.format([[%s - %s -> '%s']], t[1], dst, walkups), function()
local res = t[1]:relative_to(dst, true)
assert.is_not_nil(res)
assert.is_true(vim.startswith(tostring(res), walkups))
local strip_abs = tostring(t[1]):sub(t[1]._drive_name:len() + 1)
if vim.startswith(strip_abs, t[1].sep_str) then
strip_abs = strip_abs:sub(2)
end
assert.is_true(vim.endswith(tostring(res), strip_abs))
assert.are.equal(walkups:len() + strip_abs:len(), tostring(res):len())
end)
end
end)
end)

0 comments on commit d92854b

Please sign in to comment.