chore: Merge vfox.rs into jdx/mise monorepo (#5590)

Integrate vfox.rs into the mise monorepo by moving its source into a new
`crates/vfox` directory.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2025-07-12 06:54:20 -05:00
1 parent 1f1865ba3a
commit 67286cfc7a
53 files changed
+2947 -5

No files matched your search

-1
View File
@@ -1,4 +1,3 @@
#paths = ["../vfox.rs"]
#paths = ["../usage/lib"]
[env]
Generated
+5 -2
View File
@@ -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",
+4 -2
View File
@@ -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"] }
+59
View File
@@ -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}}"]
+21
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
A rust interface to [vfox](https://github.com/version-fox/vfox).
+256
View File
@@ -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
+283
View File
@@ -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
@@ -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
}
+22
View File
@@ -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");
}
+13
View File
@@ -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(())
}
}
+18
View File
@@ -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(())
}
}
+26
View File
@@ -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<PathBuf>,
}
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(())
}
}
+38
View File
@@ -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
}
+17
View File
@@ -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(())
}
}
+23
View File
@@ -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,
}
}
}
+45
View File
@@ -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<Option<Config>> = Mutex::new(None);
impl Config {
pub fn get() -> Self {
Self::_get().as_ref().unwrap().clone()
}
fn _get() -> MutexGuard<'static, Option<Config>> {
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(),
}
}
+16
View File
@@ -0,0 +1,16 @@
use mlua::{UserData, UserDataFields};
#[derive(Debug)]
pub(crate) struct Context {
pub args: Vec<String>,
pub(crate) version: Option<String>,
// pub(crate) runtime_version: String,
}
impl UserData for Context {
fn add_fields<F: UserDataFields<Self>>(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()));
}
}
+43
View File
@@ -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<T> = std::result::Result<T, VfoxError>;
impl From<String> 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)*)));
};
}
+95
View File
@@ -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<Vec<AvailableVersion>> {
self.available_async().await
}
pub async fn available_async(&self) -> Result<Vec<AvailableVersion>> {
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<String>,
// pub addition: Option<Table>,
}
impl FromLua for AvailableVersion {
fn from_lua(value: Value, _: &Lua) -> std::result::Result<Self, LuaError> {
match value {
Value::Table(table) => {
// TODO: try to default this to an empty table or something
// let addition = table.get::<Option<Table>>("addition")?;
Ok(AvailableVersion {
version: table.get::<String>("version")?,
note: table.get::<Option<String>>("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<String> {
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<String> {
let p = Plugin::test(plugin);
let r = p.available_async().await.unwrap();
r.iter().map(|v| v.version.clone()).collect()
}
}
+60
View File
@@ -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<String>,
pub version: String,
pub path: PathBuf,
pub main: SdkInfo,
pub sdk_info: BTreeMap<String, SdkInfo>,
}
impl Plugin {
pub async fn env_keys(&self, ctx: EnvKeysContext) -> Result<Vec<EnvKey>> {
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<Value> {
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<Self, LuaError> {
match value {
Value::Table(table) => Ok(EnvKey {
key: table.get::<String>("key")?,
value: table.get::<String>("value")?,
}),
_ => panic!("Expected table"),
}
}
}
+36
View File
@@ -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<T: serde::Serialize> {
pub args: Vec<String>,
pub options: T,
}
impl Plugin {
pub async fn mise_env<T: serde::Serialize>(
&self,
ctx: MiseEnvContext<T>,
) -> Result<Vec<EnvKey>> {
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<T: serde::Serialize> IntoLua for MiseEnvContext<T> {
fn into_lua(self, lua: &Lua) -> mlua::Result<Value> {
let table = lua.create_table()?;
table.set("options", lua.to_value(&self.options)?)?;
Ok(Value::Table(table))
}
}
+35
View File
@@ -0,0 +1,35 @@
use mlua::{IntoLua, Lua, LuaSerdeExt, Value};
use crate::error::Result;
use crate::Plugin;
#[derive(Debug)]
pub struct MisePathContext<T: serde::Serialize> {
pub args: Vec<String>,
pub options: T,
}
impl Plugin {
pub async fn mise_path<T: serde::Serialize>(
&self,
ctx: MisePathContext<T>,
) -> Result<Vec<String>> {
debug!("[vfox:{}] mise_path", &self.name);
let path = self
.eval_async(chunk! {
require "hooks/mise_path"
return PLUGIN:MisePath($ctx)
})
.await?;
Ok(path)
}
}
impl<T: serde::Serialize> IntoLua for MisePathContext<T> {
fn into_lua(self, lua: &Lua) -> mlua::Result<Value> {
let table = lua.create_table()?;
table.set("options", lua.to_value(&self.options)?)?;
Ok(Value::Table(table))
}
}
+8
View File
@@ -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;
@@ -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<String>,
pub filepath: PathBuf,
}
#[derive(Debug)]
pub struct ParseLegacyFileResponse {
pub version: Option<String>,
}
impl Plugin {
pub async fn parse_legacy_file(&self, legacy_file: &Path) -> Result<ParseLegacyFileResponse> {
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<Value> {
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::<PathBuf>("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::<Vec<String>>())
})?,
)?;
Ok(Value::Table(table))
}
}
impl FromLua for ParseLegacyFileResponse {
fn from_lua(value: Value, _: &Lua) -> std::result::Result<Self, LuaError> {
match value {
Value::Table(table) => Ok(ParseLegacyFileResponse {
version: table.get::<Option<String>>("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") }"###);
}
}
+52
View File
@@ -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<String, SdkInfo>,
}
impl IntoLua for PostInstallContext {
fn into_lua(self, lua: &Lua) -> mlua::Result<Value> {
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();
}
}
+99
View File
@@ -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<PreInstall> {
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<String>,
pub note: Option<String>,
pub sha256: Option<String>,
pub md5: Option<String>,
pub sha1: Option<String>,
pub sha512: Option<String>,
// pub addition: Option<Table>,
}
impl FromLua for PreInstall {
fn from_lua(value: Value, _: &Lua) -> std::result::Result<Self, LuaError> {
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::<String>("version")?,
url: table.get::<Option<String>>("url")?,
note: table.get::<Option<String>>("note")?,
sha256: table.get::<Option<String>>("sha256")?,
md5: table.get::<Option<String>>("md5")?,
sha1: table.get::<Option<String>>("sha1")?,
sha512: table.get::<Option<String>>("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()
}
}
+28
View File
@@ -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<String, SdkInfo>,
}
#[derive(Debug)]
pub struct PreUseResponse {
pub version: Option<String>,
}
impl Plugin {
pub async fn pre_use(&self, _vfox: &Vfox, _legacy_file: &Path) -> Result<PreUseResponse> {
debug!("[vfox:{}] pre_use", &self.name);
// let ctx = PreUseContext {
// installed_sdks: vfox.list_installed_versions(&self.name)?,
// };
unimplemented!("pre_use hook is not implemented");
}
}
@@ -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,
}
@@ -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,
}
@@ -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,
}
@@ -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,
}
+9
View File
@@ -0,0 +1,9 @@
use reqwest::{Client, ClientBuilder};
use std::sync::LazyLock;
pub static CLIENT: LazyLock<Client> = LazyLock::new(|| {
ClientBuilder::new()
.user_agent(format!("vfox.rs/{}", env!("CARGO_PKG_VERSION")))
.build()
.expect("Failed to create reqwest client")
});
+25
View File
@@ -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;
+59
View File
@@ -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<mlua::Value> = 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();
}
}
+50
View File
@@ -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();
}
}
}
+67
View File
@@ -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<String> = input
.into_iter()
.map(|v| v.to_string())
.collect::<mlua::Result<_>>()?;
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();
}
}
+39
View File
@@ -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<BTreeSet<&'static str>> {
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)
}
+149
View File
@@ -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::<Table>()?,
)?;
loaded.set(
"htmlparser.ElementNode",
lua.load(include_str!("../../lua/htmlparser/ElementNode.lua"))
.eval::<Table>()?,
)?;
loaded.set(
"htmlparser",
lua.load(include_str!("../../lua/htmlparser.lua"))
.eval::<Table>()?,
)?;
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::<Table>()?,
)?;
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("<html><body><div id='t2' name='123'>456</div><DIV foo='bar'>222</DIV></body></html>")
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();
}
}
+133
View File
@@ -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<Table> {
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<Table> {
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<Table> {
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();
}
}
+59
View File
@@ -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<String> {
serde_json::to_string(&value).into_lua_err()
}
fn decode(lua: &Lua, value: String) -> Result<Value> {
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();
}
}
+17
View File
@@ -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;
+82
View File
@@ -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<Vec<String>> {
Ok(s.split(&sep).map(|s| s.to_string()).collect())
}
fn has_prefix(_lua: &Lua, (s, prefix): (String, String)) -> LuaResult<bool> {
Ok(s.starts_with(&prefix))
}
fn has_suffix(_lua: &Lua, (s, suffix): (String, String)) -> LuaResult<bool> {
Ok(s.ends_with(&suffix))
}
fn trim(_lua: &Lua, (s, suffix): (String, String)) -> LuaResult<String> {
Ok(s.trim_end_matches(&suffix).to_string())
}
fn trim_space(_lua: &Lua, s: String) -> LuaResult<String> {
Ok(s.trim().to_string())
}
fn contains(_lua: &Lua, (s, substr): (String, String)) -> LuaResult<bool> {
Ok(s.contains(&substr))
}
fn join(_lua: &Lua, (arr, sep): (Vec<Value>, String)) -> LuaResult<String> {
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();
}
}
+36
View File
@@ -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<String>,
pub version: String,
pub description: Option<String>,
pub author: Option<String>,
pub license: Option<String>,
pub homepage: Option<String>,
pub hooks: BTreeSet<&'static str>,
}
impl TryFrom<Table> for Metadata {
type Error = VfoxError;
fn try_from(t: Table) -> Result<Self> {
let legacy_filenames = t
.get::<Option<Vec<String>>>("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(),
})
}
}
+203
View File
@@ -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<Metadata>,
}
impl Plugin {
pub fn from_dir(dir: &Path) -> Result<Self> {
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<Self> {
let dir = Config::get().plugin_dir.join(name);
Self::from_dir(&dir)
}
pub fn list() -> Result<Vec<String>> {
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<Metadata> {
Ok(self.load()?.clone())
}
pub fn sdk_info(&self, version: String, install_dir: PathBuf) -> Result<SdkInfo> {
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<String>) -> Result<Context> {
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<R>
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<V>(&self, name: &str, value: V) -> Result<()>
where
V: IntoLua,
{
self.lua.globals().set(name, value)?;
Ok(())
}
fn load_metadata(&self) -> Result<Table> {
let metadata = self
.lua
.load(
r#"
require "metadata"
return PLUGIN
"#,
)
.eval()?;
Ok(metadata)
}
}
fn get_package(lua: &Lua) -> Result<Table> {
let package = lua.globals().get::<Table>("package")?;
Ok(package)
}
fn set_paths(lua: &Lua, paths: &[PathBuf]) -> Result<()> {
let paths = paths
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect::<Vec<String>>()
.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<Self> 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<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Plugin {
fn cmp(&self, other: &Self) -> Ordering {
self.name.cmp(&other.name)
}
}
+23
View File
@@ -0,0 +1,23 @@
use std::collections::BTreeMap;
use std::str::FromStr;
use once_cell::sync::Lazy;
use url::Url;
static SDKS: Lazy<BTreeMap<String, Url>> = 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<String, Url> {
&SDKS
}
+58
View File
@@ -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<Mutex<Runtime>> = 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<F: UserDataFields<Self>>(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()));
}
}
+29
View File
@@ -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<Value> {
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))
}
}
@@ -0,0 +1,5 @@
---
source: src/vfox.rs
expression: output
---
[EnvKey { key: "PATH", value: "<INSTALL_DIR>/nodejs/20.0.0/bin" }]
@@ -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"} }
+426
View File
@@ -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<mpsc::Sender<String>>,
}
impl Vfox {
pub fn new() -> Self {
Self::default()
}
pub fn log_subscribe(&mut self) -> mpsc::Receiver<String> {
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<String, Url> {
registry::list_sdks()
}
pub async fn list_available_versions(&self, sdk: &str) -> Result<Vec<AvailableVersion>> {
let sdk = self.get_sdk(sdk)?;
sdk.available_async().await
}
pub fn list_installed_versions(&self, sdk: &str) -> Result<Vec<SdkInfo>> {
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::<Result<_>>()
}
pub fn list_sdks(&self) -> Result<Vec<Plugin>> {
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> {
Plugin::from_dir(&self.plugin_dir.join(name))
}
pub fn install_plugin(&self, sdk: &str) -> Result<Plugin> {
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<Plugin> {
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<ID: AsRef<Path>>(
&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<Metadata> {
self.get_sdk(sdk)?.get_metadata()
}
pub async fn env_keys(&self, sdk: &str, version: &str) -> Result<Vec<EnvKey>> {
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<T: serde::Serialize>(&self, sdk: &str, opts: T) -> Result<Vec<EnvKey>> {
let plugin = self.get_sdk(sdk)?;
let ctx = MiseEnvContext {
args: vec![],
options: opts,
};
plugin.mise_env(ctx).await
}
pub async fn mise_path<T: serde::Serialize>(&self, sdk: &str, opts: T) -> Result<Vec<String>> {
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<ParseLegacyFileResponse> {
let sdk = self.get_sdk(sdk)?;
sdk.parse_legacy_file(file).await
}
async fn download(&self, url: &Url, sdk: &Plugin, version: &str) -> Result<PathBuf> {
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(),
"<INSTALL_DIR>",
);
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);
}
}
+1
View File
@@ -0,0 +1 @@
v20.0.0
Binary file not shown.