(* 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 <http://www.gnu.org/licenses/>.\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 "<bcf:datasource .*>(.*)</bcf:datasource>"] 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<engine name>' (e.g. 'cllualatex'), then the default engine is <engine name>. *) 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=<timestamp> 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 ();