-- 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 whatsit_id = node.id('whatsit')
local node_write = node.write
local tail = node.tail
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 mark_attr = luatexbase and luatexbase.new_attribute('lualineno_mark') or 1
-- Labels are carried by a user defined whatsit (see the labels section).
-- A real allocator is used when available, otherwise the id defaults to 0;
-- it can be reassigned with the `whatsitid` key, like the attributes.
local label_whatsit = luatexbase and luatexbase.new_whatsit
    and luatexbase.new_whatsit('lualineno') or 0
local user_defined_sub = node.subtype("user_defined")
local base_whatsit = node.new('whatsit', user_defined_sub)
base_whatsit.user_id = label_whatsit
base_whatsit.type = string.byte('l')

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_list` 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, in which case `true` is returned.
-- Otherwise the line is just a wrapper around boxes, so we collect every
-- `vlist` reachable through box-only chains into `found` (each as a
-- `{node, hoffset, voffset}` triple) and return that list, so the caller can
-- recurse into all of them. We can't assume there is only a single box to
-- recurse into: an `hbox` can hold several numbering targets, either stacked
-- vertically (pgf stacks the title and the body of a tcolorbox with
-- `\raise`/`\lower`) or side by side (the columns of `multicol`, `paracol`,
-- two-column layouts, ...).
--
-- For each box we record both its horizontal offset (for placing the number)
-- and its vertical offset `voffset`. Inside an `hlist` the `shift` field is a
-- {\em vertical} displacement, so we accumulate it on every hop. The caller uses
-- `voffset` to tell columns (boxes side by side, sharing a `voffset`) from a
-- vertical stack (boxes at distinct `voffset`s).
--
-- The one structure where the boxes of an `hbox` form a {\em single} line that
-- should be numbered once is the cells of a table row. Those are told apart
-- by the caller using the subtype of the line (`alignment`), not here.

local function real_line(list, parent, offset, voffset, found)
    found = found or {}
    voffset = voffset or 0
    for n, id, sb in traverse(list) do
        if id == glyph_id then

-- A leading glyph means this really is a text line, so number it as a whole.
-- A glyph encountered after we already collected boxes is just part of the
-- wrapper and is ignored.

            if #found == 0 then return true end
        elseif id == vlist_id and not ignored_subtypes[sb] and real_box(n.list) then
            found[#found+1] = {n, offset + rangedimensions(parent, list, n), voffset + n.shift}
        elseif id == hlist_id and not ignored_subtypes[sb] and real_box(n.list) then
            if real_line(n.list, n, offset + rangedimensions(parent, list, n),
                         voffset + n.shift, found) == true then
                return true
            end
        end
    end
    return found
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 box marked with the `anchor` key is found
-- 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
            if get_attribute(n, mark_attr) == lineno_marks.anchor 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, 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, but 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` did not return `true`, the line is a wrapper around boxes,
-- so we need to find lines inside of each collected box as well. As before
-- offset and width might need to be updated.
--
-- The boxes of a table row are the exception: they are the cells of a single
-- line and must be numbered once, so for an alignment we only keep the first
-- box (this is what `real_line` used to return before it learned to collect
-- several boxes).
--
-- Otherwise we look at the vertical offsets of the collected boxes: boxes
-- sharing a `voffset` sit side by side and are columns, so each is processed
-- relative to its own left edge and width, and gets a column number assigned
-- in the order they appear (\LuaTeX/ keeps the columns in logical order).
-- Boxes alone at their `voffset` are a vertical stack (or just a single box)
-- and keep the inherited offset, width and column.

        local found = real_line(n.head, n, offset)
        if found ~= true then
            local last = sb == align_sub and math.min(#found, 1) or #found

-- A `voffset` shared by more than one box marks a row of columns; we count
-- the boxes per `voffset` to recognize them and number them within the row.

            local per_voffset = {}
            for i = 1, last do
                local v = found[i][3]
                per_voffset[v] = (per_voffset[v] or 0) + 1
            end
            local col_number = {}
            for i = 1, last do
                local box = found[i]
                local m, v = box[1], box[3]
                local new_offset, new_width, new_col = box[2], width
                if per_voffset[v] > 1 then
                    col_number[v] = (col_number[v] or 0) + 1
                    new_col = col_number[v]
                    new_offset, new_width = 0, m.width
                elseif get_attribute(m, mark_attr) == lineno_marks.anchor 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)
            end
            goto continue
        end

        if not 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

-- A label is attached by inserting a `user_defined` whatsit carrying the
-- label tokens as a lua table at the point of the `\lualineno{label=...}`
-- call.

local make_label
local function find_label(line)
    local head = line.list
    for n, id, sb in traverse(head) do
        if n.list then
            find_label(n)
        elseif id == whatsit_id and sb == user_defined_sub and n.user_id == label_whatsit then
            make_label(n.value, head, n)
        end
    end
end

-- The carrier stores the label tokens directly in the whatsit `value` field
-- (a lua table, type `l`). On its first use the function registers
-- `find_label` with the numbering callback and then redefines itself to the
-- bare inserter.

local add_label
add_label = function(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
    add_label = function(toks)
        local w = node_copy(base_whatsit)
        w.value = toks
        w.attr = node.current_attr()
        node_write(w)
    end
    return add_label(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},
    markattr = {scanner = scan_int},
    whatsitid = {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
        add_label(vals.label)
    end
    type_attr = vals.typeattr or type_attr
    mark_attr = vals.markattr or mark_attr
    label_whatsit = vals.whatsitid or label_whatsit
    if vals.processbox then
        local box = tex.box[vals.processbox]
        find_line(box, box.head, 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')

-- \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

-- 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 = %d]],
                                   attr_num,lineno_marks.anchor)
    local find = [[\moveright \@themargin \vbox]]
    local patch, num_subs = token.get_macro("@outputpage"):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
