From 47494d8de6080749effb02db34ef9748a918b81c Mon Sep 17 00:00:00 2001 From: James Trew Date: Sun, 8 Sep 2024 23:42:38 -0400 Subject: [PATCH] rework env var expansion --- lua/plenary/path2.lua | 142 +++++++++++++++++++++++++---------- tests/plenary/path2_spec.lua | 75 ++++++++++++------ 2 files changed, 155 insertions(+), 62 deletions(-) diff --git a/lua/plenary/path2.lua b/lua/plenary/path2.lua index 38f632ea..a83ce050 100644 --- a/lua/plenary/path2.lua +++ b/lua/plenary/path2.lua @@ -136,31 +136,70 @@ end ---@param parts string[] ---@param sep string ----@return string[] new_path +---@return string[] new_parts function _WindowsPath:expand(parts, sep) - -- Variables have a percent sign on both sides: %ThisIsAVariable% - -- The variable name can include spaces, punctuation and mixed case: - -- %_Another Ex.ample% - -- But they aren't case sensitive - -- - -- A variable name may include any of the following characters: - -- A-Z, a-z, 0-9, # $ ' ( ) * + , - . ? @ [ ] _ { } ~ - -- The first character of the name must not be numeric. - - -- this would be MUCH cleaner to implement with LPEG but backwards compatibility... - local pattern = "%%[A-Za-z#$'()*+,%-.?@[%]_{}~][A-Za-z0-9#$'()*+,%-.?@[%]_{}~]*%%" - local new_parts = {} + + local function add_expand(sub_parts, var, part, start, end_) + ---@diagnostic disable-next-line: missing-parameter + local val = vim.uv.os_getenv(var) + if val then + table.insert(sub_parts, (val:gsub("\\", sep))) + else + table.insert(sub_parts, part:sub(start, end_)) + end + end + for _, part in ipairs(parts) do - part = part:gsub(pattern, function(m) - local var_name = m:sub(2, -2) + local sub_parts = {} + local i = 1 - ---@diagnostic disable-next-line: missing-parameter - local var = uv.os_getenv(var_name) - return var and (var:gsub("\\", sep)) or m - end) + while i <= #part do + local ch = part:sub(i, i) + if ch == "'" then -- no expansion inside single quotes + local end_ = part:find("'", i + 1, true) + if end_ then + table.insert(sub_parts, part:sub(i, end_)) + i = end_ + else + table.insert(sub_parts, ch) + end + elseif ch == "%" then + local end_ = part:find("%", i + 1, true) + if end_ then + local var = part:sub(i + 1, end_ - 1) + add_expand(sub_parts, var, part, i, end_) + i = end_ + else + table.insert(sub_parts, ch) + end + elseif ch == "$" then + local nextch = part:sub(i + 1, i + 1) + if nextch == "$" then + i = i + 1 + table.insert(sub_parts, ch) + elseif nextch == "{" then + local end_ = part:find("}", i + 2, true) + if end_ then + local var = part:sub(i + 2, end_ - 1) + add_expand(sub_parts, var, part, i, end_) + i = end_ + else + table.insert(sub_parts, ch) + end + else + local end_ = part:find("[^%w_]", i + 1, false) or #part + 1 + local var = part:sub(i + 1, end_ - 1) + add_expand(sub_parts, var, part, i, end_ - 1) + i = end_ - 1 + end + else + table.insert(sub_parts, ch) + end + i = i + 1 + end - table.insert(new_parts, part) + table.insert(new_parts, table.concat(sub_parts)) end return new_parts @@ -232,28 +271,47 @@ function _PosixPath:join(path, ...) end ---@param parts string[] ----@return string[] new_path +---@return string[] new_parts function _PosixPath:expand(parts) - -- Environment variable names used by the utilities in the Shell and - -- Utilities volume of IEEE Std 1003.1-2001 consist solely of uppercase - -- letters, digits, and the '_' (underscore) from the characters defined in - -- Portable Character Set and do not begin with a digit. Other characters may - -- be permitted by an implementation; applications shall tolerate the - -- presence of such names. - - local pattern = "%$[A-Z_][A-Z0-9_]*" + local function add_expand(sub_parts, var, part, start, end_) + ---@diagnostic disable-next-line: missing-parameter + local val = vim.uv.os_getenv(var) + if val then + table.insert(sub_parts, val) + else + table.insert(sub_parts, part:sub(start, end_)) + end + end local new_parts = {} for _, part in ipairs(parts) do - part = part:gsub(pattern, function(m) - local var_name = m:sub(2) - - ---@diagnostic disable-next-line: missing-parameter - local var = uv.os_getenv(var_name) - return var or m - end) + local i = 1 + local sub_parts = {} + while i <= #part do + local ch = part:sub(i, i) + if ch == "$" then + if part:sub(i + 1, i + 1) == "{" then + local end_ = part:find("}", i + 2, true) + if end_ then + local var = part:sub(i + 2, end_ - 1) + add_expand(sub_parts, var, part, i, end_) + i = end_ + else + table.insert(sub_parts, ch) + end + else + local end_ = part:find("[^%w_]", i + 1, false) or #part + 1 + local var = part:sub(i + 1, end_ - 1) + add_expand(sub_parts, var, part, i, end_ - 1) + i = end_ - 1 + end + else + table.insert(sub_parts, ch) + end + i = i + 1 + end - table.insert(new_parts, part) + table.insert(new_parts, table.concat(sub_parts)) end return new_parts @@ -714,15 +772,17 @@ function Path:absolute() end --- get the environment variable expanded filename +--- also expand ~/ but NOT ~user/ constructs ---@return string function Path:expand() local relparts = self._flavor:expand(self.relparts, self.sep) local filename = self:_filename(nil, nil, relparts) - filename = filename:gsub("^~([^" .. self.sep .. "]+)" .. self.sep, function(m) - return Path:new(self.path.home):parent().filename .. self.sep .. m .. self.sep - end) - return (filename:gsub("^~", self.path.home)) + if filename:sub(1, 2) == "~" .. self.sep then + filename = self.path.home .. filename:sub(2) + end + + return filename end ---@param ... plenary.Path2Args diff --git a/tests/plenary/path2_spec.lua b/tests/plenary/path2_spec.lua index 4bdcb0bb..95c8b475 100644 --- a/tests/plenary/path2_spec.lua +++ b/tests/plenary/path2_spec.lua @@ -1249,20 +1249,32 @@ SOFTWARE.]] uv.os_setenv("FOOVAR", "foo") uv.os_setenv("BARVAR", "bar") - describe("unix", function() - if iswin then - return - end + it_cross_plat("match simple valid $ env vars", function() + assert.are.same("foo", Path:new("$FOOVAR"):expand()) + assert.are.same("foo$", Path:new("$FOOVAR$"):expand()) + assert.are.same(Path:new("foo/bar/baz").filename, Path:new("$FOOVAR/$BARVAR/baz"):expand()) + assert.are.same(Path:new("foo/bar baz").filename, Path:new("$FOOVAR/$BARVAR baz"):expand()) + assert.are.same(Path:new("foo/$BARVARbaz").filename, Path:new("$FOOVAR/$BARVARbaz"):expand()) + end) - it("match valid env var", function() - local p = Path:new "$FOOVAR/$BARVAR/baz" - assert.are.same("foo/bar/baz", p:expand()) - end) + it_cross_plat("match simple valid $ env vars with braces", function() + assert.are.same(Path:new("foo/bar/baz").filename, Path:new("${FOOVAR}/${BARVAR}/baz"):expand()) + assert.are.same(Path:new("foo/bar baz").filename, Path:new("${FOOVAR}/${BARVAR} baz"):expand()) + end) - it("ignore invalid env var", function() - local p = Path:new "foo/$NOT_A_REAL_ENV_VAR/baz" - assert.are.same(p.filename, p:expand()) - end) + it_cross_plat("ignore unset $ env var", function() + local p = Path:new "foo/$NOT_A_REAL_ENV_VAR/baz" + assert.are.same(p.filename, p:expand()) + end) + + it_cross_plat("ignore empty $", function() + local p = Path:new "foo/$/bar$baz$" + assert.are.same(p.filename, p:expand()) + end) + + it_cross_plat("ignore empty ${}", function() + local p = Path:new "foo/${}/bar${}" + assert.are.same(p.filename, p:expand()) end) describe("windows", function() @@ -1270,16 +1282,39 @@ SOFTWARE.]] return end - it_win("match valid env var", function() - local p = Path:new "%foovar%/%BARVAR%/baz" - local expect = Path:new "foo/bar/baz" - assert.are.same(expect.filename, p:expand()) + uv.os_setenv("{foovar", "foo1") + uv.os_setenv("{foovar}", "foo2") + + it_win("match valid %% env var", function() + assert.are.same(Path:new("foo/bar/baz").filename, Path:new("%foovar%/%BARVAR%/baz"):expand()) + assert.are.same(Path:new("foo1/bar/baz").filename, Path:new("%{foovar%/%BARVAR%/baz"):expand()) + assert.are.same(Path:new("foo2/bar/baz").filename, Path:new("%{foovar}%/%BARVAR%/baz"):expand()) + assert.are.same(Path:new("foo/bar baz").filename, Path:new("%foovar%/%BARVAR% baz"):expand()) + end) + + it_win("empty %%", function() + local p = Path:new "foo/%%/baz%%" + assert.are.same(p.filename, p:expand()) end) - it_win("ignore invalid env var", function() + it_win("match special char env var with ${}", function() + assert.are.same(Path:new("foo1/bar/baz").filename, Path:new("${{foovar}/%BARVAR%/baz"):expand()) + assert.are.same(Path:new("foo1}/bar/baz").filename, Path:new("${{foovar}}/%BARVAR%/baz"):expand()) + end) + + it_win("ignore unset %% env var", function() local p = Path:new "foo/%NOT_A_REAL_ENV_VAR%/baz" assert.are.same(p.filename, p:expand()) end) + + it_win("ignore quoted vars", function() + local paths = { "'%foovar%'", "'${foovar}'", "'$foovar'" } + for _, p in ipairs(paths) do + ---@diagnostic disable-next-line: cast-local-type + p = Path:new(p) + assert.are.same(p.filename, p:expand()) + end + end) end) it_cross_plat("matches ~", function() @@ -1288,11 +1323,9 @@ SOFTWARE.]] assert.are.same(expect.filename, p:expand()) end) - it_cross_plat("matches ~user", function() + it_cross_plat("does not matches ~user", function() local p = Path:new "~otheruser/hello" - local home = Path:new(path.home):parent() / "otheruser" - local expect = home / "hello" - assert.are.same(expect.filename, p:expand()) + assert.are.same(p.filename, p:expand()) end) uv.os_unsetenv "FOOVAR"