#!/usr/bin/env texlua
--[[
Copyright 2016-2023 ARATA Mizuki
This file is part of ClutTeX.
ClutTeX is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
ClutTeX 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. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with ClutTeX. If not, see .
]]
CLUTTEX_VERSION = "v0.6"
-- Standard libraries
local table = table
local os = os
local io = io
local string = string
local ipairs = ipairs
local coroutine = coroutine
local tostring = tostring
-- External libraries (included in texlua)
local filesys = require "lfs"
local md5 = require "md5"
-- local kpse = require "kpse"
-- My own modules
local pathutil = require "texrunner.pathutil"
local fsutil = require "texrunner.fsutil"
local shellutil = require "texrunner.shellutil"
local reruncheck = require "texrunner.reruncheck"
local luatexinit = require "texrunner.luatexinit"
local recoverylib = require "texrunner.recovery"
local message = require "texrunner.message"
local safename = require "texrunner.safename"
local extract_bibtex_from_aux_file = require "texrunner.auxfile".extract_bibtex_from_aux_file
local handle_cluttex_options = require "texrunner.handleoption".handle_cluttex_options
local checkdriver = require "texrunner.checkdriver".checkdriver
os.setlocale("", "ctype") -- Workaround for recent Universal CRT
-- arguments: input file name, jobname, etc...
local function genOutputDirectory(...)
-- The name of the temporary directory is based on the path of input file.
local message = table.concat({...}, "\0")
local hash = md5.sumhexa(message)
local tmpdir = os.getenv("TMPDIR") or os.getenv("TMP") or os.getenv("TEMP")
if tmpdir == nil then
local home = os.getenv("HOME") or os.getenv("USERPROFILE") or error("environment variable 'TMPDIR' not set!")
tmpdir = pathutil.join(home, ".latex-build-temp")
end
return pathutil.join(tmpdir, 'latex-build-' .. hash)
end
local inputfile, engine, options = handle_cluttex_options(arg)
local jobname_for_output
if options.jobname == nil then
local basename = pathutil.basename(pathutil.trimext(inputfile))
options.jobname = safename.escapejobname(basename)
jobname_for_output = basename
else
jobname_for_output = options.jobname
end
local jobname = options.jobname
assert(jobname ~= "", "jobname cannot be empty")
local output_extension
if options.output_format == "dvi" then
output_extension = engine.dvi_extension or "dvi"
else
output_extension = "pdf"
end
if options.output == nil then
options.output = jobname_for_output .. "." .. output_extension
end
-- Prepare output directory
if options.output_directory == nil then
local inputfile_abs = pathutil.abspath(inputfile)
options.output_directory = genOutputDirectory(inputfile_abs, jobname, options.engine_executable or options.engine)
if not fsutil.isdir(options.output_directory) then
assert(fsutil.mkdir_rec(options.output_directory))
elseif options.fresh then
-- The output directory exists and --fresh is given:
-- Remove all files in the output directory
if CLUTTEX_VERBOSITY >= 1 then
message.info("Cleaning '", options.output_directory, "'...")
end
assert(fsutil.remove_rec(options.output_directory))
assert(filesys.mkdir(options.output_directory))
end
elseif options.fresh then
message.error("--fresh and --output-directory cannot be used together.")
os.exit(1)
end
-- --print-output-directory
if options.print_output_directory then
io.write(options.output_directory, "\n")
os.exit(0)
end
local pathsep = ":"
if os.type == "windows" then
pathsep = ";"
end
local original_wd = filesys.currentdir()
if options.change_directory then
local TEXINPUTS = os.getenv("TEXINPUTS") or ""
local LUAINPUTS = os.getenv("LUAINPUTS") or ""
assert(filesys.chdir(options.output_directory))
options.output = pathutil.abspath(options.output, original_wd)
os.setenv("TEXINPUTS", original_wd .. pathsep .. TEXINPUTS)
os.setenv("LUAINPUTS", original_wd .. pathsep .. LUAINPUTS)
-- after changing the pwd, '.' is always the output_directory (needed for some path generation)
options.output_directory = "."
end
if options.bibtex or options.biber then
local BIBINPUTS = os.getenv("BIBINPUTS") or ""
options.output = pathutil.abspath(options.output, original_wd)
os.setenv("BIBINPUTS", original_wd .. pathsep .. BIBINPUTS)
end
-- Set `max_print_line' environment variable if not already set.
if os.getenv("max_print_line") == nil then
os.setenv("max_print_line", "16384")
end
--[[
According to texmf.cnf:
45 < error_line < 255,
30 < half_error_line < error_line - 15,
60 <= max_print_line.
On TeX Live 2023, (u)(p)bibtex fails if max_print_line >= 20000.
]]
local function path_in_output_directory(ext)
return pathutil.join(options.output_directory, jobname .. "." .. ext)
end
local recorderfile = path_in_output_directory("fls")
local recorderfile2 = path_in_output_directory("cluttex-fls")
local tex_options = {
engine_executable = options.engine_executable,
interaction = options.interaction,
file_line_error = options.file_line_error,
halt_on_error = options.halt_on_error,
synctex = options.synctex,
output_directory = options.output_directory,
shell_escape = options.shell_escape,
shell_restricted = options.shell_restricted,
jobname = options.jobname,
fmt = options.fmt,
extraoptions = options.tex_extraoptions,
}
if options.output_format ~= "pdf" and engine.supports_pdf_generation then
tex_options.output_format = options.output_format
end
-- Setup LuaTeX initialization script
if engine.is_luatex then
local initscriptfile = path_in_output_directory("cluttexinit.lua")
luatexinit.create_initialization_script(initscriptfile, tex_options)
tex_options.lua_initialization_script = initscriptfile
end
-- handle change_directory properly (needs to be after initscript gen)
if options.change_directory then
tex_options.output_directory = nil
end
-- Run TeX command (*tex, *latex)
-- should_rerun, newauxstatus = single_run([auxstatus])
-- This function should be run in a coroutine.
local function single_run(auxstatus, iteration)
local minted, epstopdf = false, false
local bibtex_aux_hash = nil
local mainauxfile = path_in_output_directory("aux")
if fsutil.isfile(recorderfile) then
-- Recorder file already exists
local filelist, filemap = reruncheck.parse_recorder_file(recorderfile, options)
if engine.is_luatex and fsutil.isfile(recorderfile2) then
filelist, filemap = reruncheck.parse_recorder_file(recorderfile2, options, filelist, filemap)
end
auxstatus = reruncheck.collectfileinfo(filelist, auxstatus)
for _,fileinfo in ipairs(filelist) do
if string.match(fileinfo.path, "minted/minted%.sty$") then
minted = true
end
if string.match(fileinfo.path, "epstopdf%.sty$") then
epstopdf = true
end
end
if options.bibtex then
local biblines = extract_bibtex_from_aux_file(mainauxfile, options.output_directory)
if #biblines > 0 then
bibtex_aux_hash = md5.sum(table.concat(biblines, "\n"))
end
end
else
-- This is the first execution
if auxstatus ~= nil then
message.error("Recorder file was not generated during the execution!")
os.exit(1)
end
auxstatus = {}
end
--local timestamp = os.time()
local tex_injection = ""
if options.includeonly then
tex_injection = string.format("%s\\includeonly{%s}", tex_options.tex_injection or "", options.includeonly)
end
if minted or options.package_support["minted"] then
local outdir = options.output_directory
if os.type == "windows" then
outdir = string.gsub(outdir, "\\", "/") -- Use forward slashes
end
tex_injection = string.format("%s\\PassOptionsToPackage{outputdir=%s}{minted}", tex_injection or "", outdir)
if not options.package_support["minted"] then
message.diag("You may want to use --package-support=minted option.")
end
end
if epstopdf or options.package_support["epstopdf"] then
local outdir = options.output_directory
if os.type == "windows" then
outdir = string.gsub(outdir, "\\", "/") -- Use forward slashes
end
if string.sub(outdir, -1, -1) ~= "/" then
outdir = outdir.."/" -- Must end with a directory separator
end
tex_injection = string.format("%s\\PassOptionsToPackage{outdir=%s}{epstopdf}", tex_injection or "", outdir)
if not options.package_support["epstopdf"] then
message.diag("You may want to use --package-support=epstopdf option.")
end
end
local inputline = tex_injection .. safename.safeinput(inputfile, engine)
local current_tex_options, lightweight_mode = tex_options, false
if iteration == 1 and options.start_with_draft then
current_tex_options = {}
for k,v in pairs(tex_options) do
current_tex_options[k] = v
end
if engine.supports_draftmode then
current_tex_options.draftmode = true
options.start_with_draft = false
end
current_tex_options.interaction = "batchmode"
lightweight_mode = true
else
current_tex_options.draftmode = false
end
local command = engine:build_command(inputline, current_tex_options)
local execlog -- the contents of .log file
local recovered = false
local function recover()
-- Check log file
if not execlog then
local logfile = assert(io.open(path_in_output_directory("log")))
execlog = logfile:read("*a")
logfile:close()
end
recovered = recoverylib.try_recovery{
execlog = execlog,
auxfile = path_in_output_directory("aux"),
options = options,
original_wd = original_wd,
}
return recovered
end
coroutine.yield(command, recover) -- Execute the command
if recovered then
return true, {}
end
local filelist, filemap = reruncheck.parse_recorder_file(recorderfile, options)
if engine.is_luatex and fsutil.isfile(recorderfile2) then
filelist, filemap = reruncheck.parse_recorder_file(recorderfile2, options, filelist, filemap)
end
if not execlog then
local logfile = assert(io.open(path_in_output_directory("log")))
execlog = logfile:read("*a")
logfile:close()
end
if options.check_driver ~= nil then
checkdriver(options.check_driver, filelist)
end
if options.makeindex then
-- Look for .idx files and run MakeIndex
for _,file in ipairs(filelist) do
if pathutil.ext(file.path) == "idx" then
-- Run makeindex if the .idx file is new or updated
local idxfileinfo = {path = file.path, abspath = file.abspath, kind = "auxiliary"}
local output_ind = pathutil.replaceext(file.abspath, "ind")
if reruncheck.comparefileinfo({idxfileinfo}, auxstatus) or reruncheck.comparefiletime(file.abspath, output_ind, auxstatus) then
local idx_dir = pathutil.dirname(file.abspath)
local makeindex_command = {
"cd", shellutil.escape(idx_dir), "&&",
options.makeindex, -- Do not escape options.makeindex to allow additional options
"-o", pathutil.basename(output_ind),
pathutil.basename(file.abspath)
}
coroutine.yield(table.concat(makeindex_command, " "))
table.insert(filelist, {path = output_ind, abspath = output_ind, kind = "auxiliary"})
else
local succ, err = filesys.touch(output_ind)
if not succ then
message.warn("Failed to touch " .. output_ind .. " (" .. err .. ")")
end
end
end
end
else
-- Check log file
if string.find(execlog, "No file [^\n]+%.ind%.") then
message.diag("You may want to use --makeindex option.")
end
end
if options.makeglossaries then
-- Look for .glo files and run makeglossaries
for _,file in ipairs(filelist) do
if pathutil.ext(file.path) == "glo" then
-- Run makeglossaries if the .glo file is new or updated
local glofileinfo = {path = file.path, abspath = file.abspath, kind = "auxiliary"}
local output_gls = pathutil.replaceext(file.abspath, "gls")
if reruncheck.comparefileinfo({glofileinfo}, auxstatus) or reruncheck.comparefiletime(file.abspath, output_gls, auxstatus) then
local makeglossaries_command = {
options.makeglossaries,
"-d", shellutil.escape(options.output_directory),
pathutil.trimext(pathutil.basename(file.path))
}
coroutine.yield(table.concat(makeglossaries_command, " "))
table.insert(filelist, {path = output_gls, abspath = output_gls, kind = "auxiliary"})
else
local succ, err = filesys.touch(output_gls)
if not succ then
message.warn("Failed to touch " .. output_ind .. " (" .. err .. ")")
end
end
end
end
else
-- Check log file
if string.find(execlog, "No file [^\n]+%.gls%.") then
message.diag("You may want to use --makeglossaries option.")
end
end
if options.bibtex then
local biblines2 = extract_bibtex_from_aux_file(mainauxfile, options.output_directory)
local bibtex_aux_hash2
if #biblines2 > 0 then
bibtex_aux_hash2 = md5.sum(table.concat(biblines2, "\n"))
end
local output_bbl = path_in_output_directory("bbl")
if bibtex_aux_hash ~= bibtex_aux_hash2 or reruncheck.comparefiletime(pathutil.abspath(mainauxfile), output_bbl, auxstatus) then
-- The input for BibTeX command has changed...
local bibtex_command = {
"cd", shellutil.escape(options.output_directory), "&&",
options.bibtex,
pathutil.basename(mainauxfile)
}
coroutine.yield(table.concat(bibtex_command, " "))
else
if CLUTTEX_VERBOSITY >= 1 then
message.info("No need to run BibTeX.")
end
local succ, err = filesys.touch(output_bbl)
if not succ then
message.warn("Failed to touch " .. output_bbl .. " (" .. err .. ")")
end
end
elseif options.biber then
for _,file in ipairs(filelist) do
-- usual compilation with biber
-- tex -> pdflatex tex -> aux,bcf,pdf,run.xml
-- bcf -> biber bcf -> bbl
-- tex,bbl -> pdflatex tex -> aux,bcf,pdf,run.xml
if pathutil.ext(file.path) == "bcf" then
-- Run biber if the .bcf file is new or updated
local bcffileinfo = {path = file.path, abspath = file.abspath, kind = "auxiliary"}
local output_bbl = pathutil.replaceext(file.abspath, "bbl")
local updated_dot_bib = false
-- get the .bib files, the bcf uses as input
for l in io.lines(file.abspath) do
local bib = l:match("(.*)") -- might be unstable if biblatex adds e.g. a linebreak
if bib then
local bibfile = pathutil.join(original_wd, bib)
local succ, err = io.open(bibfile, "r") -- check if file is present, don't use touch to avoid triggering a rerun
if succ then
succ:close()
local updated_dot_bib_tmp = not reruncheck.comparefiletime(pathutil.abspath(mainauxfile), bibfile, auxstatus)
if updated_dot_bib_tmp then
message.info(bibfile.." is newer than aux")
end
updated_dot_bib = updated_dot_bib_tmp or updated_dot_bib
else
message.warn(bibfile .. " is not accessible (" .. err .. ")")
end
end
end
if updated_dot_bib or reruncheck.comparefileinfo({bcffileinfo}, auxstatus) or reruncheck.comparefiletime(file.abspath, output_bbl, auxstatus) then
local biber_command = {
options.biber, -- Do not escape options.biber to allow additional options
"--output-directory", shellutil.escape(options.output_directory),
pathutil.basename(file.abspath)
}
coroutine.yield(table.concat(biber_command, " "))
-- watch for changes in the bbl
table.insert(filelist, {path = output_bbl, abspath = output_bbl, kind = "auxiliary"})
else
local succ, err = filesys.touch(output_bbl)
if not succ then
message.warn("Failed to touch " .. output_bbl .. " (" .. err .. ")")
end
end
end
end
else
-- Check log file
if string.find(execlog, "No file [^\n]+%.bbl%.") then
message.diag("You may want to use --bibtex or --biber option.")
end
end
if string.find(execlog, "No pages of output.") then
return "No pages of output."
end
local should_rerun, auxstatus = reruncheck.comparefileinfo(filelist, auxstatus)
return should_rerun or lightweight_mode, auxstatus
end
-- Run (La)TeX (possibly multiple times) and produce a PDF file.
-- This function should be run in a coroutine.
local function do_typeset_c()
local iteration = 0
local should_rerun, auxstatus
repeat
iteration = iteration + 1
should_rerun, auxstatus = single_run(auxstatus, iteration)
if should_rerun == "No pages of output." then
message.warn("No pages of output.")
return
end
until not should_rerun or iteration >= options.max_iterations
if should_rerun then
message.warn("LaTeX should be run once more.")
end
-- Successful
if options.output_format == "dvi" or engine.supports_pdf_generation then
-- Output file (DVI/PDF) is generated in the output directory
local outfile = path_in_output_directory(output_extension)
local oncopyerror
if os.type == "windows" then
oncopyerror = function()
message.error("Failed to copy file. Some applications may be locking the ", string.upper(options.output_format), " file.")
return false
end
end
coroutine.yield(fsutil.copy_command(outfile, options.output), oncopyerror)
if #options.dvipdfmx_extraoptions > 0 then
message.warn("--dvipdfmx-option[s] are ignored.")
end
else
-- DVI file is generated, but PDF file is wanted
local dvifile = path_in_output_directory("dvi")
local dvipdfmx_command = {"dvipdfmx", "-o", shellutil.escape(options.output)}
for _,v in ipairs(options.dvipdfmx_extraoptions) do
table.insert(dvipdfmx_command, v)
end
table.insert(dvipdfmx_command, shellutil.escape(dvifile))
coroutine.yield(table.concat(dvipdfmx_command, " "))
end
-- Copy SyncTeX file if necessary
if options.output_format == "pdf" then
local synctex = tonumber(options.synctex or "0")
local synctex_ext = nil
if synctex > 0 then
-- Compressed SyncTeX file (.synctex.gz)
synctex_ext = "synctex.gz"
elseif synctex < 0 then
-- Uncompressed SyncTeX file (.synctex)
synctex_ext = "synctex"
end
if synctex_ext then
coroutine.yield(fsutil.copy_command(path_in_output_directory(synctex_ext), pathutil.replaceext(options.output, synctex_ext)))
end
end
-- Write dependencies file
if options.make_depends then
local filelist, filemap = reruncheck.parse_recorder_file(recorderfile, options)
if engine.is_luatex and fsutil.isfile(recorderfile2) then
filelist, filemap = reruncheck.parse_recorder_file(recorderfile2, options, filelist, filemap)
end
local f = assert(io.open(options.make_depends, "w"))
f:write(options.output, ":")
for _,fileinfo in ipairs(filelist) do
if fileinfo.kind == "input" then
f:write(" ", fileinfo.path)
end
end
f:write("\n")
f:close()
end
end
local function do_typeset()
-- Execute the command string yielded by do_typeset_c
for command, recover in coroutine.wrap(do_typeset_c) do
message.exec(command)
local success, termination, status_or_signal = os.execute(command)
if type(success) == "number" then -- Lua 5.1 or LuaTeX
local code = success
success = code == 0
termination = nil
status_or_signal = code
end
if not success and not (recover and recover()) then
if termination == "exit" then
message.error("Command exited abnormally: exit status ", tostring(status_or_signal))
elseif termination == "signal" then
message.error("Command exited abnormally: signal ", tostring(status_or_signal))
else
message.error("Command exited abnormally: ", tostring(status_or_signal))
end
return false, termination, status_or_signal
end
end
-- Successful
if CLUTTEX_VERBOSITY >= 1 then
message.info("Command exited successfully")
end
return true
end
if options.watch then
-- Watch mode
local fswatcherlib
if os.type == "windows" then
-- Windows: Try built-in filesystem watcher
local succ, result = pcall(require, "texrunner.fswatcher_windows")
if not succ and CLUTTEX_VERBOSITY >= 1 then
message.warn("Failed to load texrunner.fswatcher_windows: " .. result)
end
fswatcherlib = result
end
local do_watch
if fswatcherlib then
if CLUTTEX_VERBOSITY >= 2 then
message.info("Using built-in filesystem watcher for Windows")
end
do_watch = function(files)
local watcher = assert(fswatcherlib.new())
for _,path in ipairs(files) do
assert(watcher:add_file(path))
end
local result = assert(watcher:next())
if CLUTTEX_VERBOSITY >= 2 then
message.info(string.format("%s %s", result.action, result.path))
end
watcher:close()
return true
end
elseif shellutil.has_command("fswatch") and (options.watch == "auto" or options.watch == "fswatch") then
if CLUTTEX_VERBOSITY >= 2 then
message.info("Using `fswatch' command")
end
do_watch = function(files)
local fswatch_command = {"fswatch", "--one-event", "--event=Updated", "--"}
for _,path in ipairs(files) do
table.insert(fswatch_command, shellutil.escape(path))
end
local fswatch_command_str = table.concat(fswatch_command, " ")
if CLUTTEX_VERBOSITY >= 1 then
message.exec(fswatch_command_str)
end
local fswatch = assert(io.popen(fswatch_command_str, "r"))
for l in fswatch:lines() do
for _,path in ipairs(files) do
if l == path then
fswatch:close()
return true
end
end
end
return false
end
elseif shellutil.has_command("inotifywait") and (options.watch == "auto" or options.watch == "inotifywait") then
if CLUTTEX_VERBOSITY >= 2 then
message.info("Using `inotifywait' command")
end
do_watch = function(files)
local inotifywait_command = {"inotifywait", "--event=modify", "--event=attrib", "--format=%w", "--quiet"}
for _,path in ipairs(files) do
table.insert(inotifywait_command, shellutil.escape(path))
end
local inotifywait_command_str = table.concat(inotifywait_command, " ")
if CLUTTEX_VERBOSITY >= 1 then
message.exec(inotifywait_command_str)
end
local inotifywait = assert(io.popen(inotifywait_command_str, "r"))
for l in inotifywait:lines() do
for _,path in ipairs(files) do
if l == path then
inotifywait:close()
return true
end
end
end
return false
end
else
if options.watch == "auto" then
message.error("Could not watch files because neither `fswatch' nor `inotifywait' was installed.")
elseif options.watch == "fswatch" then
message.error("Could not watch files because your selected engine `fswatch' was not installed.")
elseif options.watch == "inotifywait" then
message.error("Could not watch files because your selected engine `inotifywait' was not installed.")
end
message.info("See ClutTeX's manual for details.")
os.exit(1)
end
local success, status = do_typeset()
-- TODO: filenames here can be UTF-8 if command_line_encoding=utf-8
local filelist, filemap = reruncheck.parse_recorder_file(recorderfile, options)
if engine.is_luatex and fsutil.isfile(recorderfile2) then
filelist, filemap = reruncheck.parse_recorder_file(recorderfile2, options, filelist, filemap)
end
local input_files_to_watch = {}
for _,fileinfo in ipairs(filelist) do
if fileinfo.kind == "input" then
table.insert(input_files_to_watch, fileinfo.abspath)
end
end
while do_watch(input_files_to_watch) do
local success, status = do_typeset()
if not success then
-- error
else
local filelist, filemap = reruncheck.parse_recorder_file(recorderfile, options)
if engine.is_luatex and fsutil.isfile(recorderfile2) then
filelist, filemap = reruncheck.parse_recorder_file(recorderfile2, options, filelist, filemap)
end
input_files_to_watch = {}
for _,fileinfo in ipairs(filelist) do
if fileinfo.kind == "input" then
table.insert(input_files_to_watch, fileinfo.abspath)
end
end
end
end
else
-- Not in watch mode
local success, status = do_typeset()
if not success then
os.exit(1)
end
end