diff --git a/.cargo/config.toml b/.cargo/config.toml index 95ea9fad7f..29d817f610 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,4 +1,3 @@ -#paths = ["../vfox.rs"] #paths = ["../usage/lib"] [env] diff --git a/Cargo.lock b/Cargo.lock index 433c849590..7fe7981783 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1295,6 +1295,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" dependencies = [ "log", + "regex", ] [[package]] @@ -1312,6 +1313,7 @@ dependencies = [ "anstream", "anstyle", "env_filter", + "jiff", "log", ] @@ -6498,11 +6500,12 @@ dependencies = [ [[package]] name = "vfox" version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed8b4515b35f86ca1d3ea7c936e704ade91ec9f609000f3de82febdea27406da" dependencies = [ + "clap", + "env_logger", "homedir", "indexmap 2.10.0", + "insta", "itertools 0.14.0", "log", "mlua", diff --git a/Cargo.toml b/Cargo.toml index c86b40aac6..f2923acfd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,6 @@ +[workspace] +members = ["crates/vfox"] + [package] name = "mise" version = "2025.7.4" @@ -149,8 +152,7 @@ url = "2" urlencoding = "2.1.3" usage-lib = { version = "2", features = ["clap", "docs"] } versions = { version = "6", features = ["serde"] } -# vfox = { path = "../vfox.rs" } -vfox = { version = "1", default-features = false } +vfox = { path = "crates/vfox", default-features = false } walkdir = "2" which = "7" xx = { version = "2", features = ["glob"] } diff --git a/crates/vfox/Cargo.toml b/crates/vfox/Cargo.toml new file mode 100644 index 0000000000..a1cadee523 --- /dev/null +++ b/crates/vfox/Cargo.toml @@ -0,0 +1,59 @@ +[package] +name = "vfox" +version = "1.0.5" +edition = "2021" +license = "MIT" +description = "Interface to vfox plugins" +documentation = "https://docs.rs/vfox" +homepage = "https://github.com/jdx/vfox.rs" +repository = "https://github.com/jdx/vfox.rs" +include = ["src", "lua", "Cargo.toml", "Cargo.lock", "README.md", "LICENSE"] + +[lib] +name = "vfox" +path = "src/lib.rs" + +[[bin]] +name = "vfox-cli" +path = "src/bin.rs" + +[dependencies] +homedir = "0.3" +indexmap = "2" +itertools = "0.14" +log = "0.4" +mlua = { version = "0.10", features = [ + "async", + "lua51", + "macros", + "serialize", + "send", +] } +once_cell = "1" +reqwest = { version = "0.12", features = [] } # TODO: replace with xx +serde = "1" +serde_json = "1" +thiserror = "2" +tokio = { version = "1", features = ["macros", "fs"] } +url = "2" +xx = { version = "2", features = ["archive", "hash", "http"] } + +# cli dependencies +env_logger = { version = "0.11", optional = true } +clap = { version = "4", features = ["derive"], optional = true } +tempfile = "3" + +[dev-dependencies] +insta = "1" +#pretty_assertions = "1.4.0" + +[features] +default = ["vendored-lua"] +cli = ["clap", "tokio/full", "env_logger"] +native-tls = ["xx/native-tls"] +rustls = ["xx/rustls"] +rustls-native-roots = ["xx/rustls-native-roots"] +vendored-lua = ["mlua/vendored"] + +# [workspace.metadata.release] - removed since this is now part of mise workspace +# pre-release-hook = ["git", "cliff", "-o", "CHANGELOG.md", "--tag", "{{version}}"] diff --git a/crates/vfox/LICENSE b/crates/vfox/LICENSE new file mode 100644 index 0000000000..5333824e7f --- /dev/null +++ b/crates/vfox/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Jeff Dickey + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/vfox/README.md b/crates/vfox/README.md new file mode 100644 index 0000000000..5417a55289 --- /dev/null +++ b/crates/vfox/README.md @@ -0,0 +1 @@ +A rust interface to [vfox](https://github.com/version-fox/vfox). diff --git a/crates/vfox/lua/htmlparser.lua b/crates/vfox/lua/htmlparser.lua new file mode 100644 index 0000000000..c6226be17a --- /dev/null +++ b/crates/vfox/lua/htmlparser.lua @@ -0,0 +1,256 @@ +-- vim: ft=lua ts=2 sw=2 + +-- Syntactic Sugar {{{ +local function rine(val) -- Return (val) If it's Not Empty (non-zero-length) + return (val and #val>0) and val +end +local function rit(a) -- Return (a) If it's Table + return (type(a) == "table") and a +end +local noop = function() end +local esc = function(s) return string.gsub(s, "([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%" .. "%1") end +local str = tostring +local char = string.char +local opts = rit(htmlparser_opts) or {} -- needed for silent/noerr/noout/nonl directives, also needed to be defined before `require` in such case +local prn = opts.silent and noop or function(l,f,...) + local fd = (l=="i") and "stdout" or "stderr" + local t = (" [%s] "):format(l:upper()) + io[fd] + :write('[HTMLParser]'..t..f:format(...) + ..(opts.nonl or "\n") + ) +end +local err = opts.noerr and noop or function(f,...) prn("e",f,...) end +local out = opts.noout and noop or function(f,...) prn("i",f,...) end +local line = debug and function(lvl) return debug.getinfo(lvl or 2).currentline end or noop +local dbg = opts.debug and function(f,...) prn("d",f:gsub("#LINE#",str(line(3))),...) end or noop +-- }}} +-- Requires {{{ +local ElementNode = require"htmlparser.ElementNode" +local voidelements = require"htmlparser.voidelements" +--}}} +local HtmlParser = {} +local function parse(text,limit) -- {{{ + local opts = rine(opts) -- use top-level opts-table (the one, defined before requiring the module), if exists + or rit(htmlparser_opts) -- or defined after requiring (but before calling `parse`) + or {} -- fallback otherwise + opts.looplimit = opts.looplimit or htmlparser_looplimit + + local text = str(text) + local limit = limit or opts.looplimit or 1000 + local tpl = false + + if not opts.keep_comments then -- Strip (or not) comments {{{ + text = text:gsub("","") -- Many chances commented code will have syntax errors, that'll lead to parser failures + end -- }}} + + local tpr={} + + if not opts.keep_danger_placeholders then -- {{{ little speedup by cost of potential parsing breakages + -- search unused "invalid" bytes {{{ + local busy,i={},0; + repeat -- {{{ + local cc = char(i) + if not(text:match(cc)) then -- {{{ + if not(tpr["<"]) or not(tpr[">"]) then -- {{{ + if not(busy[i]) then -- {{{ + if not(tpr["<"]) then -- {{{ + tpr["<"] = cc; + elseif not(tpr[">"]) then + tpr[">"] = cc; + end -- }}} + busy[i] = true + dbg("c:{%s}||cc:{%d}||tpr[c]:{%s}",str(c),cc:byte(),str(tpr[c])) + dbg("busy[i]:{%s},i:{%d}",str(busy[i]),i) + dbg("[FindPH]:#LINE# Success! || i=%d",i) + else -- if !busy + dbg("[FindPH]:#LINE# Busy! || i=%d",i) + end -- if !busy -- }}} + dbg("c:{%s}||cc:{%d}||tpr[c]:{%s}",c,cc:byte(),str(tpr[c])) + dbg("%s",str(busy[i])) + else -- if < or > + dbg("[FindPH]:#LINE# Done!",i) + break + end -- if < or > -- }}} + else -- text!match(cc) + dbg("[FindPH]:#LINE# Text contains this byte! || i=%d",i) + end -- text!match(cc) -- }}} + local skip=1 + if i==31 then + skip=96 -- ASCII + end + i=i+skip + until (i==255) -- }}} + i=nil + --- }}} + + if not(tpr["<"]) or not(tpr[">"]) then + err("Impossible to find at least two unused byte codes in this HTML-code. We need it to escape bracket-contained placeholders inside tags.") + err("Consider enabling 'keep_danger_placeholders' option (to silence this error, if parser wasn't failed with current HTML-code) or manually replace few random bytes, to free up the codes.") + else + dbg("[FindPH]:#LINE# Found! || '<'=%d, '>'=%d",tpr["<"]:byte(),tpr[">"]:byte()) + end + +-- dbg("tpr[>] || tpr[] || #busy%d") + + -- g {{{ + local function g(id,...) + local arg={...} + local orig=arg[id] + arg[id]=arg[id]:gsub("(.)",tpr) + if arg[id] ~= orig then + tpl=true + dbg("[g]:#LINE# orig: %s", str(orig)) + dbg("[g]:#LINE# replaced: %s",str(arg[id])) + end + dbg("[g]:#LINE# called, id: %s, arg[id]: %s, args { "..(("{%s}, "):rep(#arg):gsub(", $","")).." }",id,arg[id],...) + dbg("[g]:#LINE# concat(arg): %s",table.concat(arg)) + return table.concat(arg) + end + -- g }}} + + -- tpl-placeholders and attributes {{{ + text=text + :gsub( + "(=[%s]-)".. -- only match attr.values, and not random strings between two random apostrophs + "(%b'')", + function(...)return g(2,...)end + ) + :gsub( + "(=[%s]-)".. -- same for " + '(%b"")', + function(...)return g(2,...)end + ) -- Escape "<"/">" inside attr.values (see issue #50) + :gsub( + "(<".. -- Match "<", + (opts.tpl_skip_pattern or "[^!]").. -- with exclusion pattern (for example, to ignore comments, which aren't template placeholders, but can legally contain "<"/">" inside. + ")([^>]+)".. -- If matched, we want to escape '<'s if we meet them inside tag + "(>)", + function(...)return g(2,...)end + ) + :gsub( + "(".. + (tpr["<"] or "__FAILED__").. -- Here we search for "<", we escaped in previous gsub (and don't break things if we have no escaping replacement) + ")(".. + (opts.tpl_marker_pattern or "[^%w%s]").. -- Capture templating symbol + ")([%g%s]-)".. -- match placeholder's content + "(%2)(>)".. -- placeholder's tail + "([^>]*>)", -- remainings + function(...)return g(5,...)end + ) + -- }}} + end -- }}} + + local index = 0 + local root = ElementNode:new(index, str(text)) + local node, descend, tpos, opentags = root, true, 1, {} + + while true do -- MainLoop {{{ + if index == limit then -- {{{ + err("Main loop reached loop limit (%d). Consider either increasing it or checking HTML-code for syntax errors", limit) + break + end -- }}} + -- openstart/tpos Definitions {{{ + local openstart, name + openstart, tpos, name = root._text:find( + "<" .. -- an uncaptured starting "<" + "([%w-]+)" .. -- name = the first word, directly following the "<" + "[^>]*>", -- include, but not capture everything up to the next ">" + tpos) + dbg("[MainLoop]:#LINE# openstart=%s || tpos=%s || name=%s",str(openstart),str(tpos),str(name)) + -- }}} + if not name then break end + -- Some more vars {{{ + index = index + 1 + local tag = ElementNode:new(index, str(name), (node or {}), descend, openstart, tpos) + node = tag + local tagloop + local tagst, apos = tag:gettext(), 1 + -- }}} + while true do -- TagLoop {{{ + dbg("[TagLoop]:#LINE# tag.name=%s, tagloop=%s",str(tag.name),str(tagloop)) + if tagloop == limit then -- {{{ + err("Tag parsing loop reached loop limit (%d). Consider either increasing it or checking HTML-code for syntax errors", limit) + break + end -- }}} + -- Attrs {{{ + local start, k, eq, quote, v, zsp + start, apos, k, zsp, eq, zsp, quote = tagst:find( + "%s+" .. -- some uncaptured space + "([^%s=/>]+)" .. -- k = an unspaced string up to an optional "=" or the "/" or ">" + "([%s]-)".. -- zero or more spaces + "(=?)" .. -- eq = the optional; "=", else "" + "([%s]-)".. -- zero or more spaces + [=[(['"]?)]=], -- quote = an optional "'" or '"' following the "=", or "" + apos) + dbg("[TagLoop]:#LINE# start=%s || apos=%s || k=%s || zsp='%s' || eq='%s', quote=[%s]",str(start),str(apos),str(k),str(zsp),str(eq),str(quote)) + -- }}} + if not k or k == "/>" or k == ">" then break end + -- Pattern {{{ + if eq == "=" then + local pattern = "=([^%s>]*)" + if quote ~= "" then + pattern = quote .. "([^" .. quote .. "]*)" .. quote + end + start, apos, v = tagst:find(pattern, apos) + dbg("[TagLoop]:#LINE# start=%s || apos=%s || v=%s || pattern=%s",str(start),str(apos),str(v),str(pattern)) + end + -- }}} + v=v or "" + if tpl then -- {{{ + for rk,rv in pairs(tpr) do + v = v:gsub(rv,rk) + dbg("[TagLoop]:#LINE# rv=%s || rk=%s",str(rv),str(rk)) + end + end -- }}} + + dbg("[TagLoop]:#LINE# k=%s || v=%s",str(k),str(v)) + tag:addattribute(k, v) + tagloop = (tagloop or 0) + 1 + end + -- }}} + if voidelements[tag.name:lower()] then -- {{{ + descend = false + tag:close() + else + descend = true + opentags[tag.name] = opentags[tag.name] or {} + table.insert(opentags[tag.name], tag) + end + -- }}} + local closeend = tpos + local closingloop + while true do -- TagCloseLoop {{{ + -- Can't remember why did I add that, so comment it for now (and not remove), in case it will be needed again + -- (although, it causes #59 and #60, so it will anyway be needed to rework) + -- if voidelements[tag.name:lower()] then break end -- already closed + if closingloop == limit then + err("Tag closing loop reached loop limit (%d). Consider either increasing it or checking HTML-code for syntax errors", limit) + break + end + + local closestart, closing, closename + closestart, closeend, closing, closename = root._text:find("[^<]*<(/?)([%w-]+)", closeend) + dbg("[TagCloseLoop]:#LINE# closestart=%s || closeend=%s || closing=%s || closename=%s",str(closestart),str(closeend),str(closing),str(closename)) + + if not closing or closing == "" then break end + + tag = table.remove(opentags[closename] or {}) or tag -- kludges for the cases of closing void or non-opened tags + closestart = root._text:find("<", closestart) + dbg("[TagCloseLoop]:#LINE# closestart=%s",str(closestart)) + tag:close(closestart, closeend + 1) + node = tag.parent + descend = true + closingloop = (closingloop or 0) + 1 + end -- }}} + end -- }}} + if tpl then -- {{{ + dbg("tpl") + for k,v in pairs(tpr) do + root._text = root._text:gsub(v,k) + end + end -- }}} + return root +end -- }}} +HtmlParser.parse = parse +return HtmlParser diff --git a/crates/vfox/lua/htmlparser/ElementNode.lua b/crates/vfox/lua/htmlparser/ElementNode.lua new file mode 100644 index 0000000000..84247f07f9 --- /dev/null +++ b/crates/vfox/lua/htmlparser/ElementNode.lua @@ -0,0 +1,283 @@ +-- vim: ft=lua ts=2 +local Set = {} +Set.mt = {__index = Set} +function Set:new(values) + local instance = {} + local isSet if getmetatable(values) == Set.mt then isSet = true end + if type(values) == "table" then + if not isSet and #values > 0 then + for _,v in ipairs(values) do + instance[v] = true + end + else + for k in pairs(values) do + instance[k] = true + end + end + elseif values ~= nil then + instance = {[values] = true} + end + return setmetatable(instance, Set.mt) +end + +function Set:add(e) + if e ~= nil then self[e] = true end + return self +end + +function Set:remove(e) + if e ~= nil then self[e] = nil end + return self +end + +function Set:tolist() + local res = {} + for k in pairs(self) do + table.insert(res, k) + end + return res +end + +Set.mt.__add = function (a, b) + local res, a, b = Set:new(), Set:new(a), Set:new(b) + for k in pairs(a) do res[k] = true end + for k in pairs(b) do res[k] = true end + return res +end + +-- Subtraction +Set.mt.__sub = function (a, b) + local res, a, b = Set:new(), Set:new(a), Set:new(b) + for k in pairs(a) do res[k] = true end + for k in pairs(b) do res[k] = nil end + return res +end + +-- Intersection +Set.mt.__mul = function (a, b) + local res, a, b = Set:new(), Set:new(a), Set:new(b) + for k in pairs(a) do + res[k] = b[k] + end + return res +end + +-- String representation +Set.mt.__tostring = function (set) + local s = "{" + local sep = "" + for k in pairs(set) do + s = s .. sep .. tostring(k) + sep = ", " + end + return s .. "}" +end + + +local ElementNode = {} +ElementNode.mt = {__index = ElementNode} +function ElementNode:new(index, nameortext, node, descend, openstart, openend) + local instance = { + index = index, + name = nameortext, + level = 0, + parent = nil, + root = nil, + nodes = {}, + _openstart = openstart, _openend = openend, + _closestart = openstart, _closeend = openend, + attributes = {}, + id = nil, + classes = {}, + deepernodes = Set:new(), + deeperelements = {}, deeperattributes = {}, deeperids = {}, deeperclasses = {} + } + if not node then + instance.name = "root" + instance.root = instance + instance._text = nameortext + local length = string.len(nameortext) + instance._openstart, instance._openend = 1, length + instance._closestart, instance._closeend = 1, length + elseif descend then + instance.root = node.root + instance.parent = node + instance.level = node.level + 1 + table.insert(node.nodes, instance) + else + instance.root = node.root + instance.parent = node.parent or node --XXX: adds some safety but needs more testing for heisenbugs in corner cases + instance.level = node.level + table.insert((node.parent and node.parent.nodes or node.nodes), instance) --XXX: see above about heisenbugs + end + return setmetatable(instance, ElementNode.mt) +end + +function ElementNode:gettext() + return string.sub(self.root._text, self._openstart, self._closeend) +end + +function ElementNode:settext(c) + self.root._text=c +end + +function ElementNode:textonly() + return (self:gettext():gsub("<[^>]*>","")) +end + +function ElementNode:getcontent() + return string.sub(self.root._text, self._openend + 1, self._closestart - 1) +end + +function ElementNode:addattribute(k, v) + self.attributes[k] = v + if string.lower(k) == "id" then + self.id = v + -- class attribute contains "space-separated tokens", each of which we'd like quick access to + elseif string.lower(k) == "class" then + for class in string.gmatch(v, "%S+") do + table.insert(self.classes, class) + end + end +end + +local function insert(table, name, node) + table[name] = table[name] or Set:new() + table[name]:add(node) +end + +function ElementNode:close(closestart, closeend) + if closestart and closeend then + self._closestart, self._closeend = closestart, closeend + end + -- inform hihger level nodes about this element's existence in their branches + local node = self + while true do + node = node.parent + if not node then break end + node.deepernodes:add(self) + insert(node.deeperelements, string.lower(self.name), self) + for k in pairs(self.attributes) do + insert(node.deeperattributes, k, self) + end + if self.id then + insert(node.deeperids, self.id, self) + end + for _,v in ipairs(self.classes) do + insert(node.deeperclasses, v, self) + end + end +end + +local function escape(s) + -- escape all ^, $, (, ), %, ., [, ], *, +, - , and ? with a % prefix + return string.gsub(s, "([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%" .. "%1") +end + +local function select(self, s) + if not s or type(s) ~= "string" or s == "" then return Set:new() end + local sets = {[""] = self.deeperelements, ["["] = self.deeperattributes, + ["#"] = self.deeperids, ["."] = self.deeperclasses} + local function match(t, w) + local m, e, v + if t == "[" then w, m, e, v = string.match(w, + "([^=|%*~%$!%^]+)" .. -- w = 1 or more characters up to a possible "=", "|", "*", "~", "$", "!", or "^" + "([|%*~%$!%^]?)" .. -- m = an optional "|", "*", "~", "$", "!", or "^", preceding the optional "=" + "(=?)" .. -- e = the optional "=" + "(.*)" -- v = anything following the "=", or else "" + ) + end + local matched = Set:new(sets[t][w]) + -- attribute value selectors + if e == "=" then + if #v < 2 then v = "'" .. v .. "'" end -- values should be quoted + v = string.sub(v, 2, #v - 1) -- strip quotes + if m == "!" then matched = Set:new(self.deepernodes) end -- include those without that attribute + for node in pairs(matched) do + local a = node.attributes[w] + -- equals + if m == "" and a ~= v then matched:remove(node) + -- not equals + elseif m == "!" and a == v then matched:remove(node) + -- prefix + elseif m =="|" and string.match(a, "^[^-]*") ~= v then matched:remove(node) + -- contains + elseif m =="*" and string.match(a, escape(v)) ~= v then matched:remove(node) + -- word + elseif m =="~" then matched:remove(node) + for word in string.gmatch(a, "%S+") do + if word == v then matched:add(node) break end + end + -- starts with + elseif m =="^" and string.match(a, "^" .. escape(v)) ~= v then matched:remove(node) + -- ends with + elseif m =="$" and string.match(a, escape(v) .. "$") ~= v then matched:remove(node) + end + end -- for node + end -- if v + return matched + end + + local subjects, resultset, childrenonly = Set:new({self}) + for part in string.gmatch(s, "%S+") do + repeat + if part == ">" then childrenonly = true --[[goto nextpart]] break end + resultset = Set:new() + for subject in pairs(subjects) do + local star = subject.deepernodes + if childrenonly then star = Set:new(subject.nodes) end + resultset = resultset + star + end + childrenonly = false + if part == "*" then --[[goto nextpart]] break end + local excludes, filter = Set:new() + local start, pos = 0, 0 + while true do + local switch, stype, name, eq, quote + start, pos, switch, stype, name, eq, quote = string.find(part, + "(%(?%)?)" .. -- switch = a possible ( or ) switching the filter on or off + "([:%[#.]?)" .. -- stype = a possible :, [, #, or . + "([%w-_\\]+)" .. -- name = 1 or more alfanumeric chars (+ hyphen, reverse slash and uderscore) + "([|%*~%$!%^]?=?)" .. -- eq = a possible |=, *=, ~=, $=, !=, ^=, or = + "(['\"]?)", -- quote = a ' or " delimiting a possible attribute value + pos + 1 + ) + if not name then break end + repeat + if ":" == stype then + filter = name + --[[goto nextname]] break + end + if ")" == switch then + filter = nil + end + if "[" == stype and "" ~= quote then + local value + start, pos, value = string.find(part, "(%b" .. quote .. quote .. ")]", pos) + name = name .. eq .. value + end + local matched = match(stype, name) + if filter == "not" then + excludes = excludes + matched + else + resultset = resultset * matched + end + --::nextname:: + break + until true + end + resultset = resultset - excludes + subjects = Set:new(resultset) + --::nextpart:: +break +until true + end + resultset = resultset:tolist() + table.sort(resultset, function (a, b) return a.index < b.index end) + return resultset +end + +function ElementNode:select(s) return select(self, s) end +ElementNode.mt.__call = select + +return ElementNode diff --git a/crates/vfox/lua/htmlparser/voidelements.lua b/crates/vfox/lua/htmlparser/voidelements.lua new file mode 100644 index 0000000000..43dedf5156 --- /dev/null +++ b/crates/vfox/lua/htmlparser/voidelements.lua @@ -0,0 +1,19 @@ +-- vim: ft=lua ts=2 +return { + area = true, + base = true, + br = true, + col = true, + command = true, + embed = true, + hr = true, + img = true, + input = true, + keygen = true, + link = true, + meta = true, + param = true, + source = true, + track = true, + wbr = true +} diff --git a/crates/vfox/src/bin.rs b/crates/vfox/src/bin.rs new file mode 100644 index 0000000000..2457a59b43 --- /dev/null +++ b/crates/vfox/src/bin.rs @@ -0,0 +1,22 @@ +#[cfg(feature = "cli")] +#[macro_use] +extern crate log; + +#[cfg(feature = "cli")] +mod cli; + +#[allow(clippy::needless_return)] +#[cfg(feature = "cli")] +#[tokio::main] +async fn main() { + env_logger::init_from_env(env_logger::Env::default().filter_or("VFOX_LOG", "info")); + if let Err(err) = cli::run().await { + error!("{err}"); + std::process::exit(1); + } +} + +#[cfg(not(feature = "cli"))] +fn main() { + panic!("cli feature is not enabled"); +} diff --git a/crates/vfox/src/cli/available.rs b/crates/vfox/src/cli/available.rs new file mode 100644 index 0000000000..3cb4a315b9 --- /dev/null +++ b/crates/vfox/src/cli/available.rs @@ -0,0 +1,13 @@ +use vfox::{Vfox, VfoxResult}; + +#[derive(clap::Args)] +pub struct Available {} + +impl Available { + pub async fn run(&self) -> VfoxResult<()> { + for (name, url) in Vfox::list_available_sdks() { + println!("{name} {url}"); + } + Ok(()) + } +} diff --git a/crates/vfox/src/cli/env_keys.rs b/crates/vfox/src/cli/env_keys.rs new file mode 100644 index 0000000000..77676fde6f --- /dev/null +++ b/crates/vfox/src/cli/env_keys.rs @@ -0,0 +1,18 @@ +use vfox::{Vfox, VfoxResult}; + +#[derive(clap::Args)] +pub struct EnvKeys { + pub sdk: String, + pub version: String, +} + +impl EnvKeys { + pub async fn run(&self) -> VfoxResult<()> { + let vfox = Vfox::new(); + let env_keys = vfox.env_keys(&self.sdk, &self.version).await?; + for env_key in env_keys { + println!("{}={}", env_key.key, env_key.value); + } + Ok(()) + } +} diff --git a/crates/vfox/src/cli/install.rs b/crates/vfox/src/cli/install.rs new file mode 100644 index 0000000000..5a9d39c9e1 --- /dev/null +++ b/crates/vfox/src/cli/install.rs @@ -0,0 +1,26 @@ +use std::path::PathBuf; +use vfox::{Vfox, VfoxResult}; + +#[derive(clap::Args)] +pub struct Install { + pub sdk: String, + pub version: String, + #[clap(short, long)] + pub output_dir: Option, +} + +impl Install { + pub async fn run(&self) -> VfoxResult<()> { + let vfox = Vfox::new(); + let out = self + .output_dir + .clone() + .unwrap_or_else(|| vfox.install_dir.join(&self.sdk).join(&self.version)); + info!( + "Installing {} version {} to {out:?}", + self.sdk, self.version + ); + vfox.install(&self.sdk, &self.version, &out).await?; + Ok(()) + } +} diff --git a/crates/vfox/src/cli/mod.rs b/crates/vfox/src/cli/mod.rs new file mode 100644 index 0000000000..f7bf20313d --- /dev/null +++ b/crates/vfox/src/cli/mod.rs @@ -0,0 +1,38 @@ +use clap::Parser; +use vfox::VfoxResult; + +mod available; +mod env_keys; +mod install; +mod plugins; + +#[derive(Parser)] +#[command(version)] +pub(crate) struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(clap::Subcommand)] +enum Commands { + Available(available::Available), + EnvKeys(env_keys::EnvKeys), + Install(install::Install), + #[command(alias = "plugin")] + Plugins(plugins::Plugins), +} + +impl Commands { + pub async fn run(self) -> VfoxResult<()> { + match self { + Commands::Available(available) => available.run().await, + Commands::EnvKeys(env_keys) => env_keys.run().await, + Commands::Install(install) => install.run().await, + Commands::Plugins(plugins) => plugins.run().await, + } + } +} + +pub async fn run() -> VfoxResult<()> { + Cli::parse().command.run().await +} diff --git a/crates/vfox/src/cli/plugins/list.rs b/crates/vfox/src/cli/plugins/list.rs new file mode 100644 index 0000000000..e2f67ba33e --- /dev/null +++ b/crates/vfox/src/cli/plugins/list.rs @@ -0,0 +1,17 @@ +use vfox::Vfox; +use vfox::VfoxResult; + +#[derive(clap::Args)] +#[command(alias = "ls")] +pub struct List {} + +impl List { + pub async fn run(&self) -> VfoxResult<()> { + let vfox = Vfox::new(); + let sdks = vfox.list_sdks()?; + for sdk in sdks { + println!("{sdk}"); + } + Ok(()) + } +} diff --git a/crates/vfox/src/cli/plugins/mod.rs b/crates/vfox/src/cli/plugins/mod.rs new file mode 100644 index 0000000000..3834ac7baf --- /dev/null +++ b/crates/vfox/src/cli/plugins/mod.rs @@ -0,0 +1,23 @@ +use vfox::VfoxResult; + +mod list; + +#[derive(clap::Subcommand)] +pub(crate) enum Commands { + // Install(install::Install), + List(list::List), +} + +#[derive(clap::Args)] +pub(crate) struct Plugins { + #[clap(subcommand)] + command: Commands, +} + +impl Plugins { + pub(crate) async fn run(&self) -> VfoxResult<()> { + match &self.command { + Commands::List(list) => list.run().await, + } + } +} diff --git a/crates/vfox/src/config.rs b/crates/vfox/src/config.rs new file mode 100644 index 0000000000..4efcbb49af --- /dev/null +++ b/crates/vfox/src/config.rs @@ -0,0 +1,45 @@ +use std::env::consts::{ARCH, OS}; +use std::path::PathBuf; +use std::sync::{Mutex, MutexGuard}; + +#[derive(Debug, Clone)] +pub struct Config { + pub plugin_dir: PathBuf, +} + +static CONFIG: Mutex> = Mutex::new(None); + +impl Config { + pub fn get() -> Self { + Self::_get().as_ref().unwrap().clone() + } + + fn _get() -> MutexGuard<'static, Option> { + let mut config = CONFIG.lock().unwrap(); + if config.is_none() { + let home = homedir::my_home() + .ok() + .flatten() + .unwrap_or_else(|| PathBuf::from("/")); + *config = Some(Config { + plugin_dir: home.join(".version-fox/plugin"), + }); + } + config + } +} + +pub fn os() -> String { + match OS { + "macos" => "darwin".to_string(), + os => os.to_string(), + } +} + +pub fn arch() -> String { + match ARCH { + "aarch64" => "arm64".to_string(), + "x86_64" => "amd64".to_string(), + arch => arch.to_string(), + } +} diff --git a/crates/vfox/src/context.rs b/crates/vfox/src/context.rs new file mode 100644 index 0000000000..b3809b18d7 --- /dev/null +++ b/crates/vfox/src/context.rs @@ -0,0 +1,16 @@ +use mlua::{UserData, UserDataFields}; + +#[derive(Debug)] +pub(crate) struct Context { + pub args: Vec, + pub(crate) version: Option, + // pub(crate) runtime_version: String, +} + +impl UserData for Context { + fn add_fields>(fields: &mut F) { + fields.add_field_method_get("args", |_, t| Ok(t.args.clone())); + fields.add_field_method_get("version", |_, t| Ok(t.version.clone())); + // fields.add_field_method_get("runtimeVersion", |_, t| Ok(t.runtime_version.clone())); + } +} diff --git a/crates/vfox/src/error.rs b/crates/vfox/src/error.rs new file mode 100644 index 0000000000..bd6787fea1 --- /dev/null +++ b/crates/vfox/src/error.rs @@ -0,0 +1,43 @@ +use mlua::Error as MLuaError; +use thiserror::Error; +use xx::XXError; + +#[derive(Error, Debug)] +#[non_exhaustive] +pub enum VfoxError { + #[error("{0}")] + Error(String), + #[error(transparent)] + LuaError(#[from] MLuaError), + #[error("serde_json")] + SerdeJsonError(#[from] serde_json::Error), + #[error(transparent)] + XXError(#[from] XXError), + #[error(transparent)] + ReqwestError(#[from] reqwest::Error), + #[error(transparent)] + IoError(#[from] std::io::Error), + #[error(transparent)] + UrlParseError(#[from] url::ParseError), +} + +pub type Result = std::result::Result; + +impl From for VfoxError { + fn from(s: String) -> Self { + VfoxError::Error(s) + } +} + +impl From<&str> for VfoxError { + fn from(s: &str) -> Self { + VfoxError::Error(s.to_string()) + } +} + +#[macro_export] +macro_rules! error { + ($($arg:tt)*) => { + return Err(VfoxError::Error(format!($($arg)*))); + }; +} diff --git a/crates/vfox/src/hooks/available.rs b/crates/vfox/src/hooks/available.rs new file mode 100644 index 0000000000..2709b6b63d --- /dev/null +++ b/crates/vfox/src/hooks/available.rs @@ -0,0 +1,95 @@ +use mlua::prelude::LuaError; +use mlua::{FromLua, Lua, Value}; + +use crate::error::Result; +use crate::Plugin; + +impl Plugin { + #[allow(clippy::needless_return)] // seems to be a clippy bug + #[tokio::main(flavor = "current_thread")] + pub async fn available(&self) -> Result> { + self.available_async().await + } + + pub async fn available_async(&self) -> Result> { + debug!("[vfox:{}] available_async", &self.name); + let ctx = self.context(None)?; + let available = self + .eval_async(chunk! { + require "hooks/available" + return PLUGIN:Available($ctx) + }) + .await?; + + Ok(available) + } +} + +#[derive(Debug)] +pub struct AvailableVersion { + pub version: String, + pub note: Option, + // pub addition: Option, +} + +impl FromLua for AvailableVersion { + fn from_lua(value: Value, _: &Lua) -> std::result::Result { + match value { + Value::Table(table) => { + // TODO: try to default this to an empty table or something + // let addition = table.get::>("addition")?; + Ok(AvailableVersion { + version: table.get::("version")?, + note: table.get::>("note")?, + // addition, + }) + } + _ => panic!("Expected table"), + } + } +} + +#[cfg(test)] +mod tests { + use crate::Plugin; + + #[test] + fn dummy() { + let versions = run("dummy"); + assert_debug_snapshot!(versions, @r###" + [ + "1.0.0", + "1.0.1", + ] + "###); + } + + #[tokio::test] + async fn dummy_async() { + let versions = run_async("dummy").await; + assert_debug_snapshot!(versions, @r###" + [ + "1.0.0", + "1.0.1", + ] + "###); + } + + #[tokio::test] + async fn nodejs_async() { + let versions = run_async("nodejs").await; + assert!(versions.contains(&"20.0.0".to_string())); + } + + fn run(plugin: &str) -> Vec { + let p = Plugin::test(plugin); + let r = p.available().unwrap(); + r.iter().map(|v| v.version.clone()).collect() + } + + async fn run_async(plugin: &str) -> Vec { + let p = Plugin::test(plugin); + let r = p.available_async().await.unwrap(); + r.iter().map(|v| v.version.clone()).collect() + } +} diff --git a/crates/vfox/src/hooks/env_keys.rs b/crates/vfox/src/hooks/env_keys.rs new file mode 100644 index 0000000000..4a13dd7aa4 --- /dev/null +++ b/crates/vfox/src/hooks/env_keys.rs @@ -0,0 +1,60 @@ +use mlua::prelude::LuaError; +use mlua::{FromLua, IntoLua, Lua, Value}; +use std::collections::BTreeMap; +use std::path::PathBuf; + +use crate::error::Result; +use crate::sdk_info::SdkInfo; +use crate::Plugin; + +#[derive(Debug)] +pub struct EnvKey { + pub key: String, + pub value: String, +} + +#[derive(Debug)] +pub struct EnvKeysContext { + pub args: Vec, + pub version: String, + pub path: PathBuf, + pub main: SdkInfo, + pub sdk_info: BTreeMap, +} + +impl Plugin { + pub async fn env_keys(&self, ctx: EnvKeysContext) -> Result> { + debug!("[vfox:{}] env_keys", &self.name); + let env_keys = self + .eval_async(chunk! { + require "hooks/env_keys" + return PLUGIN:EnvKeys($ctx) + }) + .await?; + + Ok(env_keys) + } +} + +impl IntoLua for EnvKeysContext { + fn into_lua(self, lua: &Lua) -> mlua::Result { + let table = lua.create_table()?; + table.set("version", self.version)?; + table.set("path", self.path.to_string_lossy().to_string())?; + table.set("sdkInfo", self.sdk_info)?; + table.set("main", self.main)?; + Ok(Value::Table(table)) + } +} + +impl FromLua for EnvKey { + fn from_lua(value: Value, _: &Lua) -> std::result::Result { + match value { + Value::Table(table) => Ok(EnvKey { + key: table.get::("key")?, + value: table.get::("value")?, + }), + _ => panic!("Expected table"), + } + } +} diff --git a/crates/vfox/src/hooks/mise_env.rs b/crates/vfox/src/hooks/mise_env.rs new file mode 100644 index 0000000000..cc801d8834 --- /dev/null +++ b/crates/vfox/src/hooks/mise_env.rs @@ -0,0 +1,36 @@ +use mlua::{IntoLua, Lua, LuaSerdeExt, Value}; + +use crate::error::Result; +use crate::hooks::env_keys::EnvKey; +use crate::Plugin; + +#[derive(Debug)] +pub struct MiseEnvContext { + pub args: Vec, + pub options: T, +} + +impl Plugin { + pub async fn mise_env( + &self, + ctx: MiseEnvContext, + ) -> Result> { + debug!("[vfox:{}] mise_env", &self.name); + let env_keys = self + .eval_async(chunk! { + require "hooks/mise_env" + return PLUGIN:MiseEnv($ctx) + }) + .await?; + + Ok(env_keys) + } +} + +impl IntoLua for MiseEnvContext { + fn into_lua(self, lua: &Lua) -> mlua::Result { + let table = lua.create_table()?; + table.set("options", lua.to_value(&self.options)?)?; + Ok(Value::Table(table)) + } +} diff --git a/crates/vfox/src/hooks/mise_path.rs b/crates/vfox/src/hooks/mise_path.rs new file mode 100644 index 0000000000..014bd1234c --- /dev/null +++ b/crates/vfox/src/hooks/mise_path.rs @@ -0,0 +1,35 @@ +use mlua::{IntoLua, Lua, LuaSerdeExt, Value}; + +use crate::error::Result; +use crate::Plugin; + +#[derive(Debug)] +pub struct MisePathContext { + pub args: Vec, + pub options: T, +} + +impl Plugin { + pub async fn mise_path( + &self, + ctx: MisePathContext, + ) -> Result> { + debug!("[vfox:{}] mise_path", &self.name); + let path = self + .eval_async(chunk! { + require "hooks/mise_path" + return PLUGIN:MisePath($ctx) + }) + .await?; + + Ok(path) + } +} + +impl IntoLua for MisePathContext { + fn into_lua(self, lua: &Lua) -> mlua::Result { + let table = lua.create_table()?; + table.set("options", lua.to_value(&self.options)?)?; + Ok(Value::Table(table)) + } +} diff --git a/crates/vfox/src/hooks/mod.rs b/crates/vfox/src/hooks/mod.rs new file mode 100644 index 0000000000..c8f1fac6ce --- /dev/null +++ b/crates/vfox/src/hooks/mod.rs @@ -0,0 +1,8 @@ +pub mod available; +pub mod env_keys; +pub mod mise_env; +pub mod mise_path; +pub mod parse_legacy_file; +pub mod post_install; +pub mod pre_install; +pub mod pre_use; diff --git a/crates/vfox/src/hooks/parse_legacy_file.rs b/crates/vfox/src/hooks/parse_legacy_file.rs new file mode 100644 index 0000000000..24c1c1a296 --- /dev/null +++ b/crates/vfox/src/hooks/parse_legacy_file.rs @@ -0,0 +1,87 @@ +use mlua::prelude::LuaError; +use mlua::{FromLua, IntoLua, Lua, MultiValue, Value}; +use std::path::{Path, PathBuf}; + +use crate::error::Result; +use crate::Plugin; + +#[derive(Debug)] +pub struct LegacyFileContext { + pub args: Vec, + pub filepath: PathBuf, +} + +#[derive(Debug)] +pub struct ParseLegacyFileResponse { + pub version: Option, +} + +impl Plugin { + pub async fn parse_legacy_file(&self, legacy_file: &Path) -> Result { + debug!("[vfox:{}] parse_legacy_file", &self.name); + let ctx = LegacyFileContext { + args: vec![], + filepath: legacy_file.to_path_buf(), + }; + let legacy_file_response = self + .eval_async(chunk! { + require "hooks/available" + require "hooks/parse_legacy_file" + return PLUGIN:ParseLegacyFile($ctx) + }) + .await?; + + Ok(legacy_file_response) + } +} + +impl IntoLua for LegacyFileContext { + fn into_lua(self, lua: &Lua) -> mlua::Result { + let table = lua.create_table()?; + table.set("args", self.args)?; + table.set("filepath", self.filepath.to_string_lossy().to_string())?; + table.set( + "getInstalledVersions", + lua.create_async_function(|lua, _input: MultiValue| async move { + let plugin_dir = lua.named_registry_value::("plugin_dir")?; + Ok(Plugin::from_dir(plugin_dir.as_path()) + .map_err(|e| LuaError::RuntimeError(e.to_string()))? + .available_async() + .await + .map_err(|e| LuaError::RuntimeError(e.to_string()))? + .into_iter() + .map(|v| v.version) + .collect::>()) + })?, + )?; + Ok(Value::Table(table)) + } +} + +impl FromLua for ParseLegacyFileResponse { + fn from_lua(value: Value, _: &Lua) -> std::result::Result { + match value { + Value::Table(table) => Ok(ParseLegacyFileResponse { + version: table.get::>("version")?, + }), + _ => panic!("Expected table"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Vfox; + + #[tokio::test] + async fn test_parse_legacy_file_nodejs() { + let vfox = Vfox::test(); + let response = vfox + .parse_legacy_file("nodejs", Path::new("test/data/.node-version")) + .await + .unwrap(); + let out = format!("{response:?}"); + assert_snapshot!(out, @r###"ParseLegacyFileResponse { version: Some("20.0.0") }"###); + } +} diff --git a/crates/vfox/src/hooks/post_install.rs b/crates/vfox/src/hooks/post_install.rs new file mode 100644 index 0000000000..94ce27c593 --- /dev/null +++ b/crates/vfox/src/hooks/post_install.rs @@ -0,0 +1,52 @@ +use crate::error::Result; +use crate::sdk_info::SdkInfo; +use crate::Plugin; +use mlua::{IntoLua, Lua, Value}; +use std::collections::BTreeMap; +use std::path::PathBuf; + +impl Plugin { + pub async fn post_install(&self, ctx: PostInstallContext) -> Result<()> { + debug!("[vfox:{}] post_install", &self.name); + self.exec_async(chunk! { + require "hooks/post_install" + PLUGIN:PostInstall($ctx) + }) + .await + } +} + +pub struct PostInstallContext { + pub root_path: PathBuf, + pub runtime_version: String, + pub sdk_info: BTreeMap, +} + +impl IntoLua for PostInstallContext { + fn into_lua(self, lua: &Lua) -> mlua::Result { + let table = lua.create_table()?; + table.set("rootPath", self.root_path.to_string_lossy().to_string())?; + table.set("runtimeVersion", self.runtime_version)?; + table.set("sdkInfo", self.sdk_info)?; + Ok(Value::Table(table)) + } +} + +#[cfg(test)] +mod tests { + use crate::Plugin; + use tokio::test; + + use super::*; + + #[test] + async fn dummy() { + let p = Plugin::test("dummy"); + let ctx = PostInstallContext { + root_path: PathBuf::from("root_path"), + runtime_version: "runtime_version".to_string(), + sdk_info: BTreeMap::new(), + }; + p.post_install(ctx).await.unwrap(); + } +} diff --git a/crates/vfox/src/hooks/pre_install.rs b/crates/vfox/src/hooks/pre_install.rs new file mode 100644 index 0000000000..c95faad896 --- /dev/null +++ b/crates/vfox/src/hooks/pre_install.rs @@ -0,0 +1,99 @@ +use mlua::prelude::LuaError; +use mlua::{FromLua, Lua, Value}; + +use crate::error::Result; +use crate::Plugin; + +impl Plugin { + pub async fn pre_install(&self, version: &str) -> Result { + debug!("[vfox:{}] pre_install", &self.name); + let ctx = self.context(Some(version.to_string()))?; + let pre_install = self + .eval_async(chunk! { + require "hooks/pre_install" + return PLUGIN:PreInstall($ctx) + }) + .await?; + + Ok(pre_install) + } +} + +#[derive(Debug)] +pub struct PreInstall { + pub version: String, + pub url: Option, + pub note: Option, + pub sha256: Option, + pub md5: Option, + pub sha1: Option, + pub sha512: Option, + // pub addition: Option
, +} + +impl FromLua for PreInstall { + fn from_lua(value: Value, _: &Lua) -> std::result::Result { + match value { + Value::Table(table) => { + if !table.contains_key("version")? { + return Err(LuaError::FromLuaConversionError { + from: "table", + to: "PreInstall".into(), + message: Some("no version returned from vfox plugin".to_string()), + }); + } + Ok(PreInstall { + version: table.get::("version")?, + url: table.get::>("url")?, + note: table.get::>("note")?, + sha256: table.get::>("sha256")?, + md5: table.get::>("md5")?, + sha1: table.get::>("sha1")?, + sha512: table.get::>("sha512")?, + // addition, + }) + } + _ => panic!("Expected table"), + } + } +} + +#[cfg(test)] +mod tests { + use crate::hooks::pre_install::PreInstall; + use crate::runtime::Runtime; + use crate::Plugin; + use std::string::ToString; + use tokio::test; + + #[test] + async fn dummy() { + let pre_install = run("dummy", "1.0.1").await; + assert_debug_snapshot!(pre_install); + } + + #[test] + async fn nodejs() { + Runtime::set_os("linux".to_string()); + Runtime::set_arch("x64".to_string()); + let pre_install = run("nodejs", "20.0.0").await; + assert_debug_snapshot!(pre_install); + + Runtime::set_os("macos".to_string()); + Runtime::set_arch("arm64".to_string()); + let pre_install = run("nodejs", "20.1.0").await; + assert_debug_snapshot!(pre_install); + + Runtime::set_os("windows".to_string()); + Runtime::set_arch("x64".to_string()); + let pre_install = run("nodejs", "20.3.0").await; + assert_debug_snapshot!(pre_install); + + Runtime::reset(); + } + + async fn run(plugin: &str, v: &str) -> PreInstall { + let p = Plugin::test(plugin); + p.pre_install(v).await.unwrap() + } +} diff --git a/crates/vfox/src/hooks/pre_use.rs b/crates/vfox/src/hooks/pre_use.rs new file mode 100644 index 0000000000..9a2c916716 --- /dev/null +++ b/crates/vfox/src/hooks/pre_use.rs @@ -0,0 +1,28 @@ +use indexmap::IndexMap; +use std::path::Path; + +use crate::error::Result; +use crate::sdk_info::SdkInfo; +use crate::{Plugin, Vfox}; + +#[allow(dead_code)] +#[derive(Debug)] +pub struct PreUseContext { + pub installed_sdks: IndexMap, +} + +#[derive(Debug)] +pub struct PreUseResponse { + pub version: Option, +} + +impl Plugin { + pub async fn pre_use(&self, _vfox: &Vfox, _legacy_file: &Path) -> Result { + debug!("[vfox:{}] pre_use", &self.name); + // let ctx = PreUseContext { + // installed_sdks: vfox.list_installed_versions(&self.name)?, + // }; + + unimplemented!("pre_use hook is not implemented"); + } +} diff --git a/crates/vfox/src/hooks/snapshots/vfox__hooks__pre_install__tests__dummy.snap b/crates/vfox/src/hooks/snapshots/vfox__hooks__pre_install__tests__dummy.snap new file mode 100644 index 0000000000..489c3726ba --- /dev/null +++ b/crates/vfox/src/hooks/snapshots/vfox__hooks__pre_install__tests__dummy.snap @@ -0,0 +1,13 @@ +--- +source: src/hooks/pre_install.rs +expression: pre_install +--- +PreInstall { + version: "1.0.1", + url: None, + note: None, + sha256: None, + md5: None, + sha1: None, + sha512: None, +} diff --git a/crates/vfox/src/hooks/snapshots/vfox__hooks__pre_install__tests__nodejs-2.snap b/crates/vfox/src/hooks/snapshots/vfox__hooks__pre_install__tests__nodejs-2.snap new file mode 100644 index 0000000000..bdae36f6c3 --- /dev/null +++ b/crates/vfox/src/hooks/snapshots/vfox__hooks__pre_install__tests__nodejs-2.snap @@ -0,0 +1,15 @@ +--- +source: src/hooks/pre_install.rs +expression: pre_install +--- +PreInstall { + version: "20.1.0", + url: Some( + "https://nodejs.org/dist/v20.1.0/node-v20.1.0-macos-arm64.tar.gz", + ), + note: None, + sha256: None, + md5: None, + sha1: None, + sha512: None, +} diff --git a/crates/vfox/src/hooks/snapshots/vfox__hooks__pre_install__tests__nodejs-3.snap b/crates/vfox/src/hooks/snapshots/vfox__hooks__pre_install__tests__nodejs-3.snap new file mode 100644 index 0000000000..ee2387f8e8 --- /dev/null +++ b/crates/vfox/src/hooks/snapshots/vfox__hooks__pre_install__tests__nodejs-3.snap @@ -0,0 +1,17 @@ +--- +source: src/hooks/pre_install.rs +expression: pre_install +--- +PreInstall { + version: "20.3.0", + url: Some( + "https://nodejs.org/dist/v20.3.0/node-v20.3.0-win-x64.zip", + ), + note: None, + sha256: Some( + "43be53f9f4d6fa19e27efdb724e10cbdf3c7abfaebe0d852af62fc80c6f465a2", + ), + md5: None, + sha1: None, + sha512: None, +} diff --git a/crates/vfox/src/hooks/snapshots/vfox__hooks__pre_install__tests__nodejs.snap b/crates/vfox/src/hooks/snapshots/vfox__hooks__pre_install__tests__nodejs.snap new file mode 100644 index 0000000000..52b6e3a35b --- /dev/null +++ b/crates/vfox/src/hooks/snapshots/vfox__hooks__pre_install__tests__nodejs.snap @@ -0,0 +1,17 @@ +--- +source: src/hooks/pre_install.rs +expression: pre_install +--- +PreInstall { + version: "20.0.0", + url: Some( + "https://nodejs.org/dist/v20.0.0/node-v20.0.0-linux-x64.tar.gz", + ), + note: None, + sha256: Some( + "95439a0a836fc858565c7b6a7b1e55d1741901e6f205485e496abbefe973cfce", + ), + md5: None, + sha1: None, + sha512: None, +} diff --git a/crates/vfox/src/http.rs b/crates/vfox/src/http.rs new file mode 100644 index 0000000000..22b51c54d4 --- /dev/null +++ b/crates/vfox/src/http.rs @@ -0,0 +1,9 @@ +use reqwest::{Client, ClientBuilder}; +use std::sync::LazyLock; + +pub static CLIENT: LazyLock = LazyLock::new(|| { + ClientBuilder::new() + .user_agent(format!("vfox.rs/{}", env!("CARGO_PKG_VERSION"))) + .build() + .expect("Failed to create reqwest client") +}); diff --git a/crates/vfox/src/lib.rs b/crates/vfox/src/lib.rs new file mode 100644 index 0000000000..ea2fcf4886 --- /dev/null +++ b/crates/vfox/src/lib.rs @@ -0,0 +1,25 @@ +#[cfg(test)] +#[macro_use] +extern crate insta; +#[macro_use] +extern crate log; +#[macro_use] +extern crate mlua; + +pub use error::Result as VfoxResult; +pub use error::VfoxError; +pub use plugin::Plugin; +pub use vfox::Vfox; + +mod config; +mod context; +mod error; +mod hooks; +mod http; +mod lua_mod; +mod metadata; +mod plugin; +mod registry; +mod runtime; +mod sdk_info; +mod vfox; diff --git a/crates/vfox/src/lua_mod/archiver.rs b/crates/vfox/src/lua_mod/archiver.rs new file mode 100644 index 0000000000..259c05e118 --- /dev/null +++ b/crates/vfox/src/lua_mod/archiver.rs @@ -0,0 +1,59 @@ +use crate::error::Result; +use mlua::{ExternalResult, Lua, MultiValue, Table}; +use std::path::PathBuf; + +pub fn mod_archiver(lua: &Lua) -> Result<()> { + let package: Table = lua.globals().get("package")?; + let loaded: Table = package.get("loaded")?; + Ok(loaded.set( + "archiver", + lua.create_table_from(vec![( + "decompress", + lua.create_async_function( + |_lua: mlua::Lua, input| async move { decompress(&_lua, input) }, + )?, + )])?, + )?) +} + +fn decompress(_lua: &Lua, input: MultiValue) -> mlua::Result<()> { + let paths: Vec = input.into_iter().collect(); + let archive: PathBuf = PathBuf::from(paths[0].to_string()?); + let destination: PathBuf = PathBuf::from(paths[1].to_string()?); + let filename = archive.file_name().unwrap().to_str().unwrap(); + if filename.ends_with(".zip") { + xx::archive::unzip(&archive, &destination).into_lua_err()?; + } else if filename.ends_with(".tar.gz") { + xx::archive::untar_gz(&archive, &destination).into_lua_err()?; + } else if filename.ends_with(".tar.xz") { + xx::archive::untar_xz(&archive, &destination).into_lua_err()?; + } else if filename.ends_with(".tar.bz2") { + xx::archive::untar_bz2(&archive, &destination).into_lua_err()?; + } else { + unimplemented!("Unsupported archive format {:?}", archive); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_zip() { + let _ = std::fs::remove_dir_all("/tmp/test_zip_dst"); + let lua = Lua::new(); + mod_archiver(&lua).unwrap(); + lua.load(mlua::chunk! { + local archiver = require("archiver") + archiver.decompress("test/data/foo.zip", "/tmp/test_zip_dst") + }) + .exec() + .unwrap(); + assert_eq!( + std::fs::read_to_string("/tmp/test_zip_dst/foo/test.txt").unwrap(), + "yep\n" + ); + std::fs::remove_dir_all("/tmp/test_zip_dst").unwrap(); + } +} diff --git a/crates/vfox/src/lua_mod/env.rs b/crates/vfox/src/lua_mod/env.rs new file mode 100644 index 0000000000..1d33a9a187 --- /dev/null +++ b/crates/vfox/src/lua_mod/env.rs @@ -0,0 +1,50 @@ +use mlua::prelude::*; +use mlua::Table; + +pub fn mod_env(lua: &Lua) -> LuaResult<()> { + let package: Table = lua.globals().get("package")?; + let loaded: Table = package.get("loaded")?; + let env = lua.create_table_from(vec![("setenv", lua.create_function(setenv)?)])?; + loaded.set("env", env.clone())?; + loaded.set("vfox.env", env)?; + Ok(()) +} + +fn setenv(_lua: &Lua, (key, val): (String, String)) -> LuaResult<()> { + std::env::set_var(key, val); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_env() { + let lua = Lua::new(); + mod_env(&lua).unwrap(); + if cfg!(windows) { + lua.load(mlua::chunk! { + local env = require("env") + env.setenv("TEST_ENV", "myvar") + handle = io.popen("pwsh -Command \"echo $env:TEST_ENV\"") + result = handle:read("*a") + handle:close() + assert(result == "myvar\n") + }) + .exec() + .unwrap(); + } else { + lua.load(mlua::chunk! { + local env = require("env") + env.setenv("TEST_ENV", "myvar") + handle = io.popen("echo $TEST_ENV") + result = handle:read("*a") + handle:close() + assert(result == "myvar\n") + }) + .exec() + .unwrap(); + } + } +} diff --git a/crates/vfox/src/lua_mod/file.rs b/crates/vfox/src/lua_mod/file.rs new file mode 100644 index 0000000000..e5926afb88 --- /dev/null +++ b/crates/vfox/src/lua_mod/file.rs @@ -0,0 +1,67 @@ +use crate::error::Result; +use mlua::{ExternalResult, Lua, MultiValue, Table}; +#[cfg(unix)] +use std::os::unix::fs::symlink as _symlink; +#[cfg(windows)] +use std::os::windows::fs::symlink_dir; +#[cfg(windows)] +use std::os::windows::fs::symlink_file; +use std::path::Path; + +pub fn mod_file(lua: &Lua) -> Result<()> { + let package: Table = lua.globals().get("package")?; + let loaded: Table = package.get("loaded")?; + Ok(loaded.set( + "file", + lua.create_table_from(vec![( + "symlink", + lua.create_async_function(|_lua: mlua::Lua, input| async move { + symlink(&_lua, input).await + })?, + )])?, + )?) +} + +async fn symlink(_lua: &Lua, input: MultiValue) -> mlua::Result<()> { + let input: Vec = input + .into_iter() + .map(|v| v.to_string()) + .collect::>()?; + let src = Path::new(&input[0]); + let dst = Path::new(&input[1]); + #[cfg(windows)] + { + if src.is_dir() { + symlink_dir(src, dst).into_lua_err()?; + } else { + symlink_file(src, dst).into_lua_err()?; + } + } + #[cfg(unix)] + _symlink(src, dst).into_lua_err()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn test_symlink() { + let _ = fs::remove_file("/tmp/test_symlink_dst"); + let lua = Lua::new(); + mod_file(&lua).unwrap(); + lua.load(mlua::chunk! { + local file = require("file") + file.symlink("/tmp/test_symlink_src", "/tmp/test_symlink_dst") + }) + .exec() + .unwrap(); + assert_eq!( + fs::read_link("/tmp/test_symlink_dst").unwrap(), + Path::new("/tmp/test_symlink_src") + ); + fs::remove_file("/tmp/test_symlink_dst").unwrap(); + } +} diff --git a/crates/vfox/src/lua_mod/hooks.rs b/crates/vfox/src/lua_mod/hooks.rs new file mode 100644 index 0000000000..f024898370 --- /dev/null +++ b/crates/vfox/src/lua_mod/hooks.rs @@ -0,0 +1,39 @@ +use crate::error::Result; +use mlua::Lua; +use std::collections::BTreeSet; +use std::path::Path; + +pub struct HookFunc { + _name: &'static str, + required: bool, + filename: &'static str, +} + +#[rustfmt::skip] +const HOOK_FUNCS: [HookFunc; 9] = [ + HookFunc { _name: "Available", required: false, filename: "available" }, + HookFunc { _name: "PreInstall", required: false, filename: "pre_install" }, + HookFunc { _name: "EnvKeys", required: false, filename: "env_keys" }, + HookFunc { _name: "PostInstall", required: false, filename: "post_install" }, + HookFunc { _name: "PreUse", required: false, filename: "pre_use" }, + HookFunc { _name: "ParseLegacyFile", required: false, filename: "parse_legacy_file" }, + HookFunc { _name: "PreUninstall", required: false, filename: "pre_uninstall" }, + + // mise + HookFunc { _name: "MiseEnv", required: false, filename: "mise_env" }, + HookFunc { _name: "MisePath", required: false, filename: "mise_path" }, +]; + +pub fn mod_hooks(lua: &Lua, root: &Path) -> Result> { + let mut hooks = BTreeSet::new(); + for hook in &HOOK_FUNCS { + let hook_path = root.join("hooks").join(format!("{}.lua", hook.filename)); + if hook_path.exists() { + lua.load(hook_path).exec()?; + hooks.insert(hook.filename); + } else if hook.required { + return Err(format!("Required hook '{}' not found", hook.filename).into()); + } + } + Ok(hooks) +} diff --git a/crates/vfox/src/lua_mod/html.rs b/crates/vfox/src/lua_mod/html.rs new file mode 100644 index 0000000000..8d87f0d97a --- /dev/null +++ b/crates/vfox/src/lua_mod/html.rs @@ -0,0 +1,149 @@ +use mlua::{Lua, Table}; + +pub fn mod_html(lua: &Lua) -> mlua::Result<()> { + let package: Table = lua.globals().get("package")?; + let loaded: Table = package.get("loaded")?; + loaded.set( + "htmlparser.voidelements", + lua.load(include_str!("../../lua/htmlparser/voidelements.lua")) + .eval::
()?, + )?; + loaded.set( + "htmlparser.ElementNode", + lua.load(include_str!("../../lua/htmlparser/ElementNode.lua")) + .eval::
()?, + )?; + loaded.set( + "htmlparser", + lua.load(include_str!("../../lua/htmlparser.lua")) + .eval::
()?, + )?; + loaded.set( + "html", + lua.load(mlua::chunk! { + local htmlparser = require("htmlparser") + return { + parse = function(s) + Node = { + find = function(self, tag) + local nodes = self.node:select(tag) + return Node.new(nodes) + end, + first = function(self) + return Node.new({self.nodes[1]}) + end, + eq = function(self, idx) + local node = self.nodes[idx + 1] + return Node.new({node}) + end, + each = function(self, f) + for i, node in ipairs(self.nodes) do + f(i - 1, Node.new({node})) + end + end, + text = function(self) + if self.node == nil then + return "" + end + return self.node:getcontent() + end, + attr = function(self, key) + if self.node == nil then + return "" + end + return self.node.attributes[key] + end, + } + Node.new = function(nodes) + return setmetatable({nodes = nodes, node = nodes[1]}, {__index = Node}) + end + local root = htmlparser.parse(s, 100000) + return Node.new({root}) + end + } + }) + .eval::
()?, + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::lua_mod::http::mod_http; + + #[test] + fn test_html() { + let lua = Lua::new(); + mod_html(&lua).unwrap(); + lua.load(mlua::chunk! { + local html = require("html") + local doc = html.parse("
456
222
") + local f = doc:find("div"):eq(0) + local s = doc:find("div"):eq(1) + assert(s:text() == "222") + assert(f:text() == "456") + + assert(s:attr("foo") == "bar") + + doc:find("div"):each(function(i, e) + if i == 0 then + assert(e:text() == "456") + else + assert(e:text() == "222") + end + end) + }) + .exec() + .unwrap(); + } + + #[tokio::test] + #[ignore] // TODO: make this actually work + async fn test_html_go() { + let lua = Lua::new(); + mod_html(&lua).unwrap(); + mod_http(&lua).unwrap(); + lua.load(mlua::chunk! { + local http = require("http") + local html = require("html") + + table = {} + + resp, err = http.get({ + url = "https://go.dev/dl/" + }) + if err ~= nil or resp.status_code ~= 200 then + error("parsing release info failed." .. err) + end + local doc = html.parse(resp.body) + local listDoc = doc:find("div#archive") + listDoc:find(".toggle"):each(function(i, selection) + local versionStr = selection:attr("id") + if versionStr ~= nil then + selection:find("table.downloadtable tr"):each(function(ti, ts) + local td = ts:find("td") + local filename = td:eq(0):text() + local kind = td:eq(1):text() + local os = td:eq(2):text() + local arch = td:eq(3):text() + local checksum = td:eq(5):text() + if kind == "Archive" and os == "Windows" and arch == "x86-64" then + table.insert(result, { + version = string.sub(versionStr, 3), + url = "https://go.dev/dl/" .. filename, + note = "", + sha256 = checksum, + }) + end + end) + end + end) + print(table) + // TODO: check results + }) + .exec_async() + .await + .unwrap(); + } +} diff --git a/crates/vfox/src/lua_mod/http.rs b/crates/vfox/src/lua_mod/http.rs new file mode 100644 index 0000000000..47cf5fe00e --- /dev/null +++ b/crates/vfox/src/lua_mod/http.rs @@ -0,0 +1,133 @@ +use mlua::{ExternalResult, Lua, MultiValue, Result, Table}; + +use crate::http::CLIENT; + +pub fn mod_http(lua: &Lua) -> Result<()> { + let package: Table = lua.globals().get("package")?; + let loaded: Table = package.get("loaded")?; + loaded.set( + "http", + lua.create_table_from(vec![ + ( + "get", + lua.create_async_function(|lua: mlua::Lua, input| async move { + get(&lua, input).await + })?, + ), + ( + "head", + lua.create_async_function(|lua: mlua::Lua, input| async move { + head(&lua, input).await + })?, + ), + ( + "download_file", + lua.create_async_function(|_lua: mlua::Lua, input| async move { + download_file(&_lua, input).await + })?, + ), + ])?, + ) +} + +async fn get(lua: &Lua, input: Table) -> Result
{ + let url: String = input.get("url").into_lua_err()?; + let resp = CLIENT.get(&url).send().await.into_lua_err()?; + let t = lua.create_table()?; + t.set("status_code", resp.status().as_u16())?; + t.set("headers", get_headers(lua, resp.headers())?)?; + t.set("body", resp.text().await.into_lua_err()?)?; + Ok(t) +} + +async fn download_file(_lua: &Lua, input: MultiValue) -> Result<()> { + let t: &Table = input.iter().next().unwrap().as_table().unwrap(); + let url: String = t.get("url").into_lua_err()?; + let path: String = input.iter().nth(1).unwrap().to_string()?; + let resp = CLIENT.get(&url).send().await.into_lua_err()?; + resp.error_for_status_ref().into_lua_err()?; + let mut file = tokio::fs::File::create(&path).await.into_lua_err()?; + let bytes = resp.bytes().await.into_lua_err()?; + tokio::io::AsyncWriteExt::write_all(&mut file, &bytes) + .await + .into_lua_err()?; + Ok(()) +} + +async fn head(lua: &Lua, input: Table) -> Result
{ + let url: String = input.get("url").into_lua_err()?; + let resp = CLIENT.head(&url).send().await.into_lua_err()?; + let t = lua.create_table()?; + t.set("status_code", resp.status().as_u16())?; + t.set("headers", get_headers(lua, resp.headers())?)?; + Ok(t) +} + +fn get_headers(lua: &Lua, headers: &reqwest::header::HeaderMap) -> Result
{ + let t = lua.create_table()?; + for (name, value) in headers.iter() { + t.set(name.as_str(), value.to_str().into_lua_err()?)?; + } + Ok(t) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[tokio::test] + async fn test_get() { + let lua = Lua::new(); + mod_http(&lua).unwrap(); + lua.load(mlua::chunk! { + local http = require("http") + local resp = http.get({ url = "https://httpbin.org/get" }) + assert(resp.status_code == 200) + assert(type(resp.body) == "string") + }) + .exec_async() + .await + .unwrap(); + } + + #[tokio::test] + async fn test_head() { + let lua = Lua::new(); + mod_http(&lua).unwrap(); + lua.load(mlua::chunk! { + local http = require("http") + local resp = http.head({ url = "https://httpbin.org/get" }) + print(resp.headers) + assert(resp.status_code == 200) + assert(type(resp.headers) == "table") + assert(resp.headers["content-type"] == "application/json") + assert(resp.content_length == nil) + }) + .exec_async() + .await + .unwrap(); + } + + #[tokio::test] + #[ignore] // TODO: find out why this often fails in CI + async fn test_download_file() { + let lua = Lua::new(); + mod_http(&lua).unwrap(); + let path = "test/data/test_download_file.txt"; + lua.load(mlua::chunk! { + local http = require("http") + err = http.download_file({ + url = "https://vfox-plugins.lhan.me/index.json", + headers = {} + }, $path) + assert(err == nil, [[must be nil]]) + }) + .exec_async() + .await + .unwrap(); + // TODO: figure out why this fails on gha + assert!(fs::read_to_string(path).unwrap().contains("vfox-nodejs")); + tokio::fs::remove_file(path).await.unwrap(); + } +} diff --git a/crates/vfox/src/lua_mod/json.rs b/crates/vfox/src/lua_mod/json.rs new file mode 100644 index 0000000000..d36fc53101 --- /dev/null +++ b/crates/vfox/src/lua_mod/json.rs @@ -0,0 +1,59 @@ +use mlua::{ExternalResult, Lua, LuaSerdeExt, Result, Table, Value}; + +pub fn mod_json(lua: &Lua) -> Result<()> { + let package: Table = lua.globals().get("package")?; + let loaded: Table = package.get("loaded")?; + loaded.set( + "json", + lua.create_table_from(vec![ + ("encode", lua.create_function(encode)?), + ("decode", lua.create_function(decode)?), + ])?, + ) +} + +fn encode(_lua: &Lua, value: Value) -> Result { + serde_json::to_string(&value).into_lua_err() +} + +fn decode(lua: &Lua, value: String) -> Result { + let value: serde_json::Value = serde_json::from_str(&value).into_lua_err()?; + lua.to_value(&value) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encode() { + let lua = Lua::new(); + mod_json(&lua).unwrap(); + lua.load(mlua::chunk! { + local json = require("json") + local obj = { "a", 1, "b", 2, "c", 3 } + local jsonStr = json.encode(obj) + assert(jsonStr == "[\"a\",1,\"b\",2,\"c\",3]") + }) + .exec() + .unwrap(); + } + + #[test] + fn test_decode() { + let lua = Lua::new(); + mod_json(&lua).unwrap(); + lua.load(mlua::chunk! { + local json = require("json") + local obj = json.decode("[\"a\",1,\"b\",2,\"c\",3]") + assert(obj[1] == "a") + assert(obj[2] == 1) + assert(obj[3] == "b") + assert(obj[4] == 2) + assert(obj[5] == "c") + assert(obj[6] == 3) + }) + .exec() + .unwrap(); + } +} diff --git a/crates/vfox/src/lua_mod/mod.rs b/crates/vfox/src/lua_mod/mod.rs new file mode 100644 index 0000000000..58916a4d42 --- /dev/null +++ b/crates/vfox/src/lua_mod/mod.rs @@ -0,0 +1,17 @@ +mod archiver; +mod env; +mod file; +mod hooks; +mod html; +mod http; +mod json; +mod strings; + +pub use archiver::mod_archiver as archiver; +pub use env::mod_env as env; +pub use file::mod_file as file; +pub use hooks::mod_hooks as hooks; +pub use html::mod_html as html; +pub use http::mod_http as http; +pub use json::mod_json as json; +pub use strings::mod_strings as strings; diff --git a/crates/vfox/src/lua_mod/strings.rs b/crates/vfox/src/lua_mod/strings.rs new file mode 100644 index 0000000000..b441746878 --- /dev/null +++ b/crates/vfox/src/lua_mod/strings.rs @@ -0,0 +1,82 @@ +use mlua::prelude::*; +use mlua::{Table, Value}; + +pub fn mod_strings(lua: &Lua) -> LuaResult<()> { + let package: Table = lua.globals().get("package")?; + let loaded: Table = package.get("loaded")?; + let strings = lua.create_table_from(vec![ + ("split", lua.create_function(split)?), + ("has_prefix", lua.create_function(has_prefix)?), + ("has_suffix", lua.create_function(has_suffix)?), + ("trim", lua.create_function(trim)?), + ("trim_space", lua.create_function(trim_space)?), + ("contains", lua.create_function(contains)?), + ("join", lua.create_function(join)?), + ])?; + loaded.set("strings", strings.clone())?; + loaded.set("vfox.strings", strings)?; + Ok(()) +} + +fn split(_lua: &Lua, (s, sep): (String, String)) -> LuaResult> { + Ok(s.split(&sep).map(|s| s.to_string()).collect()) +} + +fn has_prefix(_lua: &Lua, (s, prefix): (String, String)) -> LuaResult { + Ok(s.starts_with(&prefix)) +} + +fn has_suffix(_lua: &Lua, (s, suffix): (String, String)) -> LuaResult { + Ok(s.ends_with(&suffix)) +} + +fn trim(_lua: &Lua, (s, suffix): (String, String)) -> LuaResult { + Ok(s.trim_end_matches(&suffix).to_string()) +} + +fn trim_space(_lua: &Lua, s: String) -> LuaResult { + Ok(s.trim().to_string()) +} + +fn contains(_lua: &Lua, (s, substr): (String, String)) -> LuaResult { + Ok(s.contains(&substr)) +} + +fn join(_lua: &Lua, (arr, sep): (Vec, String)) -> LuaResult { + let mut res = String::new(); + for (i, v) in arr.iter().enumerate() { + if i > 0 { + res.push_str(&sep); + } + res.push_str(&v.to_string()?); + } + Ok(res) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_strings() { + let lua = Lua::new(); + mod_strings(&lua).unwrap(); + lua.load(mlua::chunk! { + local strings = require("strings") + local str_parts = strings.split("hello world", " ") + print(str_parts[1]) -- hello + + assert(strings.has_prefix("hello world", "hello"), [[not strings.has_prefix("hello")]]) + assert(strings.has_suffix("hello world", "world"), [[not strings.has_suffix("world")]]) + assert(strings.trim("hello world", "world") == "hello ", "strings.trim()") + assert(strings.contains("hello world", "hello ") == true, "strings.contains()") + + // got = strings.trim_space(tt.input) + // + // local str = strings.join({"1",3,"4"},";") + // assert(str == "1;3;4", "strings.join()") + }) + .exec() + .unwrap(); + } +} diff --git a/crates/vfox/src/metadata.rs b/crates/vfox/src/metadata.rs new file mode 100644 index 0000000000..09ab14eb64 --- /dev/null +++ b/crates/vfox/src/metadata.rs @@ -0,0 +1,36 @@ +use mlua::Table; +use std::collections::BTreeSet; + +use crate::error::Result; +use crate::error::VfoxError; + +#[derive(Debug, Clone)] +pub struct Metadata { + pub name: String, + pub legacy_filenames: Vec, + pub version: String, + pub description: Option, + pub author: Option, + pub license: Option, + pub homepage: Option, + pub hooks: BTreeSet<&'static str>, +} + +impl TryFrom
for Metadata { + type Error = VfoxError; + fn try_from(t: Table) -> Result { + let legacy_filenames = t + .get::>>("legacyFilenames")? + .unwrap_or_default(); + Ok(Metadata { + name: t.get("name")?, + legacy_filenames, + version: t.get("version")?, + description: t.get("description")?, + author: t.get("author")?, + license: t.get("license")?, + homepage: t.get("homepage")?, + hooks: Default::default(), + }) + } +} diff --git a/crates/vfox/src/plugin.rs b/crates/vfox/src/plugin.rs new file mode 100644 index 0000000000..b89d40b5e1 --- /dev/null +++ b/crates/vfox/src/plugin.rs @@ -0,0 +1,203 @@ +use std::cmp::Ordering; +use std::fmt::Display; +use std::path::{Path, PathBuf}; + +use mlua::{AsChunk, FromLuaMulti, IntoLua, Lua, Table}; +use once_cell::sync::OnceCell; + +use crate::config::Config; +use crate::context::Context; +use crate::error::Result; +use crate::metadata::Metadata; +use crate::runtime::Runtime; +use crate::sdk_info::SdkInfo; +use crate::{config, error, lua_mod, VfoxError}; + +#[derive(Debug)] +pub struct Plugin { + pub name: String, + pub dir: PathBuf, + lua: Lua, + metadata: OnceCell, +} + +impl Plugin { + pub fn from_dir(dir: &Path) -> Result { + if !dir.exists() { + error!("Plugin directory not found: {:?}", dir); + } + let lua = Lua::new(); + lua.set_named_registry_value("plugin_dir", dir.to_path_buf())?; + Ok(Self { + name: dir.file_name().unwrap().to_string_lossy().to_string(), + dir: dir.to_path_buf(), + lua, + metadata: OnceCell::new(), + }) + } + + pub fn from_name(name: &str) -> Result { + let dir = Config::get().plugin_dir.join(name); + Self::from_dir(&dir) + } + + pub fn list() -> Result> { + let config = Config::get(); + if !config.plugin_dir.exists() { + return Ok(vec![]); + } + let plugins = xx::file::ls(&config.plugin_dir)?; + let plugins = plugins + .iter() + .filter_map(|p| { + p.file_name() + .and_then(|f| f.to_str()) + .map(|s| s.to_string()) + }) + .collect(); + Ok(plugins) + } + + pub fn get_metadata(&self) -> Result { + Ok(self.load()?.clone()) + } + + pub fn sdk_info(&self, version: String, install_dir: PathBuf) -> Result { + Ok(SdkInfo::new( + self.get_metadata()?.name.clone(), + version, + install_dir, + )) + } + + #[cfg(test)] + pub(crate) fn test(name: &str) -> Self { + let dir = PathBuf::from("plugins").join(name); + Self::from_dir(&dir).unwrap() + } + + pub(crate) fn context(&self, version: Option) -> Result { + let ctx = Context { + args: vec![], + version, + // version: "1.0.0".to_string(), + // runtime_version: "xxx".to_string(), + }; + Ok(ctx) + } + + pub(crate) async fn exec_async<'a>(&self, chunk: impl AsChunk<'a>) -> Result<()> { + self.load()?; + let chunk = self.lua.load(chunk); + chunk.exec_async().await?; + Ok(()) + } + + pub(crate) async fn eval_async<'a, R>(&self, chunk: impl AsChunk<'a>) -> Result + where + R: FromLuaMulti, + { + self.load()?; + let chunk = self.lua.load(chunk); + let result = chunk.eval_async().await?; + Ok(result) + } + + fn load(&self) -> Result<&Metadata> { + self.metadata.get_or_try_init(|| { + debug!("Getting metadata for {self}"); + set_paths( + &self.lua, + &[ + self.dir.join("?.lua"), //xx + self.dir.join("hooks/?.lua"), + self.dir.join("lib/?.lua"), + ], + )?; + + lua_mod::archiver(&self.lua)?; + lua_mod::file(&self.lua)?; + lua_mod::html(&self.lua)?; + lua_mod::http(&self.lua)?; + lua_mod::json(&self.lua)?; + lua_mod::strings(&self.lua)?; + lua_mod::env(&self.lua)?; + + let metadata = self.load_metadata()?; + self.set_global("PLUGIN", metadata.clone())?; + self.set_global("RUNTIME", Runtime::get(self.dir.clone()))?; + self.set_global("OS_TYPE", config::os())?; + self.set_global("ARCH_TYPE", config::arch())?; + + let mut metadata: Metadata = metadata.try_into()?; + + metadata.hooks = lua_mod::hooks(&self.lua, &self.dir)?; + + Ok(metadata) + }) + } + + fn set_global(&self, name: &str, value: V) -> Result<()> + where + V: IntoLua, + { + self.lua.globals().set(name, value)?; + Ok(()) + } + + fn load_metadata(&self) -> Result
{ + let metadata = self + .lua + .load( + r#" + require "metadata" + return PLUGIN + "#, + ) + .eval()?; + Ok(metadata) + } +} + +fn get_package(lua: &Lua) -> Result
{ + let package = lua.globals().get::
("package")?; + Ok(package) +} + +fn set_paths(lua: &Lua, paths: &[PathBuf]) -> Result<()> { + let paths = paths + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect::>() + .join(";"); + + get_package(lua)?.set("path", paths)?; + + Ok(()) +} + +impl Display for Plugin { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name) + } +} + +impl PartialEq for Plugin { + fn eq(&self, other: &Self) -> bool { + self.dir == other.dir + } +} + +impl Eq for Plugin {} + +impl PartialOrd for Plugin { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Plugin { + fn cmp(&self, other: &Self) -> Ordering { + self.name.cmp(&other.name) + } +} diff --git a/crates/vfox/src/registry.rs b/crates/vfox/src/registry.rs new file mode 100644 index 0000000000..d9ba5701bf --- /dev/null +++ b/crates/vfox/src/registry.rs @@ -0,0 +1,23 @@ +use std::collections::BTreeMap; +use std::str::FromStr; + +use once_cell::sync::Lazy; +use url::Url; + +static SDKS: Lazy> = Lazy::new(|| { + [ + ("nodejs", "https://github.com/version-fox/vfox-nodejs"), + ("cmake", "https://github.com/version-fox/vfox-cmake"), + ] + .iter() + .map(|(name, url)| (name.to_string(), Url::from_str(url).unwrap())) + .collect() +}); + +pub fn sdk_url(name: &str) -> Option<&Url> { + SDKS.get(name) +} + +pub fn list_sdks() -> &'static BTreeMap { + &SDKS +} diff --git a/crates/vfox/src/runtime.rs b/crates/vfox/src/runtime.rs new file mode 100644 index 0000000000..dad76e66ac --- /dev/null +++ b/crates/vfox/src/runtime.rs @@ -0,0 +1,58 @@ +use crate::config::{arch, os}; +use mlua::{UserData, UserDataFields}; +use once_cell::sync::Lazy; +use std::path::PathBuf; +use std::sync::Mutex; + +#[derive(Debug, Clone)] +pub(crate) struct Runtime { + pub(crate) os: String, + pub(crate) arch: String, + pub(crate) version: String, + pub(crate) plugin_dir_path: PathBuf, +} + +static RUNTIME: Lazy> = Lazy::new(|| { + Mutex::new(Runtime { + os: os(), + arch: arch(), + version: "0.6.0".to_string(), // https://github.com/version-fox/vfox/releases + plugin_dir_path: PathBuf::new(), + }) +}); + +impl Runtime { + pub(crate) fn get(plugin_dir_path: PathBuf) -> Runtime { + let mut runtime = RUNTIME.lock().unwrap().clone(); + runtime.plugin_dir_path = plugin_dir_path; + runtime + } + + #[cfg(test)] + pub(crate) fn set_os(os: String) { + let mut runtime = RUNTIME.lock().unwrap(); + runtime.os = os; + } + + #[cfg(test)] + pub(crate) fn set_arch(arch: String) { + let mut runtime = RUNTIME.lock().unwrap(); + runtime.arch = arch; + } + + #[cfg(test)] + pub(crate) fn reset() { + let mut runtime = RUNTIME.lock().unwrap(); + runtime.os = os(); + runtime.arch = arch(); + } +} + +impl UserData for Runtime { + fn add_fields>(fields: &mut F) { + fields.add_field_method_get("osType", |_, t| Ok(t.os.clone())); + fields.add_field_method_get("archType", |_, t| Ok(t.arch.clone())); + fields.add_field_method_get("version", |_, t| Ok(t.version.clone())); + fields.add_field_method_get("pluginDirPath", |_, t| Ok(t.plugin_dir_path.clone())); + } +} diff --git a/crates/vfox/src/sdk_info.rs b/crates/vfox/src/sdk_info.rs new file mode 100644 index 0000000000..a840082560 --- /dev/null +++ b/crates/vfox/src/sdk_info.rs @@ -0,0 +1,29 @@ +use mlua::{IntoLua, Lua, Value}; +use std::path::PathBuf; + +#[derive(Debug, Clone)] +pub struct SdkInfo { + pub name: String, + pub version: String, + pub path: PathBuf, +} + +impl SdkInfo { + pub fn new(name: String, version: String, path: PathBuf) -> Self { + Self { + name, + version, + path, + } + } +} + +impl IntoLua for SdkInfo { + fn into_lua(self, lua: &Lua) -> mlua::Result { + let table = lua.create_table()?; + table.set("name", self.name)?; + table.set("version", self.version)?; + table.set("path", self.path.to_string_lossy().to_string())?; + Ok(Value::Table(table)) + } +} diff --git a/crates/vfox/src/snapshots/vfox__vfox__tests__env_keys.snap b/crates/vfox/src/snapshots/vfox__vfox__tests__env_keys.snap new file mode 100644 index 0000000000..b85be1da4e --- /dev/null +++ b/crates/vfox/src/snapshots/vfox__vfox__tests__env_keys.snap @@ -0,0 +1,5 @@ +--- +source: src/vfox.rs +expression: output +--- +[EnvKey { key: "PATH", value: "/nodejs/20.0.0/bin" }] diff --git a/crates/vfox/src/snapshots/vfox__vfox__tests__metadata.snap b/crates/vfox/src/snapshots/vfox__vfox__tests__metadata.snap new file mode 100644 index 0000000000..cadd4de83d --- /dev/null +++ b/crates/vfox/src/snapshots/vfox__vfox__tests__metadata.snap @@ -0,0 +1,6 @@ +--- +source: src/vfox.rs +expression: out +snapshot_kind: text +--- +Metadata { name: "nodejs", legacy_filenames: [".node-version", ".nvmrc"], version: "0.3.0", description: Some("Node.js runtime environment."), author: None, license: Some("Apache 2.0"), homepage: Some("https://github.com/version-fox/vfox-nodejs"), hooks: {"available", "env_keys", "parse_legacy_file", "pre_install", "pre_use"} } diff --git a/crates/vfox/src/vfox.rs b/crates/vfox/src/vfox.rs new file mode 100644 index 0000000000..0b92560b35 --- /dev/null +++ b/crates/vfox/src/vfox.rs @@ -0,0 +1,426 @@ +use itertools::Itertools; +use reqwest::Url; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use std::sync::mpsc; +use tempfile::TempDir; +use xx::file; + +use crate::error::Result; +use crate::hooks::available::AvailableVersion; +use crate::hooks::env_keys::{EnvKey, EnvKeysContext}; +use crate::hooks::mise_env::MiseEnvContext; +use crate::hooks::mise_path::MisePathContext; +use crate::hooks::parse_legacy_file::ParseLegacyFileResponse; +use crate::hooks::post_install::PostInstallContext; +use crate::hooks::pre_install::PreInstall; +use crate::http::CLIENT; +use crate::metadata::Metadata; +use crate::plugin::Plugin; +use crate::registry; +use crate::sdk_info::SdkInfo; + +#[derive(Debug)] +pub struct Vfox { + pub runtime_version: String, + pub install_dir: PathBuf, + pub plugin_dir: PathBuf, + pub cache_dir: PathBuf, + pub download_dir: PathBuf, + log_tx: Option>, +} + +impl Vfox { + pub fn new() -> Self { + Self::default() + } + + pub fn log_subscribe(&mut self) -> mpsc::Receiver { + let (tx, rx) = mpsc::channel(); + self.log_tx = Some(tx); + rx + } + + fn log_emit(&self, msg: String) { + if let Some(tx) = &self.log_tx { + let _ = tx.send(msg); + } + } + + pub fn list_available_sdks() -> &'static BTreeMap { + registry::list_sdks() + } + + pub async fn list_available_versions(&self, sdk: &str) -> Result> { + let sdk = self.get_sdk(sdk)?; + sdk.available_async().await + } + + pub fn list_installed_versions(&self, sdk: &str) -> Result> { + let path = self.install_dir.join(sdk); + if !path.exists() { + return Ok(Default::default()); + } + let sdk = self.get_sdk(sdk)?; + let versions = xx::file::ls(&path)?; + versions + .into_iter() + .filter_map(|p| { + p.file_name() + .and_then(|f| f.to_str()) + .map(|s| s.to_string()) + }) + .sorted() + .map(|version| { + let path = path.join(&version); + sdk.sdk_info(version, path) + }) + .collect::>() + } + pub fn list_sdks(&self) -> Result> { + if !self.plugin_dir.exists() { + return Ok(Default::default()); + } + let plugins = xx::file::ls(&self.plugin_dir)?; + plugins + .into_iter() + .filter_map(|p| { + p.file_name() + .and_then(|f| f.to_str()) + .map(|s| s.to_string()) + }) + .sorted() + .map(|name| self.get_sdk(&name)) + .collect() + } + + pub fn get_sdk(&self, name: &str) -> Result { + Plugin::from_dir(&self.plugin_dir.join(name)) + } + + pub fn install_plugin(&self, sdk: &str) -> Result { + let plugin_dir = self.plugin_dir.join(sdk); + if !plugin_dir.exists() { + let url = registry::sdk_url(sdk).ok_or_else(|| format!("Unknown SDK: {sdk}"))?; + return self.install_plugin_from_url(url); + } + Plugin::from_dir(&plugin_dir) + } + + pub fn install_plugin_from_url(&self, url: &Url) -> Result { + let sdk = url + .path_segments() + .and_then(|mut s| { + let filename = s.next_back().unwrap(); + filename + .strip_prefix("vfox-") + .map(|s| s.to_string()) + .or_else(|| Some(filename.to_string())) + }) + .ok_or("No filename in URL")?; + let plugin_dir = self.plugin_dir.join(&sdk); + if !plugin_dir.exists() { + debug!("Installing plugin {sdk}"); + xx::git::clone(url.as_ref(), &plugin_dir, &Default::default())?; + } + Plugin::from_dir(&plugin_dir) + } + + pub fn uninstall_plugin(&self, sdk: &str) -> Result<()> { + let plugin_dir = self.plugin_dir.join(sdk); + if plugin_dir.exists() { + file::remove_dir_all(&plugin_dir)?; + } + Ok(()) + } + + pub async fn install>( + &self, + sdk: &str, + version: &str, + install_dir: ID, + ) -> Result<()> { + self.install_plugin(sdk)?; + let sdk = self.get_sdk(sdk)?; + let pre_install = sdk.pre_install(version).await?; + let install_dir = install_dir.as_ref(); + trace!("{pre_install:?}"); + if let Some(url) = pre_install.url.as_ref().map(|s| Url::from_str(s)) { + let file = self.download(&url?, &sdk, version).await?; + self.verify(&pre_install, &file)?; + self.extract(&file, install_dir)?; + } + + if sdk.get_metadata()?.hooks.contains("post_install") { + let sdk_info = sdk.sdk_info(version.to_string(), install_dir.to_path_buf())?; + sdk.post_install(PostInstallContext { + root_path: install_dir.to_path_buf(), + runtime_version: self.runtime_version.clone(), + sdk_info: BTreeMap::from([(sdk_info.name.clone(), sdk_info)]), + }) + .await?; + } + + Ok(()) + } + + pub fn uninstall(&self, sdk: &str, version: &str) -> Result<()> { + let path = self.install_dir.join(sdk).join(version); + file::remove_dir_all(&path)?; + Ok(()) + } + + pub async fn metadata(&self, sdk: &str) -> Result { + self.get_sdk(sdk)?.get_metadata() + } + + pub async fn env_keys(&self, sdk: &str, version: &str) -> Result> { + debug!("Getting env keys for {sdk} version {version}"); + let sdk = self.get_sdk(sdk)?; + let sdk_info = sdk.sdk_info( + version.to_string(), + self.install_dir.join(&sdk.name).join(version), + )?; + let ctx = EnvKeysContext { + args: vec![], + version: version.to_string(), + path: sdk_info.path.clone(), + sdk_info: BTreeMap::from([(sdk_info.name.clone(), sdk_info.clone())]), + main: sdk_info, + }; + sdk.env_keys(ctx).await + } + + pub async fn mise_env(&self, sdk: &str, opts: T) -> Result> { + let plugin = self.get_sdk(sdk)?; + let ctx = MiseEnvContext { + args: vec![], + options: opts, + }; + plugin.mise_env(ctx).await + } + + pub async fn mise_path(&self, sdk: &str, opts: T) -> Result> { + let plugin = self.get_sdk(sdk)?; + let ctx = MisePathContext { + args: vec![], + options: opts, + }; + plugin.mise_path(ctx).await + } + + pub async fn parse_legacy_file( + &self, + sdk: &str, + file: &Path, + ) -> Result { + let sdk = self.get_sdk(sdk)?; + sdk.parse_legacy_file(file).await + } + + async fn download(&self, url: &Url, sdk: &Plugin, version: &str) -> Result { + self.log_emit(format!("Downloading {url}")); + let filename = url + .path_segments() + .and_then(|mut s| s.next_back()) + .ok_or("No filename in URL")?; + let path = self + .download_dir + .join(format!("{sdk}-{version}")) + .join(filename); + let resp = CLIENT.get(url.clone()).send().await?; + resp.error_for_status_ref()?; + file::mkdirp(path.parent().unwrap())?; + let mut file = tokio::fs::File::create(&path).await?; + let bytes = resp.bytes().await?; + tokio::io::AsyncWriteExt::write_all(&mut file, &bytes).await?; + Ok(path) + } + + fn verify(&self, pre_install: &PreInstall, file: &Path) -> Result<()> { + self.log_emit(format!("Verifying {file:?} checksum")); + if let Some(sha256) = &pre_install.sha256 { + xx::hash::ensure_checksum_sha256(file, sha256)?; + } + if let Some(sha512) = &pre_install.sha512 { + xx::hash::ensure_checksum_sha512(file, sha512)?; + } + if let Some(_sha1) = &pre_install.sha1 { + unimplemented!("sha1") + } + if let Some(_md5) = &pre_install.md5 { + unimplemented!("md5") + } + Ok(()) + } + + fn extract(&self, file: &Path, install_dir: &Path) -> Result<()> { + self.log_emit(format!("Extracting {file:?} to {install_dir:?}")); + let filename = file.file_name().unwrap().to_string_lossy().to_string(); + let parent = install_dir.parent().unwrap(); + file::mkdirp(parent)?; + let tmp = TempDir::with_prefix_in(&filename, parent)?; + file::remove_dir_all(install_dir)?; + let move_to_install = || { + let subdirs = file::ls(tmp.path())?; + if subdirs.len() == 1 && subdirs.first().unwrap().is_dir() { + let subdir = subdirs.first().unwrap(); + file::mv(subdir, install_dir)?; + } else { + file::mv(tmp.path(), install_dir)?; + } + Result::Ok(()) + }; + if filename.ends_with(".tar.gz") { + xx::archive::untar_gz(file, tmp.path())?; + move_to_install()?; + } else if filename.ends_with(".tar.xz") { + xx::archive::untar_xz(file, tmp.path())?; + move_to_install()?; + } else if filename.ends_with(".tar.bz2") { + xx::archive::untar_bz2(file, tmp.path())?; + move_to_install()?; + } else if filename.ends_with(".zip") { + xx::archive::unzip(file, tmp.path())?; + move_to_install()?; + } else { + file::mv(file, install_dir.join(&filename))?; + #[cfg(unix)] + file::make_executable(install_dir.join(&filename))?; + } + Ok(()) + } +} + +impl Default for Vfox { + fn default() -> Self { + Self { + runtime_version: "1.0.0".to_string(), + plugin_dir: home().join(".version-fox/plugin"), + cache_dir: home().join(".version-fox/cache"), + download_dir: home().join(".version-fox/downloads"), + install_dir: home().join(".version-fox/installs"), + log_tx: None, + } + } +} + +fn home() -> PathBuf { + homedir::my_home() + .ok() + .flatten() + .unwrap_or_else(|| PathBuf::from("/")) +} + +#[cfg(test)] +mod tests { + use super::*; + + impl Vfox { + pub fn test() -> Self { + Self { + runtime_version: "1.0.0".to_string(), + plugin_dir: PathBuf::from("plugins"), + cache_dir: PathBuf::from("test/cache"), + download_dir: PathBuf::from("test/downloads"), + install_dir: PathBuf::from("test/installs"), + log_tx: None, + } + } + } + + #[tokio::test] + async fn test_env_keys() { + let vfox = Vfox::test(); + vfox.install_plugin("nodejs").unwrap(); + let keys = vfox.env_keys("nodejs", "20.0.0").await.unwrap(); + let output = format!("{keys:?}").replace( + &vfox.install_dir.to_string_lossy().to_string(), + "", + ); + assert_snapshot!(output); + } + + #[tokio::test] + async fn test_install_plugin() { + let vfox = Vfox::test(); + vfox.uninstall_plugin("nodejs").unwrap(); + assert!(!vfox.plugin_dir.join("nodejs").exists()); + vfox.install_plugin("nodejs").unwrap(); + assert!(vfox.plugin_dir.join("nodejs").exists()); + } + + #[tokio::test] + async fn test_install() { + let vfox = Vfox::test(); + let install_dir = vfox.install_dir.join("nodejs").join("20.0.0"); + vfox.install("nodejs", "20.0.0", &install_dir) + .await + .unwrap(); + assert!(vfox + .install_dir + .join("nodejs") + .join("20.0.0") + .join("bin") + .join("node") + .exists()); + vfox.uninstall_plugin("nodejs").unwrap(); + assert!(!vfox.plugin_dir.join("nodejs").exists()); + vfox.uninstall("nodejs", "20.0.0").unwrap(); + assert!(!vfox.install_dir.join("nodejs").join("20.0.0").exists()); + file::remove_dir_all(vfox.plugin_dir.join("nodejs")).unwrap(); + file::remove_dir_all(vfox.install_dir).unwrap(); + file::remove_dir_all(vfox.download_dir).unwrap(); + } + + #[tokio::test] + async fn test_install_cmake() { + let vfox = Vfox::test(); + vfox.install_plugin("cmake").unwrap(); + let install_dir = vfox.install_dir.join("cmake").join("3.21.0"); + vfox.install("cmake", "3.21.0", &install_dir).await.unwrap(); + if cfg!(target_os = "linux") { + assert!(vfox + .install_dir + .join("cmake") + .join("3.21.0") + .join("bin") + .join("cmake") + .exists()); + } else if cfg!(target_os = "macos") { + assert!(vfox + .install_dir + .join("cmake") + .join("3.21.0") + .join("CMake.app") + .join("Contents") + .join("bin") + .join("cmake") + .exists()); + } else if cfg!(target_os = "windows") { + assert!(vfox + .install_dir + .join("cmake") + .join("3.21.0") + .join("bin") + .join("cmake.exe") + .exists()); + } + vfox.uninstall_plugin("cmake").unwrap(); + assert!(!vfox.plugin_dir.join("cmake").exists()); + vfox.uninstall("cmake", "3.21.0").unwrap(); + assert!(!vfox.install_dir.join("cmake").join("3.21.0").exists()); + file::remove_dir_all(vfox.plugin_dir.join("cmake")).unwrap(); + file::remove_dir_all(vfox.install_dir).unwrap(); + file::remove_dir_all(vfox.download_dir).unwrap(); + } + + #[tokio::test] + async fn test_metadata() { + let vfox = Vfox::test(); + let metadata = vfox.metadata("nodejs").await.unwrap(); + let out = format!("{metadata:?}"); + assert_snapshot!(out); + } +} diff --git a/crates/vfox/test/data/.node-version b/crates/vfox/test/data/.node-version new file mode 100644 index 0000000000..2a59cf43bf --- /dev/null +++ b/crates/vfox/test/data/.node-version @@ -0,0 +1 @@ +v20.0.0 diff --git a/crates/vfox/test/data/foo.zip b/crates/vfox/test/data/foo.zip new file mode 100644 index 0000000000..3377479db7 Binary files /dev/null and b/crates/vfox/test/data/foo.zip differ