#!/usr/bin/env texlua
-- Description: Install TeX packages and their dependencies
-- Copyright: 2023-2024 (c) Jianrui Lyu <
[email protected]>
-- Repository:
https://github.com/lvjr/texfindpkg
-- License: GNU General Public License v3.0
local tfp = tfp or {}
tfp.version = "2024A"
tfp.date = "2024-11-22"
local building = tfp.building
local tfpresult = ""
------------------------------------------------------------
--> \section{Some variables and functions}
------------------------------------------------------------
local lfs = require("lfs")
local insert = table.insert
local remove = table.remove
local concat = table.concat
local gmatch = string.gmatch
local match = string.match
local find = string.find
local gsub = string.gsub
local sub = string.sub
local rep = string.rep
local lookup = kpse.lookup
kpse.set_program_name("kpsewhich")
require(lookup("lualibs.lua"))
local json = utilities.json -- for json.tostring and json.tolua
local gzip = gzip -- for gzip.compress and gzip.decompress
local function tfpPrint(msg)
msg = "[tfp] " .. msg
if building then
tfpresult = tfpresult .. msg .. "\n"
else
print(msg)
end
end
local function tfpRealPrint(msg)
if not building then
print("[tfp] " .. msg)
end
end
local showdbg = false
local function dbgPrint(msg)
if showdbg then print("[debug] " .. msg) end
end
local function valueExists(tab, val)
for _, v in ipairs(tab) do
if v == val then return true end
end
return false
end
local function getFiles(path, pattern)
local files = { }
for entry in lfs.dir(path) do
if match(entry, pattern) then
insert(files, entry)
end
end
return files
end
local function fileRead(input)
local f = io.open(input, "rb")
local text
if f then -- file exists and is readable
text = f:read("*all")
f:close()
--print(#text)
return text
end
-- return nil if file doesn't exists or isn't readable
end
local function fileWrite(text, output)
-- using "wb" keeps unix eol characters
f = io.open(output, "wb")
f:write(text)
f:close()
end
local function testDistribution()
-- texlive returns "texmf-dist/web2c/updmap.cfg"
-- miktex returns nil although there is "texmfs/install/miktex/config/updmap.cfg"
local d = lookup("updmap.cfg")
if d then
return "texlive"
else
return "miktex"
end
end
------------------------------------------------------------
--> \section{Handle TeX Live package database}
------------------------------------------------------------
local tlpkgtext
local tlinspkgtext
local function tlReadPackageDB()
local tlroot = kpse.var_value("TEXMFROOT")
if tlroot then
tlroot = tlroot .. "/tlpkg"
else
tfpPrint("error in finding texmf root!")
end
local list = getFiles(tlroot, "^texlive%.tlpdb%.main")
if #list > 0 then
tlpkgtext = fileRead(tlroot .. "/" .. list[1])
if not tlpkgtext then
tfpPrint("error in reading texlive.tlpdb.main file!")
end
else
-- no texlive.tlpdb.main file in a fresh TeX live
tfpPrint("error in finding texlive package database!")
tfpPrint("please run 'tlmgr update --self' first.")
end
tlinspkgtext = fileRead(tlroot .. "/texlive.tlpdb")
if not tlinspkgtext then
tfpPrint("error in reading texlive.tlpdb file!")
end
end
local tlfiletopkg = {}
local tlpkgtofile = {}
local tlinspkgdata = {}
local function tlExtractFiles(name, desc)
-- ignore binary packages
-- also ignore latex-dev packages
if find(name, "%.") or find(name, "^latex%-[%a]-%-dev") then
--print(name)
return
end
-- ignore package files in doc folder
desc = match(desc, "\nrunfiles .+") or ""
local flist = {}
for base, ext in gmatch(desc, "/([%a%d%-%.]+)%.([%a%d]+)\n") do
if ext == "sty" or ext == "cls" or ext == "tex" or ext == "ltx" then
dbgPrint(name, base .. "." .. ext)
tlfiletopkg[base .. "." .. ext] = name
insert(flist, base .. "." .. ext)
end
end
tlpkgtofile[name]= flist
end
local function tlExtractPackages(name, desc)
tlinspkgdata[name] = true
end
local function tlParsePackageDB(tlpkgtext)
gsub(tlpkgtext, "name (.-)\n(.-)\n\n", tlExtractFiles)
return tlfiletopkg
end
local function tlParseTwoPackageDB()
gsub(tlpkgtext, "name (.-)\n(.-)\n\n", tlExtractFiles)
-- texlive.tlpdb might use different eol characters
gsub(tlinspkgtext, "name (.-)\r?\n(.-)\r?\n\r?\n", tlExtractPackages)
end
------------------------------------------------------------
--> \section{Handle MiKTeX package database}
------------------------------------------------------------
local mtpkgtext
local mtinspkgtext
local function mtReadPackageDB()
local mtvar = kpse.var_value("TEXMFDIST")
if mtvar then
mtpkgtext = fileRead(mtvar .. "/miktex/config/package-manifests.ini")
if not mtpkgtext then
tfpPrint("error in reading package-manifests.ini file!")
end
mtinspkgtext = fileRead(mtvar .. "/miktex/config/packages.ini")
if not mtinspkgtext then
tfpPrint("error in reading packages.ini file!")
end
else
tfpPrint("error in finding texmf root!")
end
end
local mtfiletopkg = {}
local mtpkgtofile = {}
local mtinspkgdata = {}
local function mtExtractFiles(name, desc)
-- ignore package files in source or doc folders
-- also ignore latex-dev packages
if find(name, "_") or find(name, "^latex%-[%a]-%-dev") then
--print(name)
return
end
local flist = {}
for base, ext in gmatch(desc, "/([%a%d%-%.]+)%.([%a%d]+)\r?\n") do
if ext == "sty" or ext == "cls" or ext == "tex" or ext == "ltx" then
dbgPrint(name, base .. "." .. ext)
mtfiletopkg[base .. "." .. ext] = name
insert(flist, base .. "." .. ext)
end
end
mtpkgtofile[name]= flist
end
local function mtExtractPackages(name, desc)
mtinspkgdata[name] = true
end
local function mtParsePackageDB(mtpkgtext)
-- package-manifests.ini might use different eol characters
gsub(mtpkgtext, "%[(.-)%]\r?\n(.-)\r?\n\r?\n", mtExtractFiles)
return mtfiletopkg
end
local function mtParseTwoPackageDB()
-- package-manifests.ini and packages.ini might use different eol characters
gsub(mtpkgtext, "%[(.-)%]\r?\n(.-)\r?\n\r?\n", mtExtractFiles)
gsub(mtinspkgtext, "%[(.-)%]\r?\n(.-)\r?\n\r?\n", mtExtractPackages)
end
------------------------------------------------------------
--> \section{Install packages in current TeX distribution}
------------------------------------------------------------
local dist -- name of current tex distribution
local totaldeplist = {} -- list of all depending packages
local totalinslist = {} -- list of all missing packages
local filecount = 0 -- total number of files found
local function initPackageDB()
dist = testDistribution()
tfpPrint("you are using " .. dist)
if dist == "texlive" then
tlReadPackageDB()
tlParseTwoPackageDB()
else
mtReadPackageDB()
mtParseTwoPackageDB()
end
end
local function findPackageFromFile(fname)
if dist == "texlive" then
return tlfiletopkg[fname]
else
return mtfiletopkg[fname]
end
end
local function findFilesInPackage(pkg)
if dist == "texlive" then
return tlpkgtofile[pkg]
else
return mtpkgtofile[pkg]
end
end
local function checkInsPakage(pkg)
if dist == "texlive" then
return tlinspkgdata[pkg]
else
return mtinspkgdata[pkg]
end
end
local function tfpExecute(c)
if not building then
if os.type == "windows" then
os.execute(c)
else
os.execute('sudo env "PATH=$PATH" ' .. c)
end
end
end
local function installSomePackages(list)
if not list then return end
if dist == "texlive" then
local pkgs = concat(list, " ")
if #list > 1 then
tfpRealPrint("installing texlive packages: " .. pkgs)
else
tfpRealPrint("installing texlive package: " .. pkgs)
end
tfpExecute("tlmgr install " .. pkgs)
else
-- miktex fails if one of the packages is already installed
-- so we install miktex packages one by one
for _, p in ipairs(list) do
tfpRealPrint("installing miktex package: " .. p)
tfpExecute("miktex packages install " .. p)
end
end
end
local function updateTotalInsList(inslist)
if #totalinslist == 0 then
totalinslist = inslist
else
for _, pkg in ipairs(inslist) do
if not valueExists(totalinslist, pkg) then
insert(totalinslist, pkg)
end
end
end
end
local function listSomePackages(list)
if not list then return {} end
if #list > 0 then
filecount = filecount + 1
end
table.sort(list)
local pkgs = concat(list, " ")
if #list == 1 then
tfpPrint(dist .. " package needed: " .. pkgs)
else
tfpPrint(dist .. " packages needed: " .. pkgs)
end
local inslist = {}
for _, p in ipairs(list) do
if not checkInsPakage(p) then
insert(inslist, p)
end
end
if #inslist == 0 then
if #list == 1 then
tfpRealPrint("this package is already installed")
else
tfpRealPrint("these packages are already installed")
end
else
table.sort(inslist)
local pkgs = concat(inslist, " ")
if #inslist == 1 then
tfpRealPrint(dist .. " package not yet installed: " .. pkgs)
else
tfpRealPrint(dist .. " packages not yet installed: " .. pkgs)
end
end
updateTotalInsList(inslist)
end
------------------------------------------------------------
--> \section{Find dependencies of package files}
------------------------------------------------------------
local tfptext = "" -- the json text
local tfpdata = {} -- the lua object
local fnlist = {} -- file name list
local pkglist = {} -- package name list
local function initDependencyDB()
local ziptext = fileRead(lookup("texfindpkg.json.gz"))
tfptext = gzip.decompress(ziptext)
if tfptext then
--print(tfptext)
tfpdata = json.tolua(tfptext)
else
tfpPrint("error in reading texfindpkg.json.gz!")
end
end
local function printDependency(fname, level)
local msg = fname
local pkg = findPackageFromFile(fname)
if pkg then
msg = msg .. " (from " .. pkg .. ")"
if not valueExists(pkglist, pkg) then
insert(pkglist, pkg)
end
if not valueExists(totaldeplist, pkg) then
insert(totaldeplist, pkg)
end
else
msg = msg .. " (not found)"
end
if level == 0 then
tfpPrint(msg)
else
tfpPrint(rep(" ", level - 1) .. "|- " .. msg)
end
end
local function findDependencies(fname, level)
--print(fname)
if valueExists(fnlist, fname) then return end
local item = tfpdata[fname]
if not item then
-- no dependency info for fname
printDependency(fname, level)
return
end
-- finding dependencies for fname
printDependency(fname, level)
insert(fnlist, fname)
local deps = item.deps
if deps then
for _, dname in ipairs(deps) do
findDependencies(dname, level + 1)
end
end
end
local function queryByFileName(fname)
fnlist, pkglist = {}, {} -- reset the list
if not find(fname, "%.") then
fname = fname .. ".sty"
end
tfpPrint("building dependency tree for " .. fname .. ":")
tfpPrint(rep("-", 24))
findDependencies(fname, 0)
tfpPrint(rep("-", 24))
if #fnlist == 0 then
tfpPrint("could not find any package with file " .. fname)
return
end
if #pkglist == 0 then
tfpPrint("error in finding package in " .. dist)
return
end
listSomePackages(pkglist)
end
local function queryByPackageName(pname)
local list = findFilesInPackage(pname)
if list == nil then
tfpPrint(dist .. " package " .. pname .. " doesn't exist")
return
end
if #list > 0 then
tfpPrint("finding package files in " .. dist .. " package " .. pname)
for _, fname in ipairs(list) do
tfpPrint(rep("=", 48))
tfpPrint("found package file " .. fname .. " in " .. dist .. " package " .. pname)
queryByFileName(fname)
end
else
tfpPrint("could not find any package file in " .. dist .. " package " .. pname)
listSomePackages({pname})
if not valueExists(totaldeplist, pname) then
insert(totaldeplist, pname)
end
end
end
local function getFileNameFromCmdEnvName(cmdenv, name)
--print(name)
local flist = {}
for line in gmatch(tfptext, "(.-)\n[,}]") do
if find(line, '"' .. name .. '"') then
--print(line)
local fname, fspec = match(line, '"(.-)":(.+)')
--print(fname, fspec)
local item = json.tolua(fspec)
if item[cmdenv] and valueExists(item[cmdenv], name) then
insert(flist, fname)
end
end
end
return flist
end
local function queryByCommandName(cname)
--print(cname)
local flist = getFileNameFromCmdEnvName("cmds", cname)
if #flist > 0 then
for _, fname in ipairs(flist) do
tfpPrint(rep("=", 48))
tfpPrint("found package file " .. fname .. " with command \\" .. cname)
queryByFileName(fname)
end
else
tfpPrint("could not find any package with command \\" .. cname)
end
end
local function queryByEnvironmentName(ename)
--print(ename)
local flist = getFileNameFromCmdEnvName("envs", ename)
if #flist > 0 then
for _, fname in ipairs(flist) do
tfpPrint(rep("=", 48))
tfpPrint("found package file " .. fname .. " with environment {" .. ename .. "}")
queryByFileName(fname)
end
else
tfpPrint("could not find any package with environment {" .. ename .. "}")
end
end
local function queryOne(t, name)
if t == "cmd" then
queryByCommandName(name)
elseif t == "env" then
queryByEnvironmentName(name)
elseif t == "file" then
tfpPrint(rep("=", 48))
queryByFileName(name)
else -- t == "pkg"
tfpPrint(rep("=", 48))
queryByPackageName(name)
end
end
local outfile = nil
local function query(namelist)
for _, v in ipairs(namelist) do
queryOne(v[1], v[2])
end
if filecount > 1 then
tfpRealPrint(rep("=", 48))
table.sort(totaldeplist)
local pkgs = concat(totaldeplist, " ")
if #totaldeplist == 0 then
--tfpRealPrint("no packages needed are found")
elseif #totaldeplist == 1 then
tfpRealPrint(dist .. " package needed in total: " .. pkgs)
else
tfpRealPrint(dist .. " packages needed in total: " .. pkgs)
end
tfpRealPrint(rep("=", 48))
table.sort(totalinslist)
local pkgs = concat(totalinslist, " ")
if #totalinslist == 0 then
tfpRealPrint("you don't need to install any packages")
elseif #totalinslist == 1 then
tfpRealPrint(dist .. " package not yet installed in total: " .. pkgs)
else
tfpRealPrint(dist .. " packages not yet installed in total: " .. pkgs)
end
if outfile then
--print(outfile)
pkgs = concat(totaldeplist, "\n")
fileWrite(pkgs, outfile)
end
end
end
local function install(namelist)
query(namelist)
if #totalinslist > 0 then
installSomePackages(totalinslist)
end
end
------------------------------------------------------------
--> \section{Parse query or install arguments}
------------------------------------------------------------
local function parseName(name)
local h = sub(name, 1, 1)
if h == "\\" then
local b = sub(name, 2)
return({"cmd", b})
elseif h == "{" then
if sub(name, -1) == "}" then
local b = sub(name, 2, -2)
return({"env", b})
else
error("invalid name '" .. name .. "'")
end
elseif find(name, "%.") then
return({"file", name})
else
return({"pkg", name})
end
end
local function readArgsInFile(list, inname)
local intext = fileRead(inname)
if not intext then
tfpPrint("error in reading input file " .. inname)
return list
end
tfpPrint("reading input file " .. inname)
for line in gmatch(intext, "%s*(.-)%s*\r?\n") do
line = match(line, "(.-)%s*#") or line
--print("|" .. line .. "|")
if line ~= "" then
insert(list, line)
end
end
return list
end
local function readArgList(arglist)
local reallist = {}
local isinput = false
local isoutput = false
for _, v in ipairs(arglist) do
if isinput then
reallist = readArgsInFile(reallist, v)
isinput = false
elseif isoutput then
outfile = v
isoutput = false
elseif v == "-i" then
isinput = true
elseif v == "-o" then
isoutput = true
else
insert(reallist, v)
end
end
return reallist
end
local function parseArgList(arglist)
local reallist = readArgList(arglist)
local namelist = {}
local nametype = nil
for _, v in ipairs(reallist) do
if v == "-c" then
nametype = "cmd"
elseif v == "-e" then
nametype = "env"
elseif v == "-f" then
nametype = "file"
elseif v == "-p" then
nametype = "pkg"
else
if nametype then
insert(namelist, {nametype, v})
else
insert(namelist, parseName(v))
end
end
end
if #namelist == 0 then
error("missing the name of file/cmd/env!")
else
return namelist
end
end
local function doQuery(arglist)
local namelist = parseArgList(arglist)
initPackageDB()
initDependencyDB()
query(namelist)
end
local function doInstall(arglist)
local namelist = parseArgList(arglist)
initPackageDB()
initDependencyDB()
install(namelist)
end
------------------------------------------------------------
--> \section{Print help or version text}
------------------------------------------------------------
local helptext = [[
usage: texfindpkg <action> [<options>] [<name>]
valid actions are:
install Install some package and its dependencies
query Query dependencies for some package
help Print this message and exit
version Print version information and exit
valid options are:
-c Query or install by command name
-e Query or install by environment name
-f Query or install by file name
-p Query or install by package name
-i Read arguments line by line from a file
-o Write total dependent list to a file
please report bug at
https://github.com/lvjr/texfindpkg
]]
local function help()
print(helptext)
end
local function version()
print("TeXFindPkg Version " .. tfp.version .. " (" .. tfp.date .. ")\n")
end
------------------------------------------------------------
--> \section{Respond to user input}
------------------------------------------------------------
local function tfpMain(tfparg)
tfpresult = ""
if tfparg[1] == nil then return help() end
local action = remove(tfparg, 1)
action = match(action, "^%-*(.*)$") -- remove leading dashes
--print(action)
if action == "query" then
doQuery(tfparg)
elseif action == "install" then
doInstall(tfparg)
elseif action == "help" then
help()
elseif action == "version" then
version()
else
tfpPrint("unknown action '" .. action .. "'")
help()
end
return tfpresult
end
local function main()
tfpMain(arg)
end
if building then
tfp.tfpMain = tfpMain
tfp.showdbg = showdbg
tfp.dbgPrint = dbgPrint
tfp.tfpPrint = tfpPrint
tfp.fileRead = fileRead
tfp.fileWrite = fileWrite
tfp.getFiles = getFiles
tfp.valueExists = valueExists
tfp.json = json
tfp.gzip = gzip
tfp.tlParsePackageDB = tlParsePackageDB
tfp.mtParsePackageDB = mtParsePackageDB
return tfp
else
main()
end