-- KKluaverb.lua
-- Copyright (c) 2026 Kosei Kawaguchi
-- Released under the MIT License (see LICENSE.md for details)
--
-- This file is based on 'bxrawstr.lua' by Takayuki YATO (aka. "ZR").
-- Copyright (c) 2018 Takayuki YATO
--

luatexbase.provides_module{
  name     = 'KKluaverb',
  date     = '2026/06/12',
  version  = '2.3.0',
}

----- for .sty interface -----
KKLuaVerb = KKLuaVerb or {}
----------


----- for .lua interface-----
local KKV = {}
local in_process = false
  -- false  : not in raw mode
  -- "verb" : raw mode opened by \KKverb (ends only with `trm`)
  -- "code" : raw mode opened by \KKcodeS (ends only with \KKcodeE)

local CMD_INIT = "\\KKlvStart*"
local CMD_TERM = "\\KKlvEnd*"
local DEFAULT_STARTER = "\\KKverb"
local DEFAULT_STARTER_flag1 = "\\KKcodeS"
local DEFAULT_TERMINATOR_flag1   = "\\KKcodeE"

KKV.code_escape_char = "\\"
KKV.wrap = false
  -- Inside \KKcodeS...\KKcodeE, this character placed
  -- immediately before \KKcodeE makes it a literal text
  -- instead of the terminator.

local ltjflg = utf8.char(0xFFFFF) .. "\n$"
----------


----- encode -----
function KKV.encode(str)
  if not str then return "" end

  local t = {}

  for _, code in utf8.codes(str) do     -- discard pos
    if ((code >= 48 and code <= 57)     -- 0-9
      or (code >= 65 and code <= 90)    -- A-Z
      or (code >= 97 and code <= 122))  -- a-z
      and code ~= 42 then               -- except for '*'
      table.insert(t, string.char(code))
    else
      -- Transform the input into hexadecimal.
      local formatted
      if code < 0x100 then
        formatted = string.format("*%02X", code)
      elseif code < 0x10000 then
        formatted = string.format("*u%04X", code)
      else
        formatted = string.format("*U%06X", code)
      end
      table.insert(t, formatted)
    end
  end

  return table.concat(t)
end

function KKV.encode_tail(str)
  local s = (str .. "\n"):gsub(ltjflg, "\n")
  return KKV.encode(s)
end
----------


----- replacement -----
KKV.replacements = {}

function KKV.add_replacement(from_char, to_char)
  -- When overwriting, clean up any tex_map entry for the old marker.
  local old = KKV.replacements[from_char]
  if old and KKV.tex_map[old] then
    KKV.tex_map[old] = nil
  end
  KKV.replacements[from_char] = to_char
end

KKV.add_replacement(" ", "\194\160")
  -- Avoid ignoring space.

-- Enhancement in v2.2.x
-- Map a character to a TeX command (e.g. " " -> \textvisiblespace).
-- Internally, an unused control byte (0x01-0x1F) is used as a marker:
-- the replacer turns <from_char> into the marker, and the output
-- routine emits <tex_cmd> (with expansion) whenever it meets the marker.
KKV.tex_map = {}

function KKV.add_tex_map_from(from_char, tex_cmd)
  -- Find an unused control byte to serve as the marker.
  local marker
  for b = 1, 31 do
    local c = string.char(b)
    if KKV.tex_map[c] == nil then
      marker = c
      break
    end
  end
  if not marker then
    texio.write_nl(
      "Package KKluaverb Warning: no free internal marker left "
      .. "(max 31 TeX mappings); mapping ignored.")
    return
  end
  KKV.tex_map[marker] = tex_cmd
  KKV.add_replacement(from_char, marker)
end

function KKV.clear_tex_map_for(from_char)
  -- Remove the tex_map entry for from_char and restore its default replacement.
  -- For space, the default is NBSP; for other chars, the mapping is simply removed.
  local marker = KKV.replacements[from_char]
  if marker and KKV.tex_map[marker] then
    KKV.tex_map[marker] = nil
    if from_char == " " then
      KKV.replacements[from_char] = "\194\160"
    else
      KKV.replacements[from_char] = nil
    end
  end
end
----------


----- decode -----
local function count_leading_nbsp(s)
  -- Returns (count, first_non_nbsp_byte_pos).
  -- NBSP = U+00A0 = UTF-8: 0xC2 0xA0
  local n, i = 0, 1
  while i + 1 <= #s do
    if s:byte(i) == 0xC2 and s:byte(i + 1) == 0xA0 then
      n = n + 1; i = i + 2
    else break end
  end
  return n, i
end

function KKV.decode(rstr)
  local chex = function(s) return utf8.char(tonumber(s, 16)) end
  local decoded = rstr
    :gsub('*U(%x%x%x%x%x%x)', chex)
    :gsub('*u(%x%x%x%x)', chex)
    :gsub('*(%x%x)', chex)

  -- After characters are decoded, run the replacer.
  for from, to in pairs(KKV.replacements) do
    decoded = decoded:gsub(from, to)
  end

  -- How to process linebreak
  local lb_flag = tex.gettoks("kklv@linebreak")

  -- Character width for wrap hangindent (measured from current \ttfamily font)
  local char_width_sp, wrap_indent_sp = 0, 0
  if KKLuaVerb.wrap then
    local f = font.getfont(font.current())
    if f and f.characters then
      local c = f.characters[48] or f.characters[65] or f.characters[120]
      char_width_sp = (c and c.width) or 0
    end
    if char_width_sp == 0 and f and f.parameters then
      char_width_sp = f.parameters[2] or 0
    end
    wrap_indent_sp = tex.getdimen("kklv@wrap@indent")
  end

  -- If lb_flag is "1",
  -- a "verbatim paragraph" is produced.
  -- Behave like an environment.
  if lb_flag == "1" then
    local dc_lines = {}
    local fl_linenumber = (tex.count["kklv@linenum@start"] - 1)

    -- Separate 'decoded' to 'dc_lines'.
    for line in (decoded .. "\n"):gmatch("(.-)\n") do
      table.insert(dc_lines, line)
    end
    local last_idx = #dc_lines
    last_idx = last_idx - 1
      -- Delete the last line.

    if KKLuaVerb.wrap then
      -- Each logical line becomes its own paragraph so \hangindent applies per-line.
      -- \parskip0pt suppresses inter-paragraph spacing within the block.
      tex.sprint("\\par{\\parskip0pt\\relax")
      for i = 2, last_idx do -- ref: NOTE#1
        local content = dc_lines[i]
        local hangindent_sp = wrap_indent_sp
        if content ~= "" then
          local leading_n, rest_pos = count_leading_nbsp(content)
          -- Keep leading NBSP (indentation); replace remaining NBSP with breakable spaces.
          local leading_bytes = content:sub(1, rest_pos - 1)
          local rest = content:sub(rest_pos):gsub("\194\160", " ")
          content = leading_bytes .. rest
          hangindent_sp = leading_n * char_width_sp + wrap_indent_sp
        end
        tex.sprint("\\noindent\\hangafter1\\hangindent=" .. hangindent_sp .. "sp ")
        if content ~= "" then
          local map_to_use = KKV.active_map or {}
          KKV.output_with_multiple_colors(content, map_to_use, true)
        end
        tex.sprint("\\hspace*{\\fill}\\par")
      end
      tex.sprint("}")
    else
      tex.sprint("\\par\\noindent")
      for i = 2, last_idx do -- ref: NOTE#1
        local content = dc_lines[i]
        if content ~= "" then
          local map_to_use = KKV.active_map or {}
          KKV.output_with_multiple_colors(content, map_to_use, true)
        end
        if i < last_idx then
          tex.sprint("\\hfill\\break\\noindent")
        else
          tex.sprint("\\hspace*{\\fill}\\par")
        end
      end
    end

  -- If lb_flag is "2",
  -- a "verbatim paragraph with line numbers"
  -- is produced.
  -- Behave like an environment.
  elseif lb_flag == "2" then
    local dc_lines = {}
    local fl_linenumber = (tex.count["kklv@linenum@start"] - 1)

    -- Separate 'decoded' to 'dc_lines'.
    for line in (decoded .. "\n"):gmatch("(.-)\n") do
      table.insert(dc_lines, line)
    end
    local last_idx = #dc_lines
    last_idx = last_idx - 1
      -- Delete the last line.

    if KKLuaVerb.wrap then
      tex.sprint("\\par{\\parskip0pt\\relax")
      for i = 2, last_idx do -- ref: NOTE#1
        local content = dc_lines[i]
        local hangindent_sp = wrap_indent_sp
        if content ~= "" then
          local leading_n, rest_pos = count_leading_nbsp(content)
          local leading_bytes = content:sub(1, rest_pos - 1)
          local rest = content:sub(rest_pos):gsub("\194\160", " ")
          content = leading_bytes .. rest
          hangindent_sp = leading_n * char_width_sp + wrap_indent_sp
        end
        tex.sprint("\\noindent\\hangafter1\\hangindent=" .. hangindent_sp .. "sp ")
        tex.sprint("\\KKlvLineNumber{" .. (i - 1 + fl_linenumber) .. "}")
        if content ~= "" then
          local map_to_use = KKV.active_map or {}
          KKV.output_with_multiple_colors(content, map_to_use, true)
        end
        tex.sprint("\\hspace*{\\fill}\\par")
      end
      tex.sprint("}")
    else
      tex.sprint("\\par\\noindent")
      for i = 2, last_idx do -- ref: NOTE#1
        tex.sprint("\\KKlvLineNumber{" .. (i - 1 + fl_linenumber) .. "}")
        local content = dc_lines[i]
        if content ~= "" then
          local map_to_use = KKV.active_map or {}
          KKV.output_with_multiple_colors(content, map_to_use, true)
        end
        if i < last_idx then
          tex.sprint("\\hfill\\break\\noindent")
        else
          tex.sprint("\\hspace*{\\fill}\\par")
        end
      end
    end

  -- If lb_flag is not "1" or "2",
  -- any linebreaks are completely ignored.
  else
    decoded = decoded:gsub('[\t\r\n]', '') 
    local map_to_use = KKV.active_map or {}
    KKV.output_with_multiple_colors(decoded, map_to_use)
  end
end
----------


----- scanner -----
function KKV.scanner_for_verb(line)
  -- If the scanner is unabled, it returns nil.
  if not KKLuaVerb.enabled then return nil end

  -- When the process_input_buffer runs,
  -- a chunk of text on a single line
  -- is passed to the function as the argument `line`

  local spec = tex.gettoks("kklv@delims")

  -- In the .sty, \kklv@delims is defined
  -- as {token1}{token2}.
  -- So cut off the brackets.
  local ini_raw, trm_raw = spec:match("^{(.*)}{(.*)}$")
  local ini = ini_raw or "|"
  local trm = trm_raw or "|"

  local pos = 1 -- the character index
  local res = {} -- a transformed chunk
  local start_cmd = DEFAULT_STARTER .. ini
  local shortcut_start = DEFAULT_STARTER_flag1
  local shortcut_end = DEFAULT_TERMINATOR_flag1

  -- While the character index 
  -- <= the length of the line,
  -- scan the chunk:
  while (pos <= #line) or (in_process and pos == 1 and #line == 0) do
    if not in_process then
      -- for \KKverb
      local s_idx, e_idx = line:find(start_cmd, pos, true)
      -- for \KKcodeS, E
      local s_short_idx, e_short_idx = line:find(shortcut_start, pos, true)

      if s_short_idx and (not s_idx or s_short_idx < s_idx) then
        in_process = "code"

        local next_char = line:sub(e_short_idx + 1, e_short_idx + 1)
        local style_num = "1" 
        local skip_len = 0

        if next_char == "+" then
          style_num = "2"   
          skip_len = 1     
        end

        local transform = line:sub(pos, s_short_idx - 1) .. "{\\KKvLNChange{style=" .. style_num .. "}" .. CMD_INIT
        table.insert(res, transform)

        pos = e_short_idx + 1 + skip_len

        -- NOTE#1
        -- This part was added 
        -- in order to avoid being inserted
        -- unwanted space to the first line 
        -- when the flag is 1 or 2. 
        if not line:find(shortcut_end, pos, true) then
          local sc_content = line:sub(pos)
          table.insert(res, KKV.encode_tail(sc_content) .. "%")
          pos = #line + 1 
        end
      elseif s_idx then
        in_process = "verb"
        table.insert(res, line:sub(pos, s_idx - 1) .. CMD_INIT)
        pos = e_idx + 1

        -- NOTE#1
        -- This part was added 
        -- in order to avoid being inserted
        -- unwanted space to the first line 
        -- when the flag is 1 or 2. 
        if not line:find(trm, pos, true) then
          local sc_content = line:sub(pos)
          table.insert(res, KKV.encode_tail(sc_content) .. "%")
          pos = #line + 1
        end
      else
        local sc_content = line:sub(pos)
        table.insert(res, sc_content)
        break
      end

    elseif in_process == "code" then
      -- Raw mode opened by \KKcodeS:
      -- only \KKcodeE terminates it; `trm` (e.g. "|") is raw text.
      -- An occurrence of \KKcodeE preceded by the escape char
      -- is kept as literal text (with the escape char dropped).
      local raw = {}
      local search = pos
      local s_end_idx, e_end_idx
      while true do
        local s, e = line:find(shortcut_end, search, true)
        if not s then break end
        if s > search and line:sub(s - 1, s - 1) == KKV.code_escape_char then
          table.insert(raw, line:sub(search, s - 2) .. shortcut_end)
          search = e + 1
        else
          s_end_idx, e_end_idx = s, e
          break
        end
      end

      if s_end_idx then
        table.insert(raw, line:sub(search, s_end_idx - 1))
        table.insert(res, KKV.encode(table.concat(raw)) .. CMD_TERM .. "}")
        in_process = false
        pos = e_end_idx + 1
      else
        table.insert(raw, line:sub(search))
        table.insert(res, KKV.encode_tail(table.concat(raw)) .. "%")
        break
      end

    else
      -- Raw mode opened by \KKverb:
      -- only `trm` terminates it.
      local s_idx, e_idx = line:find(trm, pos, true)

      if s_idx then
        local sc_content = line:sub(pos, s_idx - 1)
        table.insert(res, KKV.encode(sc_content) .. CMD_TERM)
        in_process = false
        pos = e_idx + 1

      else
        local sc_content = line:sub(pos)
        table.insert(res, KKV.encode_tail(sc_content) .. "%")
        break
      end
    end
  end
  return table.concat(res)
end
----------


----- color changer -----
-- Enhancement in v2.2.x
-- Print verbatim content, but expand registered TeX mappings:
-- segments are printed with catcode-12 (-2), while marker bytes
-- are replaced by their TeX command (printed with expansion).
local function sprint_tex_mapped(content)
  if next(KKV.tex_map) == nil then
    tex.sprint(-2, content)
    return
  end
  local pos = 1
  while true do
    local s = content:find("[\1-\31]", pos)
    if not s then
      if pos <= #content then
        tex.sprint(-2, content:sub(pos))
      end
      break
    end
    if s > pos then
      tex.sprint(-2, content:sub(pos, s - 1))
    end
    local cmd = KKV.tex_map[content:sub(s, s)]
    if cmd then
      tex.sprint(cmd)
    else
      -- Unregistered control byte: keep it as-is.
      tex.sprint(-2, content:sub(s, s))
    end
    pos = s + 1
  end
end

local function is_alnum(char, options)
  if not char or char == "" then return false end
  local p = options.word_components or "[A-Za-z0-9_]"
  return char:match(p) ~= nil
end
-- Enhancement in v2.1.1
  -- If you want to set a certain character like "\" 
  -- as a word component, use map-op:
  -- 
  --  options = { 
  --    word_components = "[A-Za-z0-9_\\]"   
  --  }

local function find_closing_delimiter(line, stop_char, start_pos, escape_char)
  local search_pos = start_pos
  while true do
    local s = line:find(stop_char, search_pos, true)
    if not s then return nil end 

    local is_escaped = false
    if escape_char and s > 1 and line:sub(s-1, s-1) == escape_char then
      local count = 0
      local p = s - 1
      while p >= 1 and line:sub(p, p) == escape_char do
        count = count + 1
        p = p - 1
      end
      if count % 2 == 1 then is_escaped = true end
    end

    if not is_escaped then 
      return s
    end
    
    search_pos = s + 1
  end
end


local function parse_inside_delim(content, forced_tokens, base_color)
  -- Only used in "delim" part

  if not forced_tokens or type(forced_tokens) ~= "table" or not next(forced_tokens) then
    return {{ type = "delim", content = content, color = base_color }}
  end

  local res = {}
  local pos = 1
  
  local keys = {}
  for k in pairs(forced_tokens) do table.insert(keys, k) end

  while pos <= #content do
    local nearest_s, nearest_e, found_k
    
    for _, k in ipairs(keys) do
      local s, e = content:find(k, pos, true)
      if s and (not nearest_s or s < nearest_s) then
        nearest_s, nearest_e, found_k = s, e, k
      end
    end

    if nearest_s then
      if nearest_s > pos then
        table.insert(res, { type = "delim_plain", content = content:sub(pos, nearest_s - 1), color = base_color })
      end
      -- forced
      table.insert(res, { type = "token_delim", content = found_k, color = forced_tokens[found_k] })
      pos = nearest_e + 1
    else
      -- plain
      table.insert(res, { type = "delim_plain", content = content:sub(pos), color = base_color })
      break
    end
  end
  
  return res
end

function KKV.cut_multiple_tokens(line, targets, options)
  local use_boundary = true
  if options and options.word_boundary ~= nil then
    use_boundary = options.word_boundary
  end
  
  local delims = (options and options.delimiters) or {}
    -- unique delimiters
  local forced = (options and options.forced_tokens) or {}
    -- forced to change color even in delims

  local escape_char = (options and options.escape_char) or "\\"
    -- Currently, the default escaper is "\".
    -- Is this a proper choice...?

  local parts = {}
  local pos = 1
  
  while pos <= #line do
    local nearest_s = nil
    local nearest_e = nil
    local found_token = nil
    local found_delim_color = nil

    for _, d in ipairs(delims) do
      local s, _ = line:find(d.start, pos, true)
      if s and (not nearest_s or s < nearest_s) then
        local e = find_closing_delimiter(line, d.stop, s + #d.start, escape_char)
        if e then
          nearest_s = s
          nearest_e = e + #d.stop - 1
          found_token = nil 
          found_delim_color = d.color
        end
      end
    end
    -- Enhancement in v2.1.2
    -- To make sure that keywords in "delim" category are color-changed, 
    -- I have to re-scan the delim part like this:
    -- 1. When the system find a demil-pair, insert every text between them into "raw_delim" category.
    -- 2. Serch for forced_keywords in the "raw_delim".
    -- * forced_keywords means that this is completely different from the normal keywords and need to be set
    --   in differnt part.
    -- 3. Cut "raw_delim" like this:
    --    $a + b = \frac{x}{y}$ (in this case, forced_keywords are "{" and "}".)
    --    a + b = \frac → "plain_delim" colored by d_color 
    --    { → "token_delim" colored by t_d_color
    
    for _, token in ipairs(targets) do
      local s, e = line:find(token, pos, true)
      if s then
        local is_valid = true 
        
        if use_boundary then
          -- Get the previous and next characters.
          local prev_char = s > 1 and line:sub(s-1, s-1) or nil
          local next_char = e < #line and line:sub(e+1, e+1) or nil
          
          -- Check the tokens.
          if is_alnum(token:sub(1, 1), options) and is_alnum(prev_char, options) then
            is_valid = false
          end
          if is_alnum(token:sub(-1, -1), options) and is_alnum(next_char, options) then
            is_valid = false
          end
        end

        if is_valid and (not nearest_s or s < nearest_s) then
          nearest_s = s
          nearest_e = e
          found_token = token
          found_delim_color = nil
        end
      end
    end
    
    if nearest_s then
      if nearest_s > pos then
        table.insert(parts, { type = "plain", content = line:sub(pos, nearest_s - 1) })
      end
      if found_delim_color then
        -- table.insert(parts, { type = "delim", content = line:sub(nearest_s, nearest_e), color = found_delim_color })

        -- Enhancement in v2.1.2
        -- Re-process "delim" part in order to search for forced_tokens.
        local delim_raw = line:sub(nearest_s, nearest_e)
        local sub_parts = parse_inside_delim(delim_raw, forced, found_delim_color)
        for _, sp in ipairs(sub_parts) do
          table.insert(parts, sp)
        end
      else
        table.insert(parts, { type = "token", content = found_token })
      end
      pos = nearest_e + 1
    else
      local rest = line:sub(pos)
      if #rest > 0 then
        table.insert(parts, { type = "plain", content = rest })
      end
      break
    end
  end
  return parts
end

function KKV.output_with_multiple_colors(line, color_map, allow_comments)
  local options = (type(color_map) == "table" and color_map.options) or {}
  local actual_map = color_map.map or color_map
  local code_part = line     
  local comment_part = ""     
  
  local all_targets = {}
  local token_to_color = {} 

  if allow_comments and options.comment_char then
    local c_char = options.comment_char
    local e_char = options.escape_char or "\\" 
    
    local search_pos = 1
    while true do
      local s = line:find(c_char, search_pos, true)
      if not s then break end 
      
      local is_escaped = false
      if s > 1 and line:sub(s-1, s-1) == e_char then
        local count = 0
        local p = s - 1
        while p >= 1 and line:sub(p, p) == e_char do
          count = count + 1
          p = p - 1
        end
        if count % 2 == 1 then is_escaped = true end
      end
      
      if not is_escaped then
        code_part = line:sub(1, s-1)
        comment_part = line:sub(s)
        break
      else
        search_pos = s + 1
      end
    end
  end

  for color, targets in pairs(actual_map) do
    for _, t in ipairs(targets) do
      table.insert(all_targets, t)
      token_to_color[t] = color
    end
  end

  local parts = KKV.cut_multiple_tokens(code_part, all_targets, options)

  for _, p in ipairs(parts) do
    if p.type == "token" then
      local t_color = token_to_color[p.content] or "black"
      tex.sprint("\\textcolor{" .. t_color .. "}{")
      sprint_tex_mapped(p.content)
      tex.sprint("}")
    -- elseif p.type == "delim" then
    --   local d_color = p.color or "black"
    --   tex.sprint("\\textcolor{" .. d_color .. "}{")
    --   tex.sprint(-2, p.content)
    --   tex.sprint("}")

    -- Enhancement in v2.1.2
    elseif p.type == "delim" or p.type == "delim_plain" then
      local d_color = p.color or "black"
      tex.sprint("\\textcolor{" .. d_color .. "}{")
      sprint_tex_mapped(p.content)
      tex.sprint("}")

    elseif p.type == "token_delim" then
      local td_color = p.color or "black"
      tex.sprint("\\textcolor{" .. td_color .. "}{")
      sprint_tex_mapped(p.content)
      tex.sprint("}")
    else
      sprint_tex_mapped(p.content)
    end
  end

  if comment_part ~= "" then
    local c_color = options.comment_color or "gray"
    tex.sprint("\\textcolor{" .. c_color .. "}{")
    sprint_tex_mapped(comment_part)
    tex.sprint("}")
  end
end
----------


----- preset -----
KKV.presets = KKV.presets or {}

function KKV.set_preset(name, map)
  KKV.presets[name] = map
end

function KKV.load_preset(name)
  if KKV.presets[name] then
    KKV.active_map = KKV.presets[name]
    print("KKV: Preset [" .. name .. "] applied.")
  else
    print("KKV: Warning - Preset [" .. name .. "] not found.")
  end
end
----------

_G.KKLuaVerb = KKV