(* This file is part of ClutTeX. *)
structure Main = struct
val CLUTTEX_VERSION = "v0.7.0"
val COPYRIGHT_NOTICE =
"Copyright (C) 2016-2024 ARATA Mizuki\n\
\\n\
\This program is free software: you can redistribute it and/or modify\n\
\it under the terms of the GNU General Public License as published by\n\
\the Free Software Foundation, either version 3 of the License, or\n\
\(at your option) any later version.\n\
\\n\
\This program is distributed in the hope that it will be useful,\n\
\but WITHOUT ANY WARRANTY; without even the implied warranty of\n\
\MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n\
\GNU General Public License for more details.\n\
\\n\
\You should have received a copy of the GNU General Public License\n\
\along with this program. If not, see .\n";
exception Abort
(* Workaround for recent Universal CRT *)
val () = Lua.call0 Lua.Lib.os.setlocale #[Lua.fromString "", Lua.fromString "ctype"]
fun getEnvMulti [] = NONE
| getEnvMulti (name :: xs) = case OS.Process.getEnv name of
SOME x => SOME x
| NONE => getEnvMulti xs
fun genOutputDirectory (temporary_directory : string option, xs : string list)
= let val message = String.concatWith "\000" xs
val hash = MD5.md5AsLowerHex (Byte.stringToBytes message)
val tmpdir = case temporary_directory of
SOME tmpdir => tmpdir
| NONE => case getEnvMulti ["TMPDIR", "TMP", "TEMP"] of
SOME tmpdir => tmpdir
| NONE => case getEnvMulti ["HOME", "USERPROFILE"] of
SOME home => OS.Path.joinDirFile { dir = home, file = ".latex-build-temp" } (* $XDG_CACHE_HOME/cluttex, $HOME/.cache/cluttex *)
| NONE => raise Fail "environment variable 'TMPDIR' not set!"
in OS.Path.joinDirFile { dir = tmpdir, file = "cluttex-" ^ hash }
end
fun showUsage () = let val progName = CommandLine.name ()
in TextIO.output (TextIO.stdErr,
"ClutTeX: Process TeX files without cluttering your working directory\n\
\\n\
\Usage:\n\
\ " ^ progName ^ " [options] [--] FILE.tex\n\
\\n\
\Options:\n\
\ -e, --engine=ENGINE Specify which TeX engine to use.\n\
\ ENGINE is one of the following:\n\
\ pdflatex, pdftex,\n\
\ lualatex, luatex, luajittex,\n\
\ xelatex, xetex, latex, etex, tex,\n\
\ platex, eptex, ptex,\n\
\ uplatex, euptex, uptex,\n\
\ --engine-executable=COMMAND+OPTIONs\n\
\ The actual TeX command to use.\n\
\ [default: ENGINE]\n\
\ -o, --output=FILE The name of output file.\n\
\ [default: JOBNAME.pdf or JOBNAME.dvi]\n\
\ --fresh Clean intermediate files before running TeX.\n\
\ Cannot be used with --output-directory.\n\
\ --max-iterations=N Maximum number of running TeX to resolve\n\
\ cross-references. [default: 3]\n\
\ --start-with-draft Start with draft mode.\n\
\ --[no-]change-directory Change directory before running TeX.\n\
\ --watch[=ENGINE] Watch input files for change. Requires fswatch\n\
\ or inotifywait to be installed. ENGINE is one of\n\
\ `fswatch', `inotifywait' or `auto' [default: `auto']\n\
\ --tex-option=OPTION Pass OPTION to TeX as a single option.\n\
\ --tex-options=OPTIONs Pass OPTIONs to TeX as multiple options.\n\
\ --dvipdfmx-option[s]=OPTION[s] Same for dvipdfmx.\n\
\ --makeindex=COMMAND+OPTIONs Command to generate index, such as\n\
\ `makeindex' or `mendex'.\n\
\ --bibtex=COMMAND+OPTIONs Command for BibTeX, such as\n\
\ `bibtex' or `pbibtex'.\n\
\ --biber[=COMMAND+OPTIONs] Command for Biber.\n\
\ --makeglossaries[=COMMAND+OPTIONs] Command for makeglossaries.\n\
\ -h, --help Print this message and exit.\n\
\ -v, --version Print version information and exit.\n\
\ -V, --verbose Be more verbose.\n\
\ --color[=WHEN] Make ClutTeX's message colorful. WHEN is one of\n\
\ `always', `auto', or `never'.\n\
\ [default: `auto' if --color is omitted,\n\
\ `always' if WHEN is omitted]\n\
\ --includeonly=NAMEs Insert '\\includeonly{NAMEs}'.\n\
\ --make-depends=FILE Write dependencies as a Makefile rule.\n\
\ --print-output-directory Print the output directory and exit.\n\
\ --package-support=PKG1[,PKG2,...]\n\
\ Enable special support for some shell-escaping\n\
\ packages.\n\
\ Currently supported: minted, epstopdf\n\
\ --check-driver=DRIVER Check that the correct driver file is loaded.\n\
\ DRIVER is one of `dvipdfmx', `dvips', `dvisvgm'.\n\
\ --source-date-epoch=TIME\n\
\ Set SOURCE_DATE_EPOCH variable.\n\
\ TIME is `now' or an integer.\n\
\\n\
\ --[no-]shell-escape\n\
\ --shell-restricted\n\
\ --synctex=NUMBER\n\
\ --fmt=FMTNAME\n\
\ --[no-]file-line-error [default: yes]\n\
\ --[no-]halt-on-error [default: yes]\n\
\ --interaction=STRING [default: nonstopmode]\n\
\ --jobname=STRING\n\
\ --output-directory=DIR [default: somewhere in the temporary directory]\n\
\ --output-format=FORMAT FORMAT is `pdf' or `dvi'. [default: pdf]\n\
\\n" ^ COPYRIGHT_NOTICE)
; OS.Process.exit OS.Process.success
end
structure HandleOptions = HandleOptions (fun showMessageAndFail message = (TextIO.output (TextIO.stdErr, message ^ "\n"); OS.Process.exit OS.Process.failure)
val showUsage = showUsage
fun showVersion () = (TextIO.output (TextIO.stdErr, "cluttex " ^ CLUTTEX_VERSION ^ "\n"); OS.Process.exit OS.Process.success)
)
(*: val pathInOutputDirectory : AppOptions.options * string -> string *)
fun pathInOutputDirectory (options : AppOptions.options, ext) = PathUtil.join2 (#output_directory options, #jobname options ^ "." ^ ext)
(*: val executeCommand : string * (unit -> bool) option -> unit *)
(*
fun executeCommand (command, recover)
= let val () = Message.exec command
val status = OS.Process.system command
val success_or_recoverd = if OS.Process.isSuccess status then
true
else
case recover of
SOME f => f ()
| NONE => false
in if success_or_recoverd then
()
else
( Message.error "Command exited abnormally" (* TODO: show status code: Unix.fromStatus *)
; raise Abort
)
end
*)
fun executeCommand (command, recover)
= let val () = Message.exec command
val (success, termination, status_or_signal) = Lua.call3 Lua.Lib.os.execute #[Lua.fromString command]
val (success, termination, status_or_signal) : bool * string option * Lua.value
= if Lua.typeof success = "number" then (* Lua 5.1 or LuaTeX *)
(Lua.== (success, Lua.fromInt 0), NONE, success)
else
(Lua.unsafeFromValue success, SOME (Lua.unsafeFromValue termination), status_or_signal)
val success_or_recovered = success orelse (case recover of
SOME f => f ()
| NONE => false
)
in if success_or_recovered then
()
else
( case termination of
SOME "exit" => Message.error ("Command exited abnormally: exit status " ^ Lua.unsafeFromValue (Lua.call1 Lua.Lib.tostring #[status_or_signal]))
| SOME "signal" => Message.error ("Command exited abnormally: signal " ^ Lua.unsafeFromValue (Lua.call1 Lua.Lib.tostring #[status_or_signal]))
| _ => Message.error ("Command exited abnormally: " ^ Lua.unsafeFromValue (Lua.call1 Lua.Lib.tostring #[status_or_signal]))
; raise Abort
)
end
(* The value to be used for SOURCE_DATE_EPOCH *)
fun getTimeSinceEpoch () : string
= Lua.unsafeFromValue (Lua.call1 Lua.Lib.tostring #[Lua.call1 Lua.Lib.os.time #[]])
type run_params = { options : AppOptions.options
, inputfile : string
, engine : TeXEngine.engine
, tex_options : TeXEngine.run_options
, recorderfile : string
, recorderfile2 : string
, original_wd : string
, output_extension : string
, source_date_epoch_info : { time_since_epoch : string, time : Time.time } ref option
}
datatype single_run_result = SHOULD_RERUN of Reruncheck.aux_status StringMap.map
| NO_NEED_TO_RERUN
| NO_PAGES_OF_OUTPUT
(* Run TeX command ( *tex, *latex) *)
(*: val singleRun : run_params * Reruncheck.aux_status StringMap.map * int -> single_run_result *)
fun singleRun ({ options, inputfile, engine, tex_options, recorderfile, recorderfile2, original_wd, source_date_epoch_info, ... } : run_params, auxstatus, iteration)
= let val mainauxfile = pathInOutputDirectory (options, "aux")
val { filelist, auxstatus, minted, epstopdf, pdfx, bibtex_aux_hash }
= if FSUtil.isFile recorderfile then
(* Recorder file already exists *)
let val recorded = Reruncheck.parseRecorderFile { file = recorderfile, options = options }
val recorded = if TeXEngine.isLuaTeX engine andalso FSUtil.isFile recorderfile2 then
Reruncheck.parseRecorderFileContinued { file = recorderfile2, options = options, previousResult = recorded }
else
recorded
val (filelist, filemap) = Reruncheck.getFileInfo recorded
val auxstatus = Reruncheck.collectFileInfo (filelist, auxstatus)
val { minted, epstopdf, pdfx } =
List.foldl (fn ({ path, ... }, { minted, epstopdf, pdfx }) =>
{ minted = minted orelse String.isSuffix "minted/minted.sty" path
, epstopdf = epstopdf orelse String.isSuffix "epstopdf.sty" path
, pdfx = pdfx orelse String.isSuffix "pdfx.sty" path
}
) { minted = false, epstopdf = false, pdfx = false } filelist
val bibtex_aux_hash = case #bibtex_or_biber options of
SOME (AppOptions.BIBTEX _) =>
let val biblines = AuxFile.extractBibTeXLines { auxfile = mainauxfile, outdir = #output_directory options }
in SOME (MD5.compute (Byte.stringToBytes (String.concatWith "\n" biblines)))
end
| _ => NONE
in { filelist, auxstatus, minted, epstopdf, pdfx, bibtex_aux_hash }
end
else
(* This is the first execution *)
if StringMap.isEmpty auxstatus then
{ filelist = [], auxstatus = StringMap.empty, minted = false, epstopdf = false, pdfx = false, bibtex_aux_hash = NONE }
else
( Message.error "Recorder file was not generated during the execution!"
; raise Abort
)
(*
* Set SOURCE_DATE_EPOCH if
* * --source-date-epoch=now is set, or
* * --source-date-epoch is not set but `pdfx' package is used and SOURCE_DATE_EPOCH is not already set.
* The value will be the newer of these:
* * The time when the program started (see main()).
* * The time we are processing after one of the input files was modified.
*)
val () = case source_date_epoch_info of
NONE => () (* already set in main () *)
| SOME r =>
let val should_set_source_date_epoch = case #source_date_epoch options of
SOME AppOptions.SourceDateEpoch.NOW => true
| SOME (AppOptions.SourceDateEpoch.RAW _) => false (* should not occur *)
| NONE => pdfx orelse #pdfx (#package_support options)
in if should_set_source_date_epoch then
let val input_time = List.foldl (fn ({ abspath, kind = Reruncheck.INPUT, ... }, acc) =>
(case StringMap.find (auxstatus, abspath) of
SOME { mtime, ... } =>
(case (mtime, acc) of
(SOME mtime', SOME t) =>
if Time.< (t, mtime') then
mtime
else
acc
| (NONE, _) => acc
| (_, NONE) => mtime
)
| NONE => acc
)
| (_, acc) => acc
) NONE filelist
val info = case input_time of
SOME input_time => if Time.< (#time (!r), input_time) then (* input file was changed since the last run *)
let val new_info = { time_since_epoch = getTimeSinceEpoch (), time = input_time }
in if Message.getVerbosity () >= 1 then
Message.info "Input file was modified; Updating SOURCE_DATE_EPOCH..."
else
()
; r := new_info
; new_info
end
else
!r
| NONE => !r
in if Message.getVerbosity () >= 1 then
Message.info ("Setting SOURCE_DATE_EPOCH to " ^ #time_since_epoch info)
else
()
; OSUtil.setEnv ("SOURCE_DATE_EPOCH", #time_since_epoch info)
end
else
()
end
val tex_injection = case #includeonly options of
SOME io => "\\includeonly{" ^ io ^ "}"
| NONE => ""
val tex_injection = if minted orelse #minted (#package_support options) then
let val () = if not (#minted (#package_support options)) then
Message.diag "You may want to use --package-support=minted option."
else
()
val outdir = #output_directory options
val outdir = if OSUtil.isWindows then
String.map (fn #"\\" => #"/" | c => c) outdir (* Use forward slashes *)
else
outdir
in tex_injection ^ "\\PassOptionsToPackage{outputdir=" ^ outdir ^ "}{minted}"
end
else
tex_injection
val tex_injection = if epstopdf orelse #epstopdf (#package_support options) then
let val () = if not (#epstopdf (#package_support options)) then
Message.diag "You may want to use --package-support=epstopdf option."
else
()
val outdir = #output_directory options
val outdir = if OSUtil.isWindows then
String.map (fn #"\\" => #"/" | c => c) outdir (* Use forward slashes *)
else
outdir
val outdir = if String.isSuffix "/" outdir then
outdir
else
outdir ^ "/" (* Must end with a directory separator *)
in tex_injection ^ "\\PassOptionsToPackage{outdir=" ^ outdir ^ "}{epstopdf}"
end
else
tex_injection
val inputline = tex_injection ^ SafeName.safeInput { name = inputfile, isPdfTeX = TeXEngine.isPdfTeX engine }
val (current_tex_options, lightweight_mode)
= if iteration = 1 andalso #start_with_draft options then
if #supports_draftmode engine then
({ tex_options where draftmode = true, interaction = SOME InteractionMode.BATCHMODE }, true)
else
({ tex_options where interaction = SOME InteractionMode.BATCHMODE }, true)
else
({ tex_options where draftmode = false }, false)
val command = TeXEngine.buildCommand (engine, inputline, current_tex_options)
val execlogCache = ref NONE
fun getExecLog () = case !execlogCache of
NONE => let val ins = TextIO.openIn (pathInOutputDirectory (options, "log"))
val log = TextIO.inputAll ins
val () = TextIO.closeIn ins
in execlogCache := SOME log
; log
end
| SOME log => log
val recovered = ref false
fun recover () = let val execlog = getExecLog ()
val r = Recovery.tryRecovery { options = options, execlog = execlog, auxfile = pathInOutputDirectory (options, "aux"), originalWorkingDirectory = original_wd }
in recovered := true
; r
end
val () = executeCommand (command, SOME recover)
in if !recovered then
SHOULD_RERUN StringMap.empty
else
let val recorded = Reruncheck.parseRecorderFile { file = recorderfile, options = options }
val recorded = if TeXEngine.isLuaTeX engine andalso FSUtil.isFile recorderfile2 then
Reruncheck.parseRecorderFileContinued { file = recorderfile2, options = options, previousResult = recorded }
else
recorded
val (filelist, filemap) = Reruncheck.getFileInfo recorded
val execlog = getExecLog ()
(* Check driver *)
val () = case #check_driver options of
NONE => ()
| SOME driver => CheckDriver.checkDriver (driver, List.map (fn { path, abspath, kind } => { path = path, kind = case kind of Reruncheck.INPUT => "input" | Reruncheck.OUTPUT => "output" | Reruncheck.AUXILIARY => "auxiliary"}) filelist)
(* makeindex *)
val filelist = case #makeindex options of
NONE => (* Check log file *)
( if Lua.isFalsy (Lua.call1 Lua.Lib.string.find #[Lua.fromString execlog, Lua.fromString "No file [^\n]+%.ind%."]) then
()
else
Message.diag "You may want to use --makeindex option."
; filelist
)
| SOME makeindex =>
let fun go (file, filelist_acc) (* Look for .idx files and run MakeIndex *)
= if PathUtil.ext (#path file) = "idx" then
(* Run makeindex if the .idx file is new or updated *)
let val idxfileinfo = { path = #path file, abspath = #abspath file, kind = Reruncheck.AUXILIARY }
val output_ind = PathUtil.replaceext { path = #abspath file, newext = "ind" }
in if #1 (Reruncheck.compareFileInfo ([idxfileinfo], auxstatus)) orelse Reruncheck.compareFileTime { srcAbs = #abspath file, dst = output_ind, auxstatus = auxstatus } then
let val idx_dir = PathUtil.dirname (#abspath file)
val makeindex_command = [
"cd", ShellUtil.escape idx_dir, "&&",
makeindex, (* Do not escape `makeindex` to allow additional options *)
"-o", PathUtil.basename output_ind,
PathUtil.basename (#abspath file)
]
in executeCommand (String.concatWith " " makeindex_command, NONE)
; { path = output_ind, abspath = output_ind, kind = Reruncheck.AUXILIARY } :: filelist_acc
end
else
( FSUtil.touch output_ind handle Lua.Error err => Message.warn ("Failed to touch " ^ output_ind ^ " (" ^ Lua.unsafeFromValue err ^ ")")
; filelist_acc
)
end
else
filelist_acc
in List.foldl go filelist filelist
end
(* makeglossaries *)
val filelist = case #makeglossaries options of
NONE => (* Check log file *)
( if Lua.isFalsy (Lua.call1 Lua.Lib.string.find #[Lua.fromString execlog, Lua.fromString "No file [^\n]+%.gls%."]) then
()
else
Message.diag "You may want to use --makeglossaries option."
; filelist
)
| SOME makeglossaries =>
let fun go (file, filelist_acc) (* Look for .glo files and run makeglossaries *)
= if PathUtil.ext (#path file) = "glo" then
(* Run makeglossaries if the .glo file is new or updated *)
let val glofileinfo = { path = #path file, abspath = #abspath file, kind = Reruncheck.AUXILIARY }
val output_gls = PathUtil.replaceext { path = #abspath file, newext = "gls" }
in if #1 (Reruncheck.compareFileInfo ([glofileinfo], auxstatus)) orelse Reruncheck.compareFileTime { srcAbs = #abspath file, dst = output_gls, auxstatus = auxstatus } then
let val makeglossaries_command = [
makeglossaries,
"-d", ShellUtil.escape (#output_directory options),
PathUtil.trimext (PathUtil.basename (#path file))
]
in executeCommand (String.concatWith " " makeglossaries_command, NONE)
; { path = output_gls, abspath = output_gls, kind = Reruncheck.AUXILIARY } :: filelist_acc
end
else
( FSUtil.touch output_gls handle Lua.Error err => Message.warn ("Failed to touch " ^ output_gls ^ " (" ^ Lua.unsafeFromValue err ^ ")")
; filelist_acc
)
end
else
filelist_acc
in List.foldl go filelist filelist
end
(* bibtex/biber *)
val filelist = case #bibtex_or_biber options of
NONE => ( if Lua.isFalsy (Lua.call1 Lua.Lib.string.find #[Lua.fromString execlog, Lua.fromString "No file [^\n]+%.bbl%."]) then
()
else
Message.diag "You may want to use --bibtex or biber option."
; filelist
)
| SOME (AppOptions.BIBTEX bibtex) =>
let val biblines2 = AuxFile.extractBibTeXLines { auxfile = mainauxfile, outdir = #output_directory options }
val bibtex_aux_hash2 = if List.null biblines2 then
NONE
else
SOME (MD5.compute (Byte.stringToBytes (String.concatWith "\n" biblines2)))
val output_bbl = pathInOutputDirectory (options, "bbl")
in if bibtex_aux_hash <> bibtex_aux_hash2 orelse Reruncheck.compareFileTime { srcAbs = PathUtil.abspath { path = mainauxfile, cwd = NONE }, dst = output_bbl, auxstatus = auxstatus } then
(* The input for BibTeX command has changed... *)
let val bibtex_command = [
"cd", ShellUtil.escape (#output_directory options), "&&",
bibtex,
PathUtil.basename mainauxfile
]
in executeCommand (String.concatWith " " bibtex_command, NONE)
end
else
( if Message.getVerbosity () >= 1 then
Message.info "No need to run BibTeX."
else
()
; FSUtil.touch output_bbl handle Lua.Error err => Message.warn ("Failed to touch " ^ output_bbl ^ " (" ^ Lua.unsafeFromValue err ^ ")")
)
; filelist
end
| SOME (AppOptions.BIBER biber) =>
let fun go (file, filelist_acc)
(* 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 (#path file) = "bcf" then
(* Run biber if the .bcf file is new or updated *)
let val bcffileinfo = { path = #path file, abspath = #abspath file, kind = Reruncheck.AUXILIARY }
val output_bbl = PathUtil.replaceext { path = #abspath file, newext = "bbl" }
fun check_bib_update abspath
= let val ins = TextIO.openIn abspath
fun go updated_dot_bib
= case TextIO.inputLine ins of
NONE => updated_dot_bib
| SOME l =>
let val bib = Lua.call1 Lua.Lib.string.match #[Lua.fromString l, Lua.fromString "(.*)"]
in if Lua.isFalsy bib then
go updated_dot_bib (* continue *)
else
let val bib = Lua.unsafeFromValue bib : string
val bibfile = PathUtil.join2 (original_wd, bib)
val updated_dot_bib = if FSUtil.isFile bibfile then
let val updated_dot_bib_tmp = not (Reruncheck.compareFileTime { srcAbs = PathUtil.abspath { path = mainauxfile, cwd = NONE }, dst = bibfile, auxstatus = auxstatus })
in if updated_dot_bib_tmp then
Message.info (bibfile ^ " is newer than aux")
else
()
; updated_dot_bib orelse updated_dot_bib_tmp
end
else
( Message.warn (bibfile ^ " is not accessible")
; updated_dot_bib
)
in go updated_dot_bib
end
end
in go false before TextIO.closeIn ins
end
val updated_dot_bib = check_bib_update (#abspath file)
in if updated_dot_bib orelse #1 (Reruncheck.compareFileInfo ([bcffileinfo], auxstatus)) orelse Reruncheck.compareFileTime { srcAbs = #abspath file, dst = output_bbl, auxstatus = auxstatus } then
let val biber_command = [
biber, (* Do not escape `biber` to allow additional options *)
"--output-directory", ShellUtil.escape (#output_directory options),
PathUtil.basename (#abspath file)
]
in executeCommand (String.concatWith " " biber_command, NONE)
; { path = output_bbl, abspath = output_bbl, kind = Reruncheck.AUXILIARY } :: filelist
end
else
( FSUtil.touch output_bbl handle Lua.Error err => Message.warn ("Failed to touch " ^ output_bbl ^ " (" ^ Lua.unsafeFromValue err ^ ")")
; filelist_acc
)
end
else
filelist_acc
in List.foldl go filelist filelist
end
in if String.isSubstring "No pages of output." execlog then
NO_PAGES_OF_OUTPUT
else
let val (should_rerun, auxstatus) = Reruncheck.compareFileInfo (filelist, auxstatus)
in if should_rerun orelse lightweight_mode then
SHOULD_RERUN auxstatus
else
NO_NEED_TO_RERUN
end
end
end
(* Run (La)TeX (possibly multiple times) and produce a PDF/DVI file. *)
(*: val doTypeset : run_params -> unit *)
fun doTypeset (run_params as { options, engine, output_extension, recorderfile, recorderfile2, source_date_epoch_info, ... } : run_params)
= let fun loop (iteration, auxstatus)
= let val iteration = iteration + 1
in case singleRun (run_params, auxstatus, iteration) of
NO_PAGES_OF_OUTPUT => ( Message.warn "No pages of output."
; false
)
| NO_NEED_TO_RERUN => true
| SHOULD_RERUN auxstatus => if iteration >= #max_iterations options then
( Message.warn "LaTeX should be run once more."
; true
)
else
loop (iteration, auxstatus)
end
in if loop (0, StringMap.empty) then
(* Successful *)
( if #output_format options = OutputFormat.DVI orelse #supports_pdf_generation engine then
(* Output file (DVI/PDF) is generated in the output directory *)
let val outfile = pathInOutputDirectory (options, output_extension)
val onCopyError = if OSUtil.isWindows then
SOME (fn () => let val output_format = case #output_format options of
OutputFormat.DVI => "DVI"
| OutputFormat.PDF => "PDF"
in Message.error ("Failed to copy file. Some applications may be locking the " ^ output_format ^ " file.")
; false
end
)
else
NONE
in executeCommand (FSUtil.copyCommand { from = outfile, to = #output options }, onCopyError)
; if List.null (#dvipdfmx_extraoptions options) then
()
else
Message.warn "--dvipdfmx-option[s] are ignored."
end
else
(* DVI file is generated, but PDF file is wanted *)
let val dvifile = pathInOutputDirectory (options, "dvi")
val dvipdfmx_command = "dvipdfmx" :: "-o" :: ShellUtil.escape (#output options) :: #dvipdfmx_extraoptions options @ [ShellUtil.escape dvifile]
in executeCommand (String.concatWith " " dvipdfmx_command, NONE)
end
; (* Copy SyncTeX file if necessary *)
if #output_format options = OutputFormat.PDF then
let val synctex = Lua.unsafeFromValue (Lua.call1 Lua.Lib.tonumber #[Lua.fromString (Option.getOpt (#synctex options, "0"))]) : int
val synctex_ext = if synctex > 0 then
(* Compressed SyncTeX file (.synctex.gz) *)
SOME "synctex.gz"
else if synctex < 0 then
(* Uncompressed SyncTeX file (.synctex) *)
SOME "synctex"
else
NONE
in case synctex_ext of
SOME ext => executeCommand (FSUtil.copyCommand { from = pathInOutputDirectory (options, ext), to = PathUtil.replaceext { path = #output options, newext = ext } }, NONE)
| NONE => ()
end
else
()
; (* Write dependencies file *)
case #make_depends options of
SOME make_depends =>
let val recorded = Reruncheck.parseRecorderFile { file = recorderfile, options = options }
val recorded = if TeXEngine.isLuaTeX engine andalso FSUtil.isFile recorderfile2 then
Reruncheck.parseRecorderFileContinued { file = recorderfile2, options = options, previousResult = recorded }
else
recorded
val (filelist, _) = Reruncheck.getFileInfo recorded
val outs = TextIO.openOut make_depends
in TextIO.output (outs, #output options ^ ":") (* TODO: quote *)
; List.app (fn { path, abspath = _, kind = Reruncheck.INPUT } => TextIO.output (outs, " " ^ path) (* TODO: quote *)
| _ => ()) filelist
; TextIO.output (outs, "\n")
; TextIO.closeOut outs
end
| NONE => ()
; (* Successful *)
if Message.getVerbosity () >= 1 then
Message.info "Command exited successfully"
else
()
)
else
(* No pages of output. *)
()
end
(*: val doWatchWindows : Lua.value -> string list -> bool *)
fun doWatchWindows fswatcherlib files
= let val watcher = Lua.call1 Lua.Lib.assert (Lua.call (Lua.field (fswatcherlib, "new")) #[])
val () = List.app (fn file => Lua.call0 Lua.Lib.assert (Lua.method (watcher, "add") #[Lua.fromString file])) files
val result = Lua.call1 Lua.Lib.assert (Lua.method (watcher, "next") #[])
val () = if Message.getVerbosity () >= 2 then
Message.info (Lua.unsafeFromValue (Lua.field (result, "action")) ^ " " ^ Lua.unsafeFromValue (Lua.field (result, "path")))
else
()
val () = Lua.method0 (watcher, "close") #[]
in true
end
(*: val doWatchFswatch : string list -> bool *)
fun doWatchFswatch files
= let val fswatch_command = "fswatch" :: "--one-event" :: "--event=Updated" :: "--" :: List.map ShellUtil.escape files
val fswatch_command_str = String.concatWith " " fswatch_command
val () = if Message.getVerbosity () >= 1 then
Message.exec fswatch_command_str
else
()
val fswatch = Lua.call1 Lua.Lib.assert (Lua.call Lua.Lib.io.popen #[Lua.fromString fswatch_command_str, Lua.fromString "r"])
val readLine = Lua.method1 (fswatch, "lines") #[]
fun go () = let val l = Lua.call1 readLine #[]
in if Lua.isFalsy l then
false
else if List.exists (fn path => Lua.unsafeFromValue l = path) files then
true
else
go ()
end
in go () before Lua.method0 (fswatch, "close") #[]
end
(*: val doWatchInotifywait : string list -> bool *)
fun doWatchInotifywait files
= let val inotifywait_command = "inotifywait" :: "--event=modify" :: "--event=attrib" :: "--format=%w" :: "--quiet" :: List.map ShellUtil.escape files
val inotifywait_command_str = String.concatWith " " inotifywait_command
val () = if Message.getVerbosity () >= 1 then
Message.exec inotifywait_command_str
else
()
val inotifywait = Lua.call1 Lua.Lib.assert (Lua.call Lua.Lib.io.popen #[Lua.fromString inotifywait_command_str, Lua.fromString "r"])
val readLine = Lua.method1 (inotifywait, "lines") #[]
fun go () = let val l = Lua.call1 readLine #[]
in if Lua.isFalsy l then
false
else if List.exists (fn path => Lua.unsafeFromValue l = path) files then
true
else
go ()
end
in go () before Lua.method0 (inotifywait, "close") #[]
end
(*: val runWatchMode : AppOptions.WatchEngine.engine * run_params -> unit *)
fun runWatchMode (watch_engine, run_params as { options, engine, recorderfile, recorderfile2, ... } : run_params)
= let val fswatcherlib = if OSUtil.isWindows then
(* Windows: Try built-in filesystem watcher *)
let val (succ, result) = Lua.call2 Lua.Lib.pcall #[Lua.Lib.require, Lua.fromString "texrunner.fswatcher_windows"]
in if Lua.isFalsy succ then
( if Message.getVerbosity () >= 1 then
Message.warn ("Failed to load texrunner.fswatcher_windows: " ^ Lua.unsafeFromValue result)
else
()
; NONE
)
else
SOME result
end
else
NONE
val doWatch = case fswatcherlib of
SOME fswatcherlib =>
( if Message.getVerbosity () >= 2 then
Message.info "Using built-in filesystem watcher for Windows"
else
()
; doWatchWindows fswatcherlib
)
| NONE => if ShellUtil.hasCommand "fswatch" andalso (watch_engine = AppOptions.WatchEngine.AUTO orelse watch_engine = AppOptions.WatchEngine.AUTO) then
( if Message.getVerbosity () >= 2 then
Message.info "Using `fswatch' command"
else
()
; doWatchFswatch
)
else if ShellUtil.hasCommand "inotifywait" andalso (watch_engine = AppOptions.WatchEngine.AUTO orelse watch_engine = AppOptions.WatchEngine.INOTIFYWAIT) then
( if Message.getVerbosity () >= 2 then
Message.info "Using `inotifywait' command"
else
()
; doWatchInotifywait
)
else
( case watch_engine of
AppOptions.WatchEngine.AUTO => Message.error "Could not watch files because neither `fswatch' nor `inotifywait' was installed."
| AppOptions.WatchEngine.FSWATCH => Message.error "Could not watch files because your selected engine `fswatch' was not installed."
| AppOptions.WatchEngine.INOTIFYWAIT => Message.error "Could not watch files because your selected engine `inotifywait' was not installed."
; Message.info "See ClutTeX's manual for details."
; OS.Process.exit OS.Process.failure
)
val _ = (doTypeset run_params; true) handle Abort => false
(* TODO: filenames here can be UTF-8 if command_line_encoding=utf-8 *)
val recorded = Reruncheck.parseRecorderFile { file = recorderfile, options = options }
val recorded = if TeXEngine.isLuaTeX engine andalso FSUtil.isFile recorderfile2 then
Reruncheck.parseRecorderFileContinued { file = recorderfile2, options = options, previousResult = recorded }
else
recorded
val (filelist, _) = Reruncheck.getFileInfo recorded
val inputFilesToWatch = List.mapPartial (fn { path = _, abspath, kind = Reruncheck.INPUT } => SOME abspath | _ => NONE) filelist
fun loop inputFilesToWatch
= if doWatch inputFilesToWatch then
let val success = (doTypeset run_params; true) handle Abort => false
in if success then
let val recorded = Reruncheck.parseRecorderFile { file = recorderfile, options = options }
val recorded = if TeXEngine.isLuaTeX engine andalso FSUtil.isFile recorderfile2 then
Reruncheck.parseRecorderFileContinued { file = recorderfile2, options = options, previousResult = recorded }
else
recorded
val (filelist, _) = Reruncheck.getFileInfo recorded
val inputFilesToWatch = List.mapPartial (fn { path = _, abspath, kind = Reruncheck.INPUT } => SOME abspath | _ => NONE) filelist
in loop inputFilesToWatch
end
else
loop inputFilesToWatch (* error; watch the same files again *)
end
else
() (* exit *)
in loop inputFilesToWatch
end
fun getConfigFilePath (SOME configFilePath) = SOME configFilePath
| getConfigFilePath NONE = case OS.Process.getEnv "CLUTTEX_CONFIG_FILE" of
SOME f => SOME f
| NONE => if OSUtil.isWindows then
case OS.Process.getEnv "APPDATA" of
SOME appData => SOME (appData ^ "\\cluttex\\config.toml")
| NONE => NONE
else
case OS.Process.getEnv "XDG_CONFIG_HOME" of
SOME xdgConfigHome => SOME (xdgConfigHome ^ "/cluttex/config.toml")
| NONE => case OS.Process.getEnv "HOME" of
SOME home => SOME (home ^ "/.config/cluttex/config.toml")
| NONE => NONE
fun loadConfig configFileOpt = case getConfigFilePath configFileOpt of
NONE => ConfigFile.defaultConfig
| SOME path => (ConfigFile.loadConfig path handle IO.Io _ => ConfigFile.defaultConfig
| ValidateUtf8.InvalidUtf8 => (Message.error ("Config file " ^ path ^ " is not UTF-8 encoded."); ConfigFile.defaultConfig)
| TomlParseError.ParseError e => (Message.error ("Config file " ^ path ^ " is not a valid TOML file: " ^ TomlParseError.toString e); ConfigFile.defaultConfig)
)
fun main () = let val (options, rest) = HandleOptions.parse (AppOptions.init, CommandLine.arguments ())
val config = loadConfig (#config_file options)
(* Apply colors *)
val () = Option.app Message.setTypeStyle (#type_ (#color config))
val () = Option.app Message.setExecuteStyle (#execute (#color config))
val () = Option.app Message.setErrorStyle (#error (#color config))
val () = Option.app Message.setWarningStyle (#warning (#color config))
val () = Option.app Message.setDiagnosticStyle (#diagnostic (#color config))
val () = Option.app Message.setInformationStyle (#information (#color config))
val watch = #watch options
val () = case #color options of
NONE => Message.setColors Message.AUTO
| _ => ()
val inputfile = case rest of
[] => showUsage () (* No input file given *)
| [input] => input
| _ => ( Message.error "Multiple input files are not supported."
; OS.Process.exit OS.Process.failure
)
val engine = case #engine options of
SOME name => (case TeXEngine.get name of
SOME engine => engine
| NONE => ( Message.error ("Unknown engine name '" ^ name ^ "'.")
; OS.Process.exit OS.Process.failure
)
)
| NONE => let val name = CommandLine.name ()
val basename = PathUtil.trimext (PathUtil.basename name)
(* If run as 'cl' (e.g. 'cllualatex'), then the default engine is . *)
fun notSpecified () = ( Message.error "Engine not specified."
; OS.Process.exit OS.Process.failure
)
in if String.isPrefix "cl" basename andalso CharVector.all Char.isAlphaNum basename then
case TeXEngine.get (String.extract (basename, 2, NONE)) of
NONE => notSpecified ()
| SOME engine => engine
else
notSpecified ()
end
val output_format = Option.getOpt (#output_format options, OutputFormat.PDF)
val check_driver = case output_format of
OutputFormat.PDF =>
( case #check_driver options of
NONE => ()
| SOME _ => ( Message.error ("--check-driver can only be used when the output format is DVI.")
; OS.Process.exit OS.Process.failure
)
; if #supports_pdf_generation engine then
if TeXEngine.isLuaTeX engine then
SOME CheckDriver.LUATEX
else if TeXEngine.isXeTeX engine then
SOME CheckDriver.XETEX
else if TeXEngine.isPdfTeX engine then
SOME CheckDriver.PDFTEX
else
( Message.warn ("Unknown engine: " ^ #name engine)
; Message.warn "Driver check will not work."
; NONE
)
else
(* ClutTeX uses dvipdfmx to generate PDF from DVI output *)
SOME CheckDriver.DVIPDFMX
)
| OutputFormat.DVI =>
case #check_driver options of
SOME AppOptions.DviDriver.DVIPDFMX => SOME CheckDriver.DVIPDFMX
| SOME AppOptions.DviDriver.DVIPS => SOME CheckDriver.DVIPS
| SOME AppOptions.DviDriver.DVISVGM => SOME CheckDriver.DVISVGM
| NONE => NONE
val (jobname, jobname_for_output) = case #jobname options of
SOME jobname => (jobname, jobname)
| NONE => let val basename = PathUtil.basename (PathUtil.trimext inputfile)
in (SafeName.escapeJobname basename, basename)
end
val output_extension = case output_format of
OutputFormat.DVI => #dvi_extension engine (* "dvi" or "xdv" *)
| OutputFormat.PDF => "pdf"
val output_from_original_wd = case #output options of
NONE => jobname_for_output ^ "." ^ output_extension
| SOME output => output
val output_directory_from_original_wd
= case #output_directory options of
SOME dir => if #fresh options then
( Message.error "--fresh and --output-directory cannot be used together."
; OS.Process.exit OS.Process.failure
)
else
dir
| NONE => let val inputfile_abs = PathUtil.abspath { path = inputfile, cwd = NONE }
val output_directory = genOutputDirectory (#temporary_directory config, [inputfile_abs, jobname, Option.getOpt (#engine_executable options, #executable engine)])
in if not (FSUtil.isDirectory output_directory) then
FSUtil.mkDirRec output_directory
else if #fresh options then
( if Message.getVerbosity () >= 1 then
Message.info ("Cleaning '" ^ output_directory ^ "'...")
else
()
; FSUtil.removeRec output_directory
; OS.FileSys.mkDir output_directory
)
else
()
; output_directory
end
val () = if #print_output_directory options then
( print (output_directory_from_original_wd ^ "\n")
; OS.Process.exit OS.Process.success
)
else
()
val pathsep = if OSUtil.isWindows then
";"
else
":"
val original_wd = OS.FileSys.getDir ()
val (output, output_directory, tex_output_directory)
= if Option.getOpt (#change_directory options, false) then
let val TEXINPUTS = Option.getOpt (OS.Process.getEnv "TEXINPUTS", "")
val LUAINPUTS = Option.getOpt (OS.Process.getEnv "LUAINPUTS", "")
val () = OS.FileSys.chDir output_directory_from_original_wd
val () = OSUtil.setEnv ("TEXINPUTS", original_wd ^ pathsep ^ TEXINPUTS)
val () = OSUtil.setEnv ("LUAINPUTS", original_wd ^ pathsep ^ LUAINPUTS)
in (PathUtil.abspath { path = output_from_original_wd, cwd = SOME original_wd }, ".", NONE)
end
else
(output_from_original_wd, output_directory_from_original_wd, SOME output_directory_from_original_wd)
val output = case #bibtex_or_biber options of
SOME _ => let val BIBINPUTS = Option.getOpt (OS.Process.getEnv "BIBINPUTS", "")
val () = OSUtil.setEnv ("BIBINPUTS", original_wd ^ pathsep ^ BIBINPUTS)
in PathUtil.abspath { path = output_from_original_wd, cwd = SOME original_wd } (* Is this needed? *)
end
| NONE => output
(*
* Set `max_print_line' environment variable if not already set.
*
* 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.
*)
val () = case OS.Process.getEnv "max_print_line" of
NONE => OSUtil.setEnv ("max_print_line", "16384")
| SOME _ => ()
fun pathInOutputDirectory ext = PathUtil.join2 (output_directory, jobname ^ "." ^ ext)
val recorderfile = pathInOutputDirectory "fls"
val recorderfile2 = pathInOutputDirectory "cluttex-fls"
val tex_output_format = case output_format of
OutputFormat.DVI => OutputFormat.DVI
| OutputFormat.PDF => if #supports_pdf_generation engine then
OutputFormat.PDF
else
OutputFormat.DVI
(* Setup LuaTeX initialization script *)
val lua_initialization_script
= if TeXEngine.isLuaTeX engine then
let val initscriptfile = pathInOutputDirectory "cluttexinit.lua"
in LuaTeXInit.createInitializationScript (initscriptfile, { file_line_error = #file_line_error options, halt_on_error = #halt_on_error options, output_directory = output_directory, jobname = jobname })
; SOME initscriptfile
end
else
NONE
(* Set SOURCE_DATE_EPOCH if --source-date-epoch= is set *)
val source_date_epoch_info = case #source_date_epoch options of
SOME (AppOptions.SourceDateEpoch.RAW raw) =>
(OSUtil.setEnv ("SOURCE_DATE_EPOCH", raw); NONE)
| _ => if #source_date_epoch options = SOME AppOptions.SourceDateEpoch.NOW orelse OS.Process.getEnv "SOURCE_DATE_EPOCH" = NONE then
SOME (ref { time_since_epoch = getTimeSinceEpoch (), time = Time.now () })
else
NONE
val tex_options : TeXEngine.run_options
= { engine_executable = #engine_executable options
, interaction = SOME (Option.getOpt (#interaction options, InteractionMode.NONSTOPMODE))
, file_line_error = #file_line_error options
, halt_on_error = #halt_on_error options
, synctex = #synctex options
, output_directory = tex_output_directory
, shell_escape = #shell_escape options
, jobname = SOME jobname
, fmt = #fmt options
, extra_options = #tex_extraoptions options
, output_format = tex_output_format
, draftmode = false
, lua_initialization_script = lua_initialization_script
}
val options : AppOptions.options
= { engine = engine
, engine_executable = #engine_executable options
, output = output
, fresh = #fresh options
, max_iterations = Option.getOpt (#max_iterations options, 4)
, start_with_draft = #start_with_draft options
, watch = #watch options
, change_directory = Option.getOpt (#change_directory options, false)
, includeonly = #includeonly options
, make_depends = #make_depends options
, print_output_directory = #print_output_directory options
, package_support = #package_support options
, check_driver = check_driver
, source_date_epoch = #source_date_epoch options
, synctex = #synctex options
, file_line_error = #file_line_error options
, interaction = Option.getOpt (#interaction options, InteractionMode.NONSTOPMODE)
, halt_on_error = #halt_on_error options
, shell_escape = #shell_escape options
, jobname = jobname
, fmt = #fmt options
, output_directory = output_directory
, output_format = output_format
, tex_extraoptions = #tex_extraoptions options
, dvipdfmx_extraoptions = #dvipdfmx_extraoptions options
, makeindex = #makeindex options
, bibtex_or_biber = #bibtex_or_biber options
, makeglossaries = #makeglossaries options
}
val run_params = { options = options
, inputfile = inputfile
, engine = engine
, tex_options = tex_options
, recorderfile = recorderfile
, recorderfile2 = recorderfile2
, original_wd = original_wd
, output_extension = output_extension
, source_date_epoch_info = source_date_epoch_info
}
in case watch of
NONE => (doTypeset run_params handle Abort => OS.Process.exit OS.Process.failure)
| SOME watch_engine => runWatchMode (watch_engine, run_params)
end
end;
val () = Main.main ();