#!/usr/bin/env texlua

--
-- This is file `llmk.lua'.
--
-- Copyright 2018-2025 Takuto Asakura (wtsnjp)
--   GitHub:   https://github.com/wtsnjp
--   Twitter:  @wtsnjp
--
-- This sofware is released under the MIT License.
--

local llmk = {} -- the module table

----------------------------------------

do -- The "core" submodule
local M = {}

-- option flags (default)
M.debug = {
  config = false,
  parser = false,
  run = false,
  fdb = false,
  programs = false,
}
M.verbosity_level = 1
M.silent = false
M.dry_run = false

llmk.core = M
end

----------------------------------------

do -- The "const" submodule
local M = {}

-- program information
M.prog_name = 'llmk'
M.version = '1.2.1'
M.copyright = 'Copyright 2018-2025'
M.author = 'Takuto Asakura (wtsnjp)'
M.llmk_toml = 'llmk.toml'

-- exit codes
M.exit_ok = 0
M.exit_error = 1
M.exit_failure = 2
M.exit_parser = 3
M.exit_type = 4

-- config item specification
M.top_level_spec = {
  -- <program> = {<type>, <default value>}
  bibtex = {'string', 'bibtex'},
  clean_files = {'[string]', {
    '%B.aux', '%B.bbl', '%B.bcf', '%B-blx.bib', '%B.blg', '%B.fls',
    '%B.idx', '%B.ilg', '%B.ind', '%B.lof', '%B.log', '%B.lot', '%B.nav',
    '%B.out', '%B.run.xml', '%B.snm', '%B.toc', '%B.vrb',
  }},
  clobber_files = {'[string]', {'%B.dvi', '%B.pdf', '%B.ps', '%B.synctex.gz'}},
  dvipdf = {'string', 'dvipdfmx'},
  dvips = {'string', 'dvips'},
  extra_clean_files = {'[string]', {}},
  latex = {'string', 'lualatex'},
  llmk_version = {'string', nil},
  makeindex = {'string', 'makeindex'},
  makeglossaries = {'string', 'makeglossaries'},
  max_repeat = {'integer', 5},
  output_directory = {'string', nil},
  ps2pdf = {'string', 'ps2pdf'},
  sequence = {'[string]', {'latex', 'bibtex', 'makeindex', 'makeglossaries', 'dvipdf'}},
  source = {'*[string]', nil},
}

M.program_spec = {
  -- <item> = {<type>, {<specifiers allowed>, <default value>}}
  args = {'*[string]', {true, {'%T'}}},
  aux_file = {'string', {true, nil}},
  aux_empty_size = {'integer', {false, nil}},
  command = {'string', {false, ''}}, -- '' default because it must be string
  generated_target = {'boolean', {false, false}},
  opts = {'*[string]', {true, nil}},
  postprocess = {'string', {false, nil}},
  target = {'string', {true, '%S'}},
}

M.default_programs = {
  bibtex = {
    target = '%B.bib',
    args = {'%B'}, -- "%B.bib" will result in an error
    postprocess = 'latex',
  },
  dvipdf = {
    target = '%B.dvi',
    generated_target = true,
  },
  dvips = {
    target = '%B.dvi',
    generated_target = true,
  },
  latex = {
    opts = {
      '-interaction=nonstopmode',
      '-file-line-error',
      '-synctex=1',
      '-output-directory="%o"',
    },
    aux_file = '%B.aux',
    aux_empty_size = 9, -- "\\relax \n" is empty
  },
  makeindex = {
    target = '%B.idx',
    generated_target = true,
    postprocess = 'latex',
  },
  makeglossaries = {
    target = '%B.glo',
    generated_target = true,
    postprocess = 'latex',
    opts = {'-d "%o"'},
    args = {'%b.glo'}, -- "%B.glo" will result in an error
  },
  ps2pdf = {
    target = '%B.ps',
    generated_target = true,
  },
}

llmk.const = M
end

----------------------------------------

do -- The "util" submodule
local M = {}

local function log(label, msg, ...)
  local prefix = llmk.const.prog_name .. ' ' .. label .. ': '
  io.stderr:write(prefix .. msg:format(...) .. '\n')
end

function M.err_print(err_type, msg, ...)
  if err_type == 'error' then
    -- error must be reported
  elseif err_type == 'info' then
    if llmk.core.verbosity_level < 2 then return end
  elseif err_type == 'warning' then
    if llmk.core.verbosity_level < 1 then return end
  end
  log(err_type, msg, ...)
end

function M.dbg_print(dbg_type, msg, ...)
  if llmk.core.debug[dbg_type] then
    log('debug-' .. dbg_type, msg, ...)
  end
end

function M.dbg_print_table(dbg_type, table)
  if not llmk.core.debug[dbg_type] then return end

  local function helper(tab, ind)
    local function pp(msg, ...)
      M.dbg_print(dbg_type, string.rep(' ', ind) .. msg, ...)
    end
    for k, v in pairs(tab) do
      if type(v) == 'table' then
        pp(k .. ':')
        helper(v, ind + 2)
      elseif type(v) == 'string' then
        pp(k .. ': "%s"', v)
      else -- number,  boolean, etc.
        pp(k .. ': %s', tostring(v))
      end
    end
  end

  helper(table, 2)
end

function M.get_status(raw)
  if os.type == 'windows' then
    return raw
  else
    return raw / 256
  end
end

-- Replace config param to filename
function M.replace_specifiers(str, source, target, output_directory)
  local tmp = '/' .. source
  local basename_match = tmp:match('^.*/(.*)%..*$')

  str = str:gsub('%%S', source)
  str = str:gsub('%%T', target)

  local sub_basename = source
  if basename_match then
    sub_basename = basename_match
  end

  local sub_output_directory = '.'
  local sub_output_directory_basename = sub_basename
  if output_directory then
    sub_output_directory = output_directory
    sub_output_directory_basename = output_directory .. '/' .. sub_basename
  end

  str = str:gsub('%%b', sub_basename)
  str = str:gsub('%%o', sub_output_directory)
  str = str:gsub('%%B', sub_output_directory_basename)

  return str
end

llmk.util = M
end

----------------------------------------

do -- The "checker" submodule
local M = {}

local function checked_value(k, v, expected)
  local function error_if_wrong_type(val, t)
    if type(val) ~= t then
      llmk.util.err_print('error',
        '[Type Error] Key "%s" must have value of type %s', k, expected)
      os.exit(llmk.const.exit_type)
    end
  end

  if expected == 'integer' then
    error_if_wrong_type(v, 'number')
  elseif expected == 'boolean' then
    error_if_wrong_type(v, 'boolean')
  elseif expected == 'string' then
    error_if_wrong_type(v, 'string')
  elseif expected == '[string]' then
    error_if_wrong_type(v, 'table')

    if v[1] ~= nil then -- it is not an empty array
      error_if_wrong_type(v[1], 'string')
    end
  elseif expected == '*[string]' then
    if type(v) == 'string' then
      v = {v}
    else
      error_if_wrong_type(v, 'table')

      if v[1] ~= nil then -- it is not an empty array
        error_if_wrong_type(v[1], 'string')
      end
    end
  end

  return v
end

local function type_check(tab)
  local new_top = {}

  for k, v in pairs(tab) do
    if k == 'programs' then
      if type(v) ~= 'table' then
        llmk.util.err_print('error', '[Type Error] Key "programs" must be a table')
        os.exit(llmk.const.exit_type)
      end

      local new_prog = {}
      for p_name, p_val in pairs(v) do
        if type(p_val) ~= 'table' then
          llmk.util.err_print('error',
            '[Type Error] Key "programs.%s" must be a table', p_name)
          os.exit(llmk.const.exit_type)
        else
          new_prog[p_name] = {}
          for ik, iv in pairs(p_val) do
            if not llmk.const.program_spec[ik] then
              llmk.util.err_print('warning',
                'Program key "%s" is unknown; ignoring it', ik)
            else
              expected = llmk.const.program_spec[ik][1]
              new_prog[p_name][ik] = checked_value(ik, iv, expected)
            end
          end
        end
      end
      new_top[k] = new_prog
    else
      if not llmk.const.top_level_spec[k] then
        llmk.util.err_print('warning',
          'Top-level key "%s" is unknown; ignoring it', k)
      else
        expected = llmk.const.top_level_spec[k][1]
        new_top[k] = checked_value(k, v, expected)
      end
    end
  end

  return new_top
end

local function version_check(given_version)
  if not given_version then -- nothing to do
    return
  end

  -- parse the given version to the llmk_version key
  local given_major, given_minor = given_version:match('^(%d+)%.(%d+)')
  if not given_major or not given_minor then
    llmk.util.err_print('warning', 'In valid llmk_version: ' .. given_version)
    return
  else
    given_major, given_minor = tonumber(given_major), tonumber(given_minor)
  end

  -- the version of this program
  local major, minor = llmk.const.version:match('^(%d+)%.(%d+)')
  major, minor = tonumber(major), tonumber(minor)

  -- warn if this program is older than the given version
  if major < given_major or (major == given_major and minor < given_minor) then
    llmk.util.err_print('warning',
      'This program (v%d.%d) is older than the specified llmk_version (v%d.%d)',
      major, minor, given_major, given_minor)
  end

  -- warn if the given mojor version is older than this program
  if given_major < major then
    llmk.util.err_print('warning',
      'The specified llmk_version (v%d.%d) is older than this program (v%d.%d)',
      given_major, given_minor, major, minor)
  end

  -- Note: no breaking change has been made (yet)
end

function M.check(tab)
  local new_tab = type_check(tab)
  version_check(new_tab.llmk_version)
  return new_tab
end

llmk.checker = M
end

----------------------------------------

do -- The "config" submodule
local M = {}

local function init_config()
  local config = {}

  for k, v in pairs(llmk.const.top_level_spec) do
    config[k] = v[2]
  end

  config.programs = llmk.const.default_programs
  return config
end

-- copy command name from top level
local function fetch_from_top_level(config, name)
  if config.programs[name] then
    if not config.programs[name].command and config[name] then
      config.programs[name].command = config[name]
    end
  end
  return config
end

local function update_config(config, tab)
  -- merge the table from TOML
  local function merge_table(tab1, tab2)
    for k, v in pairs(tab2) do
      if k == 'programs' then
        local programs1 = tab1[k]
        local programs2 = tab2[k]

        for i_k, i_v in pairs(programs2) do
          if type(programs1[i_k]) == 'table' then
            for ii_k, ii_v in pairs(programs2[i_k]) do
              programs1[i_k][ii_k] = ii_v
            end
          else
            programs1[i_k] = i_v
          end
        end
      else
        tab1[k] = v
      end
    end
    return tab1
  end
  local config = merge_table(config, tab)

  -- set essential program names from top-level
  local prg_names = {'latex', 'bibtex', 'makeindex', 'makeglossaries', 'dvipdf', 'dvips', 'ps2pdf'}
  for _, name in pairs(prg_names) do
    config = fetch_from_top_level(config, name)
  end

  -- show config table (for debug)
  llmk.util.dbg_print('config', 'The final config table is as follows:')
  llmk.util.dbg_print_table('config', config)

  return config
end

function M.fetch_from_latex_source(fn)
  local tab
  local config = init_config()

  -- get TOML field and parse it
  local toml, line = llmk.parser.get_toml(fn)
  if toml == '' then
    llmk.util.err_print('warning',
      'Neither TOML field nor magic comment is found in "%s"; ' ..
      'using default config', fn)
  end
  tab = llmk.parser.parse_toml(toml, {fn, line})

  -- check input and merge it to the config
  tab = llmk.checker.check(tab)
  config = update_config(config, tab)

  return config
end

function M.fetch_from_llmk_toml()
  local tab
  local config = init_config()

  local f = io.open(llmk.const.llmk_toml)
  if f ~= nil then
    local toml = f:read('*all')
    tab = llmk.parser.parse_toml(toml, {llmk.const.llmk_toml, 1})
    f:close()
  else
    llmk.util.err_print('error', 'No target specified and no %s found',
      llmk.const.llmk_toml)
    os.exit(llmk.const.exit_error)
  end

  -- check input and merge it to the config
  tab = llmk.checker.check(tab)
  config = update_config(config, tab)

  return config
end

llmk.config = M
end

----------------------------------------

do -- The "parser" submodule
--[[
This TOML parser is modified version of toml.lua
- Copyright 2017 Jonathan Stoler
- Licensed under MIT
  https://github.com/jonstoler/lua-toml/blob/master/LICENSE
]]
local M = {}

function M.parse_toml(toml, file_info)
  -- basic local variables
  local ws = '[\009\032]'
  local nl = '[\10\13\10]'

  local buffer = ''
  local cursor = 1

  local line = 0

  local res = {}
  local obj = res

  -- basic local functions
  local function parser_err(msg)
    local function get_toml_str(nol)
      local pattern = string.format('([^%s]*)%s', nl:sub(2, -2), nl)
      local l = 0
      for cur_line in toml:gmatch(pattern) do
        if l == nol then
          return cur_line
        end
        l = l + 1
      end
    end

    llmk.util.err_print('error', '[Parse Error] %s', msg)
    llmk.util.err_print('error', '--> %s:%d: %s',
      file_info[1], file_info[2] + line, get_toml_str(line))
    os.exit(llmk.const.exit_parser)
  end

  local function char(n)
    n = n or 0
    return toml:sub(cursor + n, cursor + n)
  end

  local function step(n)
    n = n or 1
    for i = 0, n-1 do
      if char(i):match(nl) then
        line = line + 1
      end
    end
    cursor = cursor + n
  end

  local function skip_ws()
    while(char():match(ws)) do
      step()
    end
  end

  local function trim(str)
    return str:gsub('^%s*(.-)%s*$', '%1')
  end

  local function bounds()
    return cursor <= toml:len()
  end

  -- parse functions for each type
  local function parse_string()
    -- TODO: multiline
    local del = char() -- ' or "
    local str = ''
    -- all available escape characters
    local escape = {
      b = "\b",
      t = "\t",
      n = "\n",
      f = "\f",
      r = "\r",
      ['"'] = '"',
      ["\\"] = "\\",
    }
    -- utf function from http://stackoverflow.com/a/26071044
    -- converts \uXXX into actual unicode
    local function utf(char)
      local bytemarkers = {{0x7ff, 192}, {0xffff, 224}, {0x1fffff, 240}}
      if char < 128 then return string.char(char) end
      local charbytes = {}
      for bytes, vals in pairs(bytemarkers) do
        if char <= vals[1] then
          for b = bytes + 1, 2, -1 do
            local mod = char % 64
            char = (char - mod) / 64
            charbytes[b] = string.char(128 + mod)
          end
          charbytes[1] = string.char(vals[2] + char)
          break
        end
      end
      return table.concat(charbytes)
    end

    -- skip the quotes
    step()

    while(bounds()) do
      -- end of string
      if char() == del then
        step()
        break
      end

      if char():match(nl) then
        parser_err('Single-line string cannot contain line break')
      end

      if del == '"' and char() == '\\' then -- process escape characters
        if escape[char(1)] then
          -- normal escape
          str = str .. escape[char(1)]
          step(2) -- go past backslash and the character
        elseif char(1) == 'u' then
          -- utf-16
          step()
          local uni = char(1) .. char(2) .. char(3) .. char(4)
          step(5)
          uni = tonumber(uni, 16)
          if (uni >= 0 and uni <= 0xd7ff) and not (uni >= 0xe000 and uni <= 0x10ffff) then
            str = str .. utf(uni)
          else
            parser_err('Unicode escape is not a Unicode scalar')
          end
        elseif char(1) == 'U' then
          -- utf-32
          step()
          local uni = char(1) .. char(2) .. char(3) .. char(4) ..
                      char(5) .. char(6) .. char(7) .. char(8)
          step(9)
          uni = tonumber(uni, 16)
          if (uni >= 0 and uni <= 0xd7ff) and not (uni >= 0xe000 and uni <= 0x10ffff) then
            str = str .. utf(uni)
          else
            parser_err('Unicode escape is not a Unicode scalar')
          end
        else
          parser_err('Invalid escape')
        end
      else -- literal string; leave as it is
        str = str .. char()
        step()
      end
    end

    return str
  end

  local function parse_number()
    -- TODO: exp, date
    local num = ''

    while(bounds()) do
      if char():match('[%+%-%.eE_0-9]') then
        if char() ~= '_' then
          num = num .. char()
        end
      elseif char():match(nl) then
        break
      elseif char():match(ws) or char() == '#' then
        break
      else
        parser_err('Invalid number')
      end
      step()
    end

    return tonumber(num)
  end

  local get_value

  local function parse_array()
    step()
    skip_ws()

    local a_type
    local array = {}

    while(bounds()) do
      if char() == ']' then
        break
      elseif char():match(nl) then
        step()
        skip_ws()
      elseif char() == '#' then
        while(bounds() and not char():match(nl)) do
          step()
        end
      else
        local v = get_value()
        if not v then break end

        if a_type == nil then
          a_type = type(v)
        elseif a_type ~= type(v) then
          parser_err('Mixed types in array')
        end

        array = array or {}
        table.insert(array, v)

        if char() == ',' then
          step()
        end
        skip_ws()
      end
    end
    step()

    return array
  end

  local function parse_boolean()
    local bool

    if toml:sub(cursor, cursor + 3) == 'true' then
      step(4)
      bool = true
    elseif toml:sub(cursor, cursor + 4) == 'false' then
      step(5)
      bool = false
    else
      parser_err('Invalid primitive')
    end

    skip_ws()
    if char() == '#' then
      while(not char():match(nl)) do
        step()
      end
    end

    return bool
  end

  -- judge the type and get the value
  get_value = function()
    if (char() == '"' or char() == "'") then
      return parse_string()
    elseif char():match('[%+%-0-9]') then
      return parse_number()
    elseif char() == '[' then
      return parse_array()
    -- TODO: array of table, inline table
    else
      return parse_boolean()
    end
  end

  -- main loop of parser
  while(cursor <= toml:len()) do
    -- ignore comments and whitespace
    if char() == '#' then
      while(not char():match(nl)) do
        step()
      end
    end

    if char() == '=' then
      step()
      skip_ws()

      -- prepare the key
      local key = trim(buffer)
      buffer = ''

      if key == '' then
        parser_err('Empty key name')
      elseif obj[key] then
        -- duplicate keys are not allowed
        parser_err('Cannot redefine key "' .. key .. '"')
      end

      local value = get_value()
      if value ~= nil then
        obj[key] = value
        --dbg_print('parser', 'Entry "' .. key .. ' = ' .. value .. '"')
      end

      -- skip whitespace and comments
      skip_ws()
      if char() == '#' then
        while(bounds() and not char():match(nl)) do
          step()
        end
      end

      -- if garbage remains on this line, raise an error
      if not char():match(nl) and cursor < toml:len() then
        parser_err('Invalid primitive')
      end

    elseif char() == '[' then
      buffer = ''
      step()
      local table_array = false

      if char() == '[' then
        table_array = true
        step()
      end

      obj = res

      local function process_key(is_last)
        is_last = is_last or false
        buffer = trim(buffer)

        if buffer == '' then
          parser_err('Empty table name')
        end

        if is_last and obj[buffer] and not table_array and #obj[buffer] > 0 then
          parser_err('Cannot redefine tabel')
        end

        if table_array then
          if obj[buffer] then
            obj = obj[buffer]
            if is_last then
              table.insert(obj, {})
            end
            obj = obj[#obj]
          else
            obj[buffer] = {}
            obj = obj[buffer]
            if is_last then
              table.insert(obj, {})
              obj = obj[1]
            end
          end
        else
          obj[buffer] = obj[buffer] or {}
          obj = obj[buffer]
        end
      end

      while(bounds()) do
        if char() == ']' then
          if table_array then
            if char(1) ~= ']' then
              parser_err('Mismatching brackets')
            else
              step()
            end
          end
          step()

          process_key(true)
          buffer = ''
          break
        --elseif char() == '"' or char() == "'" then
          -- TODO: quoted keys
        elseif char() == '.' then
          step()
          process_key()
          buffer = ''
        else
          buffer = buffer .. char()
          step()
        end
      end

      buffer = ''
    --elseif (char() == '"' or char() == "'") then
      -- TODO: quoted keys
    end

    -- put the char to the buffer and proceed
    buffer = buffer .. (char():match(nl) and '' or char())
    step()
  end

  return res
end

function M.get_toml(fn)
  local toml = ''
  local toml_field = false
  local toml_source = fn

  local f = io.open(toml_source)

  llmk.util.dbg_print('config', 'Looking for config in the file "%s"', toml_source)

  local ts_tmp
  local ts_latex
  local ts_bibtex

  local first_line = true
  local shebang

  local line = 0
  local start_pos = -1

  for l in f:lines() do
    line = line + 1

    -- 1. llmk-style TOML field
    if string.match(l, '^%s*%%%s*%+%+%++%s*$') then
      -- NOTE: only topmost field is valid
      if not toml_field then
        toml_field = true
        start_pos = line + 1
      else
        llmk.util.dbg_print('config', 'TOML field found')
        break
      end
    else
      if toml_field then
        local match = string.match(l, '^%s*%%%s*(.-)%s*$')
        if match == nil then
          llmk.util.err_print('error', 'Encounter invalid line in TOML field')
          os.exit(llmk.const.exit_error)
        else
          toml = toml .. match .. '\n'
        end
      end
    end

    -- 2. TeXShop directives
    ts_tmp = string.match(l, '^%s*%%%s*!%s*TEX%s+program%s*=%s*(.-)%s*$') or
             string.match(l, '^%s*%%%s*!%s*TeX%s+program%s*=%s*(.-)%s*$') or
             string.match(l, '^%s*%%%s*!%s*TEX%s+TS%-program%s*=%s*(.-)%s*$')
    if ts_tmp then
      ts_latex = ts_latex or ts_tmp
    end

    ts_tmp = string.match(l, '^%s*%%%s*!%s*BIB%s+program%s*=%s*(.-)%s*$') or
             string.match(l, '^%s*%%%s*!%s*BIB%s+TS%-program%s*=%s*(.-)%s*$')
    if ts_tmp then
      ts_bibtex = ts_bibtex or ts_tmp
    end

    -- 3. shebang
    if first_line then
      first_line = false
      shebang = string.match(l, '^%s*%%#!%s*(.-)%s*$')
    end
  end

  f:close()

  -- convert magic or shebang to TOML
  if toml == '' and (ts_latex or ts_bibtex) then
    llmk.util.dbg_print('config', 'TeXShop directives found')
    if ts_latex then
      toml = toml .. 'latex = "' .. ts_latex .. '"\n'
    end
    if ts_bibtex then
      toml = toml .. 'bibtex = "' .. ts_bibtex .. '"\n'
    end
  elseif toml == '' and shebang then
    llmk.util.dbg_print('config', 'Shebang found')
    toml = 'latex = "' .. shebang .. '"\n'
  end

  return toml, start_pos
end

llmk.parser = M
end

----------------------------------------

do -- The "runner" submodule
local M = {}

-- dependencies
local lfs = require 'lfs'
local md5 = require 'md5'

-- module local variable
local start_time = os.time()

local function table_copy(org)
  local copy
  if type(org) == 'table' then
    copy = {}
    for org_key, org_value in next, org, nil do
      copy[table_copy(org_key)] = table_copy(org_value)
    end
    setmetatable(copy, table_copy(getmetatable(org)))
  else -- number, string, boolean, etc.
    copy = org
  end
  return copy
end

local function setup_programs(fn, config)
  --[[Setup the programs table for each sequence.

  Collecting tables of only related programs, which appears in the
  `config.sequence` or `prog.postprocess`, and replace all specifiers.

  Args:
    fn (str): the input FILE name

  Returns:
    table of program tables
  ]]
  local prognames = {}
  local new_programs = {}
  local programs = config.programs

  -- collect related programs
  local function add_progname(name)
    -- is the program known?
    if not programs[name] then
      llmk.util.err_print('error', 'Unknown program "%s" is in the sequence', name)
      os.exit(llmk.const.exit_error)
    end

    -- if not new, no addition
    for _, c in pairs(prognames) do
      if c == name then
        return
      end
    end

    -- if new, add it!
    prognames[#prognames + 1] = name
  end

  for _, name in pairs(config.sequence) do
    -- add the program name
    add_progname(name)

    -- add postprocess program if any
    local postprocess = programs[name].postprocess
    if postprocess then
      add_progname(postprocess)
    end
  end

  -- setup the programs
  for _, name in ipairs(prognames) do
    local prog = table_copy(programs[name])

    -- setup the `prog.target`
    local cur_target

    if prog.target == nil then
      -- the default value of `prog.target` is `fn`
      cur_target = fn
    else
      -- here, %T should be replaced by `fn`
      cur_target = llmk.util.replace_specifiers(prog.target, fn, fn, config.output_directory)
    end

    prog.target = cur_target

    -- initialize other items
    for k, v in pairs(llmk.const.program_spec) do
      if k ~= 'target' then -- target is a special case: already treated
        if prog[k] == nil then
          if type(v[2][2]) == 'table' then
            prog[k] = table_copy(v[2][2])
          else
            prog[k] = v[2][2]
          end
        end

        if v[2][1] then -- need to replace specifiers
          if type(prog[k]) == 'table' then
            for ik, iv in ipairs(prog[k]) do
              if type(prog[k][ik]) == 'string' then
                prog[k][ik] = llmk.util.replace_specifiers(iv, fn, cur_target, config.output_directory)
              end
            end
          elseif type(prog[k]) == 'string' then
            prog[k] = llmk.util.replace_specifiers(prog[k], fn, cur_target, config.output_directory)
          end
        end
      end
    end

    -- register the program
    new_programs[name] = prog
  end

  return new_programs
end

local function file_mtime(path)
  return lfs.attributes(path, 'modification')
end

local function file_size(path)
  return lfs.attributes(path, 'size')
end

local function file_md5sum(path)
  local f = assert(io.open(path, 'rb'))
  local content = f:read('*a')
  f:close()
  return md5.sumhexa(content)
end

local function file_status(path)
  return {
    mtime = file_mtime(path),
    size = file_size(path),
    md5sum = file_md5sum(path),
  }
end

local function init_file_database(programs, fn, config)
  -- the template
  local fdb = {
    targets = {},
    aux_files = {},
  }

  -- investigate current status
  for _, v in ipairs(config.sequence) do
    -- names
    local cur_target = programs[v].target
    local cur_aux = programs[v].aux_file

    -- target
    if lfs.isfile(cur_target) and not fdb.targets[cur_target] then
      fdb.targets[cur_target] = file_status(cur_target)
    end

    -- aux_file
    if cur_aux then -- `prog.aux_file` is optional
      if lfs.isfile(cur_aux) and not fdb.aux_files[cur_aux] then
        fdb.aux_files[cur_aux] = file_status(cur_aux)
      end
    end
  end

  return fdb
end

local function construct_cmd(prog)
  -- construct the option
  local cmd_opt = ''

  if prog.opts then
    -- construct each option
    for _, opt in ipairs(prog.opts) do
      if #opt > 0 then
        cmd_opt = cmd_opt .. ' ' .. opt
      end
    end
  end

  -- construct the argument
  local cmd_arg = ''

  -- construct each argument
  for _, arg in ipairs(prog.args) do
    cmd_arg = cmd_arg .. ' "' .. arg .. '"'
  end

  -- whole command
  return prog.command .. cmd_opt .. cmd_arg
end

local function check_rerun(prog, fdb)
  llmk.util.dbg_print('run', 'Checking the neccessity of rerun')

  local aux = prog.aux_file
  local old_aux_exist = false
  local old_status

  -- if aux file does not exist, no chance of rerun
  if not aux then
    llmk.util.dbg_print('run', 'No auxiliary file specified')
    return false, fdb
  end

  -- if aux file does not exist, no chance of rerun
  if not lfs.isfile(aux) then
    llmk.util.dbg_print('run', 'The auxiliary file "%s" does not exist', aux)
    return false, fdb
  end

  -- copy old information and update fdb
  if fdb.aux_files[aux] then
    old_aux_exist = true
    old_status = table_copy(fdb.aux_files[aux])
  end
  local aux_status = file_status(aux)
  fdb.aux_files[aux] = aux_status

  -- if aux file is not new, no rerun
  local new = aux_status.mtime >= start_time
  if not new and old_aux_exist then
    new = aux_status.mtime > old_status.mtime
  end

  if not new then
    llmk.util.dbg_print('run', 'No rerun because the aux file is not new')
    return false, fdb
  end

  -- if aux file is empty (or almost), no rerun
  if aux_status.size < prog.aux_empty_size then
    llmk.util.dbg_print('run', 'No rerun because the aux file is (almost) empty')
    return false, fdb
  end

  -- if new aux is not different from older one, no rerun
  if old_aux_exist then
    if aux_status.md5sum == old_status.md5sum then
      llmk.util.dbg_print('run', 'No rerun because the aux file has not been changed')
      return false, fdb
    end
  end

  -- ok, then try rerun
  llmk.util.dbg_print('run', 'Try to rerun!')
  return true, fdb
end

local function silencer(cmd)
  local redirect_code
  if os.type == 'windows' then
    redirect_code = ' >NUL 2>&1'
  else
    redirect_code = ' >/dev/null 2>&1'
  end
  silencer = function(cmd) return cmd .. redirect_code end
  return silencer(cmd)
end

local function run_program(name, prog, fn, fdb, postprocess)
  -- preparation for dry run
  local function concat_cond(tab)
    local res
    for i, v in ipairs(tab) do
      if i == 1 then
        res = v
      else
        res = res .. '; ' .. v
      end
    end
    return res
  end
  local cond = {}

  if postprocess then
    cond[#cond + 1] = 'as postprocess'
  end

  if prog.aux_file then
    cond[#cond + 1] = 'possibly with rerunning'
  end

  -- does command specified?
  if #prog.command < 1 then
    llmk.util.err_print('warning',
      'The "command" key is not set for program "%s"; skipping', name)
    return false
  end

  -- does target exist?
  if not llmk.core.dry_run and not lfs.isfile(prog.target) then
    llmk.util.dbg_print('run',
      'Skipping "%s" because target (%s) does not exist',
      prog.command, prog.target)
    return false
  end

  -- is the target modified?
  if prog.generated_target then
    if llmk.core.dry_run then
      cond[#cond + 1] = string.format('if the target file "%s" has been generated',
        prog.target)
    elseif file_mtime(prog.target) < start_time then
      llmk.util.dbg_print('run',
        'Skipping "%s" because target (%s) is not updated',
        prog.command, prog.target)
      return false
    end
  else
    if llmk.core.dry_run then
      cond[#cond + 1] = string.format('if the target file "%s" exists', prog.target)
    end
  end

  local cmd = construct_cmd(prog)
  if llmk.core.dry_run then
    print('Dry running: ' .. cmd)
    if #cond > 0 then
      llmk.util.err_print('info', '<-- ' .. concat_cond(cond))
    end
    return false
  else
    llmk.util.err_print('info', 'Running command: ' .. cmd)
  end

  -- redirect stdout and stderr to NULL in silent mode
  if llmk.core.silent then
    cmd = silencer(cmd)
  end

  -- call and check the status
  -- Note: os.execute() returns values 256 multiples of actual status
  --       except on Windows
  local cmd_status = llmk.util.get_status(os.execute(cmd))
  if cmd_status > 0 then
    llmk.util.err_print('error',
      'Fail running %s (exit code: %d)', cmd, cmd_status)
    os.exit(llmk.const.exit_failure)
  end

  return true
end

local function process_program(programs, name, fn, fdb, config, postprocess)
  local postprocess = postprocess or false
  local prog = programs[name]
  local should_rerun

  -- execute the command
  local run = false
  local exe_count = 0
  while true do
    exe_count = exe_count + 1
    run = run_program(name, prog, fn, fdb, postprocess)

    -- if the run is skipped, break immediately
    if not run then break end

    -- if not neccesarry to rerun or reached to max_repeat, break the loop
    should_rerun, fdb = check_rerun(prog, fdb)
    if not ((exe_count < config.max_repeat) and should_rerun) then
      break
    end
  end

  -- go to the postprocess process
  if prog.postprocess and (run or llmk.core.dry_run) then
    llmk.util.dbg_print('run', 'Going to postprocess "%s"', prog.postprocess)
    process_program(programs, prog.postprocess, fn, fdb, config, true)
  end
end

function M.run_sequence(fn, config)
  llmk.util.err_print('info', 'Beginning a sequence for "%s"', fn)

  -- setup the programs table
  local programs = setup_programs(fn, config)
  llmk.util.dbg_print('programs', 'Current programs table:')
  llmk.util.dbg_print_table('programs', programs)

  -- create a file database
  local fdb = init_file_database(programs, fn, config)
  llmk.util.dbg_print('fdb', 'The initial file database is as follows:')
  llmk.util.dbg_print_table('fdb', fdb)

  -- check if output directory exists
  if config.output_directory and not lfs.isdir(config.output_directory) then
    llmk.util.err_print('error', 'Output directory does not exist "%s"', config.output_directory)
  end

  for _, name in ipairs(config.sequence) do
    llmk.util.dbg_print('run', 'Preparing for program "%s"', name)
    process_program(programs, name, fn, fdb, config)
  end
end

llmk.runner = M
end

do -- The "cleaner" submodule
local M = {}

-- dependencies
local lfs = require("lfs")

-- fn is filepath of target to remove.
local function remove(fn)
  if llmk.core.dry_run then
    print(string.format('Dry running: removing file "%s"', fn))
  else
    local ok = os.remove(fn)

    if ok ~= true then
      llmk.util.err_print('error', 'Failed to remove "%s"', fn)
    else
      llmk.util.err_print('info', 'Removed "%s"', fn)
    end
  end
end

local function replace_spec_and_remove_files(fns, source, output_directory)
  for _, fn in ipairs(fns) do
    local replaced_fn = llmk.util.replace_specifiers(fn, source, source, output_directory)
    if lfs.isfile(replaced_fn) then
      remove(replaced_fn)
    end
  end
end

-- the actual process for the --clean action
function M.clean(fn, config)
  llmk.util.err_print('info', 'Begining cleaning for "%s"', fn)
  replace_spec_and_remove_files(config.clean_files, fn, config.output_directory)
  replace_spec_and_remove_files(config.extra_clean_files, fn, config.output_directory)
end

-- the actual process for the --clobber action
function M.clobber(fn, config)
  llmk.util.err_print('info', 'Begining clobbering for "%s"', fn)
  replace_spec_and_remove_files(config.clean_files, fn, config.output_directory)
  replace_spec_and_remove_files(config.extra_clean_files, fn, config.output_directory)
  replace_spec_and_remove_files(config.clobber_files, fn, config.output_directory)
end

llmk.cleaner = M
end

----------------------------------------

do -- The "cli" submodule
local M = {}
local C = llmk.const

local help_text = [[
Usage: llmk [OPTION]... [FILE]...

Options:
  -c, --clean           Remove the temporary files such as aux and log files.
  -C, --clobber         Remove all generated files including final PDFs.
  -d CAT, --debug=CAT   Activate debug output restricted to CAT.
  -D, --debug           Activate all debug output (equal to "--debug=all").
  -h, --help            Print this help message.
  -n, --dry-run         Show what would have been executed.
  -q, --quiet           Suppress most messages.
  -s, --silent          Silence messages from called programs.
  -v, --verbose         Print additional information.
  -V, --version         Print the version number.

Please report bugs to <https://github.com/wtsnjp/llmk/issues>.
]]

local version_text = [[
%s %s

%s %s.
License: The MIT License <https://opensource.org/licenses/mit-license>.
This is free software: you are free to change and redistribute it.
]]

-- execution functions
local function read_options()
  local curr_arg
  local action = false

  -- modified Alternative Get Opt
  -- cf. http://lua-users.org/wiki/AlternativeGetOpt
  local function getopt(arg, options)
    local tmp
    local tab = {}
    local saved_arg = {table.unpack(arg)}
    for k, v in ipairs(saved_arg) do
      if string.sub(v, 1, 2) == '--' then
        table.remove(arg, 1)
        local x = string.find(v, '=', 1, true)
          if x then
            table.insert(tab, {string.sub(v, 3, x-1), string.sub(v, x+1)})
          else
            table.insert(tab, {string.sub(v, 3), true})
          end
      elseif string.sub(v, 1, 1) == '-' then
        table.remove(arg, 1)
        local y = 2
        local l = string.len(v)
        local jopt
        while (y <= l) do
          jopt = string.sub(v, y, y)
          if string.find(options, jopt, 1, true) then
            if y < l then
              tmp = string.sub(v, y+1)
              y = l
            else
              table.remove(arg, 1)
              tmp = saved_arg[k + 1]
            end
            if string.match(tmp, '^%-') then
              table.insert(tab, {jopt, false})
            else
              table.insert(tab, {jopt, tmp})
            end
          else
            table.insert(tab, {jopt, true})
          end
          y = y + 1
        end
      end
    end
    return tab
  end

  local opts = getopt(arg, 'd')
  for _, tp in pairs(opts) do
    k, v = tp[1], tp[2]
    if #k == 1 then
      curr_arg = '-' .. k
    else
      curr_arg = '--' .. k
    end

    -- action
    if (curr_arg == '-h') or (curr_arg == '--help') then
      return 'help' -- immediately show help
    elseif (curr_arg == '-V') or (curr_arg == '--version') then
      return 'version' -- immediately show version
    elseif (curr_arg == '-c') or (curr_arg == '--clean') then
      action = 'clean'
    elseif (curr_arg == '-C') or (curr_arg == '--clobber') then
      action = 'clobber'
    -- debug
    elseif (curr_arg == '-D') or
      (curr_arg == '--debug' and (v == 'all' or v == true)) then
      for c, _ in pairs(llmk.core.debug) do
        llmk.core.debug[c] = true
      end
    elseif (curr_arg == '-d') or (curr_arg == '--debug') then
      if llmk.core.debug[v] == nil then
        llmk.util.err_print('warning', 'unknown debug category: ' .. v)
      else
        llmk.core.debug[v] = true
      end
    -- verbosity
    elseif (curr_arg == '-q') or (curr_arg == '--quiet') then
      llmk.core.verbosity_level = 0
    elseif (curr_arg == '-v') or (curr_arg == '--verbose') then
      llmk.core.verbosity_level = 2
    elseif (curr_arg == '-s') or (curr_arg == '--silent') then
      llmk.core.silent = true
    -- dry run
    elseif (curr_arg == '-n') or (curr_arg == '--dry-run') then
      llmk.core.dry_run = true
    -- problem
    else
      llmk.util.err_print('error', 'unknown option: ' .. curr_arg)
      os.exit(C.exit_error)
    end
  end

  return action
end

local function check_filename(fn)
  if lfs.isfile(fn) then
    return fn -- ok
  end

  local ext = fn:match('%.(.-)$')
  if ext ~= nil then
    return nil
  end

  local new_fn = fn .. '.tex'
  if lfs.isfile(new_fn) then
    return new_fn
  else
    return nil
  end
end

local function make(fns, func)
  local config
  if #fns > 0 then
    for _, fn in ipairs(fns) do
      local checked_fn = check_filename(fn)
      if checked_fn then
        config = llmk.config.fetch_from_latex_source(checked_fn)
        func(checked_fn, config)
      else
        llmk.util.err_print('error', 'Source file "%s" does not exist', fn)
        os.exit(C.exit_error)
      end
    end
  else
    config = llmk.config.fetch_from_llmk_toml()

    local source = config.source
    if source ~= nil then
      for _, fn in ipairs(source) do
        local checked_fn = check_filename(fn)
        if checked_fn then
          func(checked_fn, config)
        else
          llmk.util.err_print('error', 'Source file "%s" does not exist', fn)
          os.exit(C.exit_error)
        end
      end
    else
      llmk.util.err_print('error', 'No source detected')
      os.exit(C.exit_error)
    end
  end
end

local function do_action(action)
  if action == 'help' then
    io.stdout:write(help_text)
  elseif action == 'version' then
    io.stdout:write(version_text:format(
      C.prog_name, C.version, C.copyright, C.author))
  elseif action == 'clean' then
    make(arg, llmk.cleaner.clean)
  elseif action == 'clobber' then
    make(arg, llmk.cleaner.clobber)
  end
end

function M.exec()
  local action = read_options()

  if action then
    do_action(action)
    os.exit(C.exit_ok)
  end

  make(arg, llmk.runner.run_sequence)
  os.exit(C.exit_ok)
end

llmk.cli = M
end

----------------------------------------

assert(llmk.cli, 'Internal error: llmk is not installed properly')
llmk.cli.exec()

-- EOF
