-- pkgmeta.lua -- -- Lua backend for the pkgmeta package. -- -- Copyright (C) 2026 Amer Amikhteh -- -- This file is part of the pkgmeta package. -- -- This work is free software: you may redistribute it and/or modify -- it under the terms of the LaTeX Project Public License as published -- by the LaTeX Project, either version 1.3c of the License, or -- (at your option) any later version. -- -- This work is distributed in the hope that it will be useful, -- but WITHOUT ANY WARRANTY; without even the implied warranty of -- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. -- -- The full license text is available at: -- https://www.latex-project.org/lppl/ -- -- This work is "maintained" by Amer Amikhteh. -- -- The current maintainer of this work is Amer Amikhteh. -- -- Version: 1.0 -- Date: 2026-09-29 local M = {} local function trim(value) if not value then return nil end return value:match("^%s*(.-)%s*$") end local function texescape(value) local s = tostring(value or "") local replacements = { ["\\"] = "\\textbackslash{}", ["{"] = "\\{", ["}"] = "\\}", ["$"] = "\\$", ["&"] = "\\&", ["#"] = "\\#", ["^"] = "\\textasciicircum{}", ["_"] = "\\_", ["%"] = "\\%", ["~"] = "\\textasciitilde{}" } return (s:gsub("[\\{}$&#^_%%~]", replacements)) end local function readfile(path) local file = io.open(path, "rb") if not file then return nil end local contents = file:read("*a") file:close() return contents end local function without_comments(contents) local result = {} for line in (contents .. "\n"):gmatch("(.-)\n") do local pieces = {} local i = 1 while i <= #line do local ch = line:sub(i, i) if ch == "%" then local preceding = 0 local j = i - 1 while j > 0 and line:sub(j, j) == "\\" do preceding = preceding + 1 j = j - 1 end if preceding % 2 == 0 then break end end pieces[#pieces + 1] = ch i = i + 1 end result[#result + 1] = table.concat(pieces) end return table.concat(result, "\n") end local function resolve_macro(contents, expression) local name = expression:match("^%s*\\([%a@:_]+)%s*$") if not name then return nil end -- Escape only Lua pattern metacharacters. -- @, :, and _ are ordinary characters. local escaped_name = name:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1") local definition = contents:match( "\\def%s*\\" .. escaped_name .. "%s*{([^{}]*)}" ) or contents:match( "\\gdef%s*\\" .. escaped_name .. "%s*{([^{}]*)}" ) or contents:match( "\\edef%s*\\" .. escaped_name .. "%s*{([^{}]*)}" ) or contents:match( "\\newcommand%s*{%s*\\" .. escaped_name .. "%s*}%s*{([^{}]*)}" ) or contents:match( "\\newcommand%s*%*\\" .. escaped_name .. "%s*{([^{}]*)}" ) if not definition then return nil end -- Resolve nested macros. for _ = 1, 10 do local changed = false definition = definition:gsub( "\\[%a@:_]+", function(macro) if macro == "\\space" then changed = true return " " end local value = resolve_macro(contents, macro) if value then changed = true return value end return macro end ) if not changed then break end end return definition end local function resolve_macros(contents, value) for _ = 1, 10 do local changed = false value = value:gsub( "\\[%a@:_]+", function(macro) if macro == "\\space" then changed = true return " " end local resolved = resolve_macro(contents, macro) if resolved then changed = true return resolved end return macro end ) if not changed then break end end return value end local function extract_description(full, date, version) if not full or not date or not version then return nil end local position = full:find(date, 1, true) if not position then return nil end local remainder = full:sub(position + #date) remainder = trim(remainder) if remainder:sub(1, 1) == "v" then remainder = remainder:sub(2) end if remainder:sub(1, #version) == version then remainder = remainder:sub(#version + 1) end return trim(remainder) end local function remove_leading_v(value) value = trim(value) if value and value:match("^v") then return value:sub(2) end return value end local function extract(contents) local source = without_comments(contents) -- ======================================================== -- \ProvidesExplPackage -- ======================================================== local date, version, description = source:match( "\\ProvidesExplPackage%s*{[^{}]*}%s*{([^{}]*)}%s*{([^{}]*)}%s*{([^{}]*)}" ) if date then date = trim(date) version = remove_leading_v(version) description = trim(description) local year, month, day = date:match("^(%d%d%d%d)[/-](%d%d)[/-](%d%d)$") return { full = date .. " " .. version .. " " .. description, date = date, version = version, year = year, month = month, day = day, description = description } end -- ======================================================== -- \ProvidesExplFile -- ======================================================== date, version, description = source:match( "\\ProvidesExplFile%s*{[^{}]*}%s*{([^{}]*)}%s*{([^{}]*)}%s*{([^{}]*)}" ) if date then date = trim(date) version = remove_leading_v(version) description = trim(description) local year, month, day = date:match("^(%d%d%d%d)[/-](%d%d)[/-](%d%d)$") return { full = date .. " " .. version .. " " .. description, date = date, version = version, year = year, month = month, day = day, description = description } end -- ======================================================== -- \ProvidesPackage / \ProvidesFile -- ======================================================== local full = source:match( "\\ProvidesPackage%s*{[^{}]*}%s*%[([^%]]*)%]" ) or source:match( "\\ProvidesFile%s*{[^{}]*}%s*%[([^%]]*)%]" ) if not full then return nil end -- ======================================================== -- Date -- ======================================================== date = full:match( "([0-9][0-9][0-9][0-9][/-][0-9][0-9][/-][0-9][0-9])" ) date = trim(date) -- ======================================================== -- Version -- ======================================================== local version if date then local position = full:find(date, 1, true) local remainder = full:sub(position + #date) version = remainder:match( "^%s*v([0-9][%w%.%-]*)" ) or remainder:match( "^%s*([0-9][%w%.%-]*)" ) version = trim(version) end -- ======================================================== -- Date macro -- ======================================================== if not date then local date_macro = full:match( "(\\[%a@:_]*date[%a@:_]*)" ) if date_macro then date = resolve_macro( source, date_macro ) date = trim(date) end end -- ======================================================== -- Version macro -- ======================================================== if not version then local version_macro = full:match( "(\\[%a@:_]*version[%a@:_]*)" ) if version_macro then version = resolve_macro( source, version_macro ) else version = full:match( "v([0-9][%w%.%-]*)" ) end version = trim(version) end -- ======================================================== -- Date components -- ======================================================== local year local month local day if date then year, month, day = date:match( "^(%d%d%d%d)[/-](%d%d)[/-](%d%d)" ) end return { full = trim(full), date = trim(date), version = trim(version), year = trim(year), month = trim(month), day = trim(day), description = nil } end function M.get(pkg, key) pkg = tostring(pkg) :gsub("^%s+", "") :gsub("%s+$", "") key = key or "default" local filename local filename -- If the name has no extension, use .sty by default. -- Otherwise, preserve the extension supplied by the user. if pkg:match("%.[^./\\]+$") then filename = pkg else filename = pkg .. ".sty" end local path = kpse.find_file(filename, "tex") if not path then return "unresolved" end local contents = readfile(path) if not contents then return "unresolved" end local data = extract(contents) if not data then return "unresolved" end -- Resolve macros contained in the complete -- \ProvidesPackage/\ProvidesFile information. data.full = trim( resolve_macros( contents, data.full ) ) -- Extract the description from the -- fully resolved information. data.description = extract_description( data.full, data.date, data.version ) local value if key == "default" then value = data.full elseif key == "version" then value = data.version elseif key == "date" then value = data.date elseif key == "year" then value = data.year elseif key == "month" then value = data.month elseif key == "day" then value = data.day elseif key == "description" then value = data.description else value = "unresolved" end return texescape( value or "unresolved" ) end function M.print(pkg, key) tex.sprint( M.get(pkg, key) ) end return M