#!/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