-- lualineno version = 0.1, 2025-11-28

-- \seccc Initialization^^M
-- Some declarations of local functions/constants of global ones
-- to avoid table lookups.

local runtoks = tex.runtoks
local put_next = token.unchecked_put_next
local create = token.create
local new_tok = token.new
local lbrace = new_tok(string.byte('{'), token.command_id'left_brace')
local rbrace = new_tok(string.byte('}'), token.command_id'right_brace')
local b = new_tok(string.byte('b'), token.command_id'other_char')
local d = new_tok(string.byte('d'), token.command_id'other_char')
local i = new_tok(string.byte('i'), token.command_id'other_char')
local r = new_tok(string.byte('r'), token.command_id'other_char')
local zero = new_tok(string.byte('0'), token.command_id'other_char')
local get_next = token.get_next
local scan_toks = token.scan_toks
local scan_string = token.scan_string
local scan_list = token.scan_list
local scan_int = token.scan_int

-- sadly there isn't a nice way in \LuaTeX/ to get 
-- a primitive token without using a csname.
-- To be sure `\hbox` has the correct meaning
-- we can use `tex.enableprimitives` to create
-- a new csname with the meaning of the primitive,
-- then create a token with the same `.mode` and `.command`
-- fields so we won't need the csname anymore.
-- All of this is to avoid to use some implementation details
-- (`local hbox = new_tok(141, 21)`)

local hbox do
  local prefix = '@lua^line&no_'
  while token.is_defined(prefix .. 'hbox') do
    prefix = prefix .. '@lua^line&no_'
  end
  tex.enableprimitives(prefix,{'hbox'})
  local tok = create(prefix .. 'hbox')
  hbox  = new_tok(tok.mode, tok.command)
end

local hlist_id = node.id('hlist')
local vlist_id = node.id('vlist')
local glyph_id = node.id('glyph')
local tail = node.tail
local get_props = node.getproperty
local set_props = node.setproperty
local get_attribute = node.get_attribute
local set_attribute = node.set_attribute
local node_flush = node.flush_node
local insert_before = node.insert_before
local insert_after = node.insert_after
local traverse = node.traverse
local rangedimensions = node.rangedimensions
local node_copy = node.copy
local base_kern = node.new('kern', 'user')
local line_sub, eq_sub, align_sub, box_sub
local ignored_subtypes = {}
for k,v in pairs(node.subtypes("hlist")) do
    if v == "line" then line_sub = k end
    if v == "alignment" then align_sub = k end
    if v == "box" then box_sub = k end
    if v == "equation" then eq_sub = k end
    if v == "equationnumber" then ignored_subtypes[k] = true end
    if v == "mathchar" then ignored_subtypes[k] = true end
end
local displayalign_sub = #node.subtypes("hlist") + 1
for k,v in pairs(node.subtypes("vlist")) do
    if v == "vextensible" then ignored_subtypes[k] = true end
    if v == "vdelimiter" then ignored_subtypes[k] = true end
end

local setattribute = tex.setattribute
local texerror = tex.error
local texnest = tex.nest
local format = tex.formatname

-- The module currently works with
-- \OpTeX, \LaTeX/ or Plain.

local optex, latex, plain
if format:find("optex") then
    optex = true
elseif format:find("latex") then
    latex = true
elseif format == "luatex" or
       format == "luahbtex" or
       format:find("plain")
then
    plain = true
end
if not (optex or latex or plain) then
    error("lualineno: The format " .. format .. " is not supported\n\n" ..
             "Use OpTeX, LuaLaTeX or Plain.")
end

local lineno_types = { }
local lineno_attrs = { }
local LINENO_NUMBER = 0x1
local LINENO_RECURSE = 0x2
local lineno_marks = {
    anchor = 1,
    processed = 2,
    display = 3,
}
local type_attr = luatexbase and luatexbase.new_attribute('lualineno_type') or 0
local col_attr = luatexbase and luatexbase.new_attribute('lualineno_col') or 1
local mark_attr = luatexbase and luatexbase.new_attribute('lualineno_mark') or 2
local unset_attr = -0x7FFFFFFF

-- We use the `luakeyval` module for the user interface

local keyval = require('luakeyval')
local scan_choice = keyval.choices
local scan_bool = keyval.bool
local process_keys = keyval.process
local messages = {
    error1 = "lualineno: Wrong syntax in \\lualineno",
    value_forbidden = 'lualineno: The key "%s" does not accept a value',
    value_required = 'lualineno: The key "%s" requires a value',
}

-- \seccc Numbering Lines^^M
-- In here we define the main functions of the module,
-- the functions that find and number the lines in a page.
--
-- The following function is used to number a line that is considered
-- \"real" (i.e. has some glyphs in it that are not equation number or 
-- a big math delimiter). This function is used in the `lualineno.numbering`
-- callback, so it can be replaced if desired.
--
-- `line` is the hlist node representing the line, `line_type` is a lua table
-- with the parameters defined in the define key according to the attribute
-- and the column, `offset` is the total shift calculated from the start of
-- the line and `width` is the width of the column containing the lines.

local function number_line(line, line_type, offset, width, dir)

   local head = line.head
   local is_offset = line_type['offset']
   
-- In case \LaTeX/ is used without the luacolor package,
-- we add an additional group to make the boxes color safe.
-- Since `token.scan_string` is ran in horizontal mode, the 
-- default box direction is `\textdirection`, and this can be
-- pretty random at shipout time, so an explicit direction is specified.
-- Currently LTR is used, if someone ask an option can be added, but
-- you can always use `\hbox bidr 1 {}` inside the box.

    put_next({rbrace, rbrace})
    put_next(line_type.left)
    put_next({hbox,b,d,i,r,zero,lbrace,lbrace})
    put_next({rbrace, rbrace})
    put_next(line_type.right)
    put_next({hbox,b,d,i,r,zero,lbrace,lbrace})
    
-- to make sure \"right" always means right, we check the line direction.

    local end_box, start_box  
    if line.dir == "TLT" then
        end_box = scan_list()
        start_box = scan_list()
    else
        start_box = scan_list()
        end_box = scan_list()
    end
    
-- if the vertical list containing the line and the line has different directions
-- we need to mirror the kerns as the kern means opposite direction then the shift,
-- or alignment (similar to how `\shapemode` is working). 
    
    local start_kern_width = 0
    local end_kern_width = 0
    if is_offset then 
        if line.dir == dir then
            start_kern_width = offset
            end_kern_width = width - line.width - offset
        else
            start_kern_width = width - line.width - offset
            end_kern_width = offset
        end
    end
    
    if start_box.head then
        if start_kern_width ~= 0 then
            local start_kern = node_copy(base_kern)
            start_kern.kern = start_kern_width
            head = insert_before(head,head,start_kern)
        end
        head = insert_before(head,head,start_box)
        start_kern_width = -start_box.width - start_kern_width
        if start_kern_width ~= 0 then
            local start_kern = node_copy(base_kern)
            start_kern.kern = start_kern_width
            head = insert_before(head,head,start_kern)
        end
    else
        node_flush(start_box)
    end
    if end_box.head then
        if end_kern_width ~= 0 then
            local end_kern = node_copy(base_kern)
            end_kern.kern = end_kern_width
            head = insert_after(head,tail(head),end_kern)
        end
        head = insert_after(head,tail(head),end_box)
    else
        node_flush(end_box)
    end
    line.head = head
end

local lineno_callbacks
if luatexbase then
    luatexbase.create_callback('lualineno.pre_numbering', 'simple', false)
    luatexbase.create_callback('lualineno.numbering', 'exclusive', number_line)
    luatexbase.create_callback('lualineno.post_numbering', 'simple', false)
    luatexbase.add_to_callback('lualineno.pre_numbering', function(_, line_type)
        runtoks(function() put_next(line_type['toks']) end) 
    end, 'lualineno.runtoks')
    local call_callback = luatexbase.call_callback
    lineno_callbacks = function(line, line_type, offset, width, dir)
        call_callback('lualineno.pre_numbering', line, line_type, offset, width, dir)
        call_callback('lualineno.numbering', line, line_type, offset, width, dir)
        call_callback('lualineno.post_numbering', line, line_type, offset, width, dir)
    end
else
    lineno_callbacks = function(line, line_type, offset, width, dir)
        runtoks(function() put_next(line_type['toks']) end)
        number_line(line, line_type, offset, width, dir)
    end
end

-- Not every object that would be considered a line from \LuaTeX's point of view
-- would be considered a line from a human perspective. For example, a line
-- containing only an indent box, or an alignment containing only rules,
-- so we use the following two functions to search for a glyph node recursively,
-- while ignoring boxes
-- for equation number, for big delimiters (i.e. in `cases` environment)
-- or dummy boxes for null delimiters. 

local function real_box(list)
    for n, id, sb in traverse(list) do
        if id == glyph_id then
            return true
        elseif (id == hlist_id or id == vlist_id) and not ignored_subtypes[sb] then
            if real_box(n.list) then
                return true
            end
        end
    end
    return false
end

-- If the first thing (that we care about) in a line is a glyph
-- we simply number it, if it is an hlist we keep looking inside
-- for glyphs and if it is a vlist we add the shift to the offset
-- and go back to finding lines in that vlist.

local function real_line(list, parent, offset)
    for n, id, sb in traverse(list) do
        if id == glyph_id then
            return true
        elseif id == vlist_id and not ignored_subtypes[sb] and real_box(n.list) then
            return n, offset + rangedimensions(parent, list, n)
        elseif id == hlist_id and not ignored_subtypes[sb] and real_box(n.list) then
           offset = offset + rangedimensions(parent, list, n)
           return real_line(n.list, n, offset)
        end
    end
    return false
end

-- This function finds the lines that needs to be numbered in a page.
-- It should be used right before shipout, but can be used on individual
-- boxes using the `processbox` key if needed (maybe special numbering order
-- is desired). 
-- When a line found, `lineno_callbacks` is called to number it.
local find_line
find_line = function(parent, list, column, offset, width)

    if get_attribute(parent, mark_attr) == lineno_marks.processed then return end
    set_attribute(parent, mark_attr, lineno_marks.processed)
    
-- We need to keep track of the parent id to know if the `.shift`
-- field represent horizontal or vertical displacement.

    local parent_is_vlist = parent.id == vlist_id
    for n, id, sb in traverse(list) do
    
-- lines are `hlist`s, so if a node is not one we dig deeper,
-- while calculating the offset and the width.
-- If a column is found, or a box marked with the `anchor` key
-- then the offset is reset and the width is updated.
 
        if id ~= hlist_id then
            if not n.list then goto continue end
            local new_offset, new_width = offset, width
            local new_col = get_attribute(n, col_attr)
            if get_attribute(n, mark_attr) == lineno_marks.anchor or new_col then
                new_offset, new_width = 0, n.width
            elseif parent_is_vlist then
                new_offset = new_offset + n.shift
            end
            find_line(n, n.list, new_col or column, new_offset, new_width)
            goto continue
        end
        
-- A line type is determined by the attribute of its last node so that line types can be switched
-- from within the line (but maybe this should be configurable).
-- The flag is a bitset that determines whether to number or recurse further.

        local line_attr = n.head and get_attribute(tail(n.head), type_attr)
        local line_type = line_attr and lineno_types[line_attr] and lineno_types[line_attr][column]
        local flag
        if sb == align_sub and get_attribute(n,mark_attr) == lineno_marks.display then
            flag = line_type and line_type[displayalign_sub]
        else
            flag = line_type and line_type[sb]
        end

-- If a line does not have any attribute we don't number it, be we do recurse further.

        local should_number = flag and (flag & LINENO_NUMBER) ~= 0 or false
        local should_recurse = flag and (flag & LINENO_RECURSE) ~= 0 or true 
        if not (should_number or should_recurse) then
            goto continue
        end
        
-- This is the case where a line should be numbered only once.
-- Maybe someone would like to number alignment once, regardless
-- of the fact the first column contains cells with paragraphs.        

        if should_number and not should_recurse then
            if real_box(n.list) then
                local new_offset = parent_is_vlist and (offset + n.shift) or offset
                lineno_callbacks(n, line_type, new_offset, width, parent.dir)
            end
            goto continue
        end
        
-- If `real_line` returned a `new_offset`, the first thing encountered in the line is a `vlist`,
-- so we need to find lines inside of that `vlist` as well. As before offset and width might need
-- to be updated. If we encounter a column, maybe this line contains more columns, so we number the
-- first one and keep looking for more.
        
        local m, new_offset = real_line(n.head, n, offset)
        local new_width = width
        if new_offset then
            local new_col = get_attribute(m, col_attr)
            if get_attribute(m, mark_attr) == lineno_marks.anchor or new_col then
                new_offset, new_width = 0, m.width
            elseif parent_is_vlist then
                new_offset = new_offset + n.shift
            end
            find_line(m, m.head, new_col or column, new_offset, new_width)
            if new_col then
                find_line(n, n.head, new_col, new_offset, width)
            end
            goto continue
        end
        
        if not (m and should_number) then goto continue end
        
-- A line is found! update the offset and number it.
        
        local new_offset = parent_is_vlist and (offset + n.shift) or offset        
        lineno_callbacks(n, line_type, new_offset, width, parent.dir)
        
        ::continue::
    end
end

if not plain then
    luatexbase.add_to_callback('pre_shipout_filter', function(box)
        find_line(box, box.list, 1, 0, box.width)
        return true
    end, 'lualineno.shipout')
end

-- Check if inside display for display alignment

if luatexbase then 
    luatexbase.add_to_callback("buildpage_filter", function(info)
        if info == "before_display" then 
            setattribute(mark_attr,lineno_marks.display)
        elseif info == "after_display" then
            setattribute(mark_attr,unset_attr)
        end
    end, "lualineno.indisplay")
end

-- \seccc Anchoring numbers to a box^^M

local function mark_last_vlist(n)
    local current = n
    while current do
        if current.id == vlist_id then
            set_attribute(current, mark_attr, lineno_marks.anchor)
            return true
        elseif current.id == hlist_id then
            if mark_last_vlist(tail(current.list)) then return true end
        end
        current = current.prev
    end
    return false
end

-- \seccc labels^^M

local make_label
local function find_label(line)
    local list = line.list
    for n in traverse(list) do
        if n.id == glyph_id then
            local props = get_props(n)
            if props then
                local label = props.lualineno
                if label then
                    make_label(label, list, n)
                end 
            end
        elseif n.list then
            find_label(n)
        end
    end
end

local function label_last_glyph(m, tokens)
    if optex then
        luatexbase.add_to_callback('lualineno.pre_numbering', find_label, 'lualineno.labels')
    elseif latex then
        luatexbase.add_to_callback('lualineno.post_numbering', find_label, 'lualineno.labels')
    end
    label_last_glyph = function(n, toks)
        local current = n
        while current do
            if current.id == glyph_id then
                local props = get_props(current)
                if not props then
                    props = { }
                    set_props(current,props)
                end
                props.lualineno = toks
                return true
            elseif current.list then
                if label_last_glyph(tail(current.list), toks) then 
                    return true 
                end
            end
            current = current.prev
        end
        return false
    end
    return label_last_glyph(m, tokens)
end

-- \seccc User Interface^^M
-- This section describes the definition of
-- the one macro exposed to the end user.
-- It is based on the luakeyval module.

local defaults = {
    toks = { },
    left = { },
    right = { },
    box = {number = true, recurse = true},
    alignment = {number = true, recurse = true},
    displayalignment = {number = true, recurse = true},
    equation = {number = true, recurse = true},
    line = {number = true, recurse = true},
    offset = true,
    column = 1,
}

local inner_keys = {
    number = {scanner = scan_bool, default = true},
    recurse = {scanner = scan_bool, default = true}
}

local defaults_keys = {
    toks = {scanner = scan_toks},
    left = {scanner = scan_toks},
    right = {scanner = scan_toks},
    box = {scanner = process_keys, args = {inner_keys, messages}},
    alignment = {scanner = process_keys, args = {inner_keys, messages}},
    displayalignment = {scanner = process_keys, args = {inner_keys, messages}},
    equation = {scanner = process_keys, args = {inner_keys, messages}},
    line = {scanner = process_keys, args = {inner_keys, messages}},
    offset = {scanner = scan_bool},
    column = {scanner = scan_int}
}

local function set_defaults()
    local vals = process_keys(defaults_keys,messages)
    for k,v in pairs(vals) do
        defaults[k] = v
    end
end

local define_keys = { }
for k,v in pairs(defaults_keys) do
    define_keys[k] = v
end
define_keys.name = {scanner = scan_string}

local function define_lineno()
    local vals = process_keys(define_keys, messages)
    local name = vals['name']
    if not name then 
        texerror("lualineno: Missing name when defining a lineno")
        return
    end
    
    local col = vals['column'] or defaults.column
    lineno_attrs[name] = lineno_attrs[name] or #lineno_types + 1
    local i = lineno_attrs[name]
    lineno_types[i] = lineno_types[i] or {}
    lineno_types[i][col] = lineno_types[i][col] or {}
    
    local c = lineno_types[i][col]
    
    local function store_type(key, subtype_id)
        local setting = vals[key] or defaults[key]
        local flags = 0
        if setting.number then flags = flags | LINENO_NUMBER end
        if setting.recurse then flags = flags | LINENO_RECURSE end
        c[subtype_id] = flags
    end
    
    store_type('box', box_sub)
    store_type('alignment', align_sub)
    store_type('equation', eq_sub)
    store_type('line', line_sub)
    store_type('displayalignment', displayalign_sub)
    
    c.toks = vals.toks or defaults.toks
    c.left = vals.left or defaults.left
    c.right = vals.right or defaults.right
    if vals.offset ~= nil then
        c.offset = vals.offset
    else
        c.offset = defaults.offset
    end
end

local lualineno_keys = {
    set = {scanner = scan_string},
    unset = { default = true },
    define = {scanner = function() return true end, func = define_lineno},
    defaults = {scanner = function() return true end, func = set_defaults},
    anchor = { default = true },
    label = {scanner = scan_toks, args = {false, true}},
    typeattr = {scanner = scan_int},
    colattr = {scanner = scan_int},
    markattr = {scanner = scan_int},
    processbox = {scanner = scan_int},
}

local function lualineno()
    local saved_endlinechar = tex.endlinechar
    tex.endlinechar = 32
    local vals = process_keys(lualineno_keys,messages)
    tex.endlinechar = saved_endlinechar
    if vals.set then
        local attr = lineno_attrs[vals.set]
        if attr then
          setattribute(type_attr, attr)
        else
          texerror("lualineno: type '" .. vals.set .. "' undefined")
        end
    end
    if vals.unset then
        setattribute(type_attr, unset_attr)
    end
    if vals.anchor then
        for i=texnest.ptr,0,-1 do 
            if mark_last_vlist(texnest[i].tail) then return end
        end
    end
    if vals.label then
        for i=texnest.ptr,0,-1 do 
            if label_last_glyph(texnest[i].tail, vals.label) then return end
        end
    end
    type_attr = vals.typeattr or type_attr
    col_attr = vals.colattr or col_attr
    mark_attr = vals.markattr or mark_attr
    if vals.processbox then 
        local box = tex.box[vals.processbox]
        local col = get_attribute(box, col_attr)
        find_line(box, box.head, col or 1, 0, box.width)
    end
end

do
  if token.is_defined('lualineno') then
      texio.write_nl('log', "lualineno: redefining \\lualineno")
  end
  local function_table = lua.get_functions_table()
  local luafnalloc = luatexbase and luatexbase.new_luafunction 
    and luatexbase.new_luafunction('lualineno') or #function_table + 1
  token.set_lua('lualineno', luafnalloc, 'protected')
  function_table[luafnalloc] = lualineno
end

-- \seccc Format Specific Code^^M

if format == 'optex' then

-- To be able to use \OpTeX/'s color mechanism in line numbers the colorizing
-- needs to happen after line numbers are added, so we remove and insert
-- back again the colorizing function from the `pre_shipout_filter` callback.

    local colorize = callback.remove_from_callback('pre_shipout_filter', '_colors')
    callback.add_to_callback('pre_shipout_filter', colorize, '_colors')

-- This is the patch for `\beginmulti` in order mark the columns boxes.
-- For each box we assign an attribute with a value
-- according to the column number.

    local replace = table.concat({
      "\\_directlua{",
      "local column = tex.splitbox(6, tex.dimen[1], 'exactly') ",
      "local num = tex.count['_tmpnum'] ",
      "local attr = luatexbase.attributes['lualineno_col'] ",
      "node.set_attribute(column, attr, num) ",
      "node.write(column) ",
      "}",
    })
    local find = [[\_vsplit 6 to\_dimen 1 ]]
    local patch, success = token.get_macro("_createcolumns"):gsub(find, replace)
    
-- Log the success or failure of the patch

    if success > 0 then
        token.set_macro("_createcolumns", patch)
    else
        texio.write_nl('log', "lualineno: failed to patch \\_createcolumns")
    end

-- \OpTeX/ only needs to run `\label[toks]` before a destination to label it.

    local lbracket = new_tok(string.byte('['), token.command_id'other_char')
    local rbracket = new_tok(string.byte(']'), token.command_id'other_char')
    local label_tok = create('_label')
    make_label = function(label)
        runtoks(function()
            put_next({rbracket})
            put_next(label)
            put_next({label_tok,lbracket})
        end)
    end
    
elseif latex then

-- Here we mark the columns according to `\if@firstcolumn`
    
    local true_mode do
      local prefix = '@lua^line&no_'
      while token.is_defined(prefix .. 'iftrue') do
        prefix = prefix .. '@lua^line&no_'
      end
      tex.enableprimitives(prefix,{'iftrue'})
      local tok = create(prefix .. 'iftrue')
      true_mode  = tok.mode
    end


    luatexbase.add_to_callback('pre_output_filter', function()
        if create('if@firstcolumn').mode == true_mode then
           setattribute(col_attr, 1)
        else
           setattribute(col_attr, 2)
        end
        return true
    end, 'lualineno.mark_columns')
    luatexbase.add_to_callback('buildpage_filter', function(info)
        if info == 'after_output' then
            setattribute(col_attr, unset_attr)
        end 
    end, 'lualineno.mark_columns')
    
-- If the luacolor package is loaded,
-- colorizing must happen after line numbers
-- are added to be able to color them.

    luatexbase.declare_callback_rule('pre_shipout_filter', 
         'lualineno.shipout', 'before', 'luacolor.process')

-- Since \LaTeX/ isn't really shipping out the page box, but a box
-- containing the `\topmargin` and the page box which is shifted with
-- `\moveright`, so it adds an undesired offset in `find_line`, so
-- we mark the page box as `anchor`.

    local attr_num = luatexbase.attributes['lualineno_mark']
    local replace = string.format([[\moveright \@themargin \vbox attr %d = -1]], attr_num)
    local find = [[\moveright \@themargin \vbox]]
    local patch, num_subs = token.get_macro("@outputpage"):gsub(find, replace)

-- space controls are changed as well to avoid a bug in `token.set_macro`

    find = [[\catcode `\ 10\relax \catcode `\	10\relax]]
    replace = [[\catcode 32=10\relax \catcode 9=10\relax]]
    patch, num_subs = patch:gsub(find, replace)

-- Log the success or failure of the patch

    if num_subs > 0 then
        token.set_macro("@outputpage", patch)
    else
        texio.write_nl('log', "lualineno: failed to patch \\@outputpage")
    end

-- \LaTeX/'s `\label`'s creates a whatsit node (`\write`), so we temporarily box the label
-- to fetch this node, and add it to the list.

    local label_tok = create('label')
    make_label = function(label, list, n)
        runtoks(function()
            put_next({rbrace,rbrace})
            put_next(label)
            put_next({hbox, lbrace, label_tok,lbrace})
            local label_node = scan_list()
            list = insert_after(list,n,node_copy(label_node.head))
            node_flush(label_node)
        end)
    end
    
end
