-- lapp.lua
-- Simple command-line parsing using human-readable specification
--
-- $Id: xindex-lapp.lua 2 2025-08-02 11:30:20Z herbert $
-----------------------------
--~ -- args.lua
--~ local args = require ('lapp') [[
--~ Testing parameter handling
--~     -p               Plain flag (defaults to false)
--~     -q,--quiet       Plain flag with GNU-style optional long name
--~     -o  (string)     Required string option
--~     -n  (number)     Required number option
--~     -s (default 1.0) Option that takes a number, but will default
--~     <start> (number) Required number argument
--~     <input> (default stdin)  A parameter which is an input file
--~     <output> (default stdout) One that is an output file
--~ ]]
--~ for k,v in pairs(args) do
--~     print(k,v)
--~ end
-------------------------------
--~ > args -pq -o help -n 2 2.3
--~ input   file (781C1B78)
--~ p       true
--~ s       1
--~ output  file (781C1B98)
--~ quiet   true
--~ start   2.3
--~ o       help
--~ n       2
--------------------------------
if not modules then modules = { } end modules ['xindex-lapp'] = {
      version = 0.31,
      comment = "read parameter for xindex.lua",
       author = "Steve Donovan",
    copyright = "Steve Donovan",
      license = "MIT"
}


lapp = {}

local append = table.insert
local usage
local open_files = {}
local parms = {}
local aliases = {}
local parmlist = {}

local filetypes = {
    stdin = {io.stdin,'file-in'}, stdout = {io.stdout,'file-out'},
    stderr = {io.stderr,'file-out'}
}

local function quit(msg,no_usage)
    if msg then
        io.stderr:write(msg..'\n\n')
    end
    if not no_usage then
        io.stderr:write(usage)
    end
    os.exit(1);
end

local function error(msg,no_usage)
    quit(arg[0]:gsub('.+[\\/]','')..':'..msg,no_usage)
end

local function ltrim(line)
    return line:gsub('^%s*','')
end

local function rtrim(line)
    return line:gsub('%s*$','')
end

local function trim(s)
    return ltrim(rtrim(s))
end

--- open a file.
-- This will quit on error, and keep a list of file objects for later cleanup.
-- @string file filename
-- @string[opt] opt same as second parameter of `io.open`
function open (file,opt)
    local val,err = io.open(file,opt)
    if not val then 
        file = file..".idx"
	val,err = io.open(file,opt)
	if not val then
	    error(err,true) 
	end
    end
    append(open_files,val)
    return val
end

local function xassert(condn,msg)
    if not condn then
        error(msg)
    end
end

local function range_check(x,min,max,parm)
    xassert(min <= x and max >= x,parm..' out of range')
end

local function xtonumber(s)
    local val = tonumber(s)
    if not val then error("unable to convert to number: "..s) end
    return val
end

local function is_filetype(type)
    return type == 'file-in' or type == 'file-out'
end

local types = {}

local function convert_parameter(ps,val)
    if ps.converter then
        val = ps.converter(val)
    end
    if ps.type == 'number' then
        val = xtonumber(val)
    elseif is_filetype(ps.type) then
        val = open(val,(ps.type == 'file-in' and 'r') or 'w' )
    elseif ps.type == 'boolean' then
        val = true
    end
    if ps.constraint then
        ps.constraint(val)
    end
    return val
end

function lapp.add_type (name,converter,constraint)
    types[name] = {converter=converter,constraint=constraint}
end

local function force_short(short)
    xassert(#short==1,short..": short parameters should be one character")
end

function process_options_string(str)
    local res = {}
    local varargs

    local function check_varargs(s)
        local res,cnt = s:gsub('%.%.%.$','')
        varargs = cnt > 0
        return res
    end

    local function set_result(ps,parm,val)
        if not ps.varargs then
            res[parm] = val
        else
            if not res[parm] then
                res[parm] = { val }
            else
                append(res[parm],val)
            end
        end
    end

    usage = str

    for line in str:gmatch('([^\n]*)\n') do
        local optspec,optparm,i1,i2,defval,vtype,constraint
        line = ltrim(line)
        -- flags: either -<short> or -<short>,<long>
        i1,i2,optspec = line:find('^%-(%S+)')
        if i1 then
            optspec = check_varargs(optspec)
            local short,long = optspec:match('([^,]+),(.+)')
            if short then
                optparm = long:sub(3)
                aliases[short] = optparm
                force_short(short)
            else
                optparm = optspec
                force_short(optparm)
            end
        else -- is it <parameter_name>?
            i1,i2,optparm = line:find('(%b<>)')
            if i1 then
                -- so <input file...> becomes input_file ...
                optparm = check_varargs(optparm:sub(2,-2)):gsub('%A','_')
                append(parmlist,optparm)
            end
        end
        if i1 then -- this is not a pure doc line
            local last_i2 = i2
            local sval
            line = ltrim(line:sub(i2+1))
            -- do we have (default <val>) or (<type>)?
            i1,i2,typespec = line:find('^%s*(%b())')
            if i1 then
                typespec = trim(typespec:sub(2,-2)) -- trim the parens and any space
                sval = typespec:match('default%s+(.+)')
                if sval then
                    local val = tonumber(sval)
                    if val then -- we have a number!
                        defval = val
                        vtype = 'number'
                    elseif filetypes[sval] then
                        local ft = filetypes[sval]
                        defval = ft[1]
                        vtype = ft[2]
                    else
                        defval = sval
                        vtype = 'string'
                    end
                else
                    local min,max = typespec:match '([^%.]+)%.%.(.+)'
                    if min then -- it's (min..max)
                        vtype = 'number'
                        min = xtonumber(min)
                        max = xtonumber(max)
                        constraint = function(x)
                            range_check(x,min,max,optparm)
                        end
                    else -- () just contains type of required parameter
                        vtype = typespec
                    end
                end
            else -- must be a plain flag, no extra parameter required
                defval = false
                vtype = 'boolean'
            end
            local ps = {
                type = vtype,
                defval = defval,
                required = defval == nil,
                comment = line:sub((i2 or last_i2)+1) or optparm,
                constraint = constraint,
                varargs = varargs
            }
            if types[vtype] then
                local converter = types[vtype].converter
                if type(converter) == 'string' then
                    ps.type = converter
                else
                    ps.converter = converter
                end
                ps.constraint = types[vtype].constraint
            end
            parms[optparm] = ps
        end
    end
    -- cool, we have our parms, let's parse the command line args
    local iparm = 1
    local iextra = 1
    local i = 1
    local parm,ps,val
    while i <= #arg do
        -- look for a flag, -<short flags> or --<long flag>
        local i1,i2,dash,parmstr = arg[i]:find('^(%-+)(%a.*)')
        if i1 then -- we have a flag
            if #dash == 2 then -- long option
                parm = parmstr
            else -- short option
                if #parmstr == 1 then
                    parm = parmstr
                else -- multiple flags after a '-',?
                    parm = parmstr:sub(1,1)
                    if parmstr:find('^%a%d+') then
                        -- a short option followed by a digit? (exception for AW ;))
                        -- push ahead into the arg array
                        table.insert(arg,i+1,parmstr:sub(2))
                    else
                        -- push multiple flags into the arg array!
                        for k = 2,#parmstr do
                            table.insert(arg,i+k-1,'-'..parmstr:sub(k,k))
                        end
                    end
                end
            end
            if parm == 'h' or parm == 'help' then
                quit()
            end
            if aliases[parm] then parm = aliases[parm] end
            ps = parms[parm]
            if not ps then error("unrecognized parameter: "..parm) end
            if ps.type ~= 'boolean' then -- we need a value! This should follow
                val = arg[i+1]
                i = i + 1
                xassert(val,parm.." was expecting a value")
            end
        else -- a parameter
            parm = parmlist[iparm]
            if not parm then
               -- extra unnamed parameters are indexed starting at 1
               parm = iextra
               iextra = iextra + 1
               ps = { type = 'string' }
            else
                ps = parms[parm]
            end
            if not ps.varargs then
                iparm = iparm + 1
            end
            val = arg[i]
        end
        ps.used = true
        val = convert_parameter(ps,val)
        set_result(ps,parm,val)
        if is_filetype(ps.type) then
            set_result(ps,parm..'_name',arg[i])
        end
        if lapp.callback then
            lapp.callback(parm,arg[i],res)
        end
        i = i + 1
    end
    -- check unused parms, set defaults and check if any required parameters were missed
    for parm,ps in pairs(parms) do
        if not ps.used then
            if ps.required then error("missing required parameter: "..parm) end
            set_result(ps,parm,ps.defval)
        end
    end
    return res
end

setmetatable(lapp, {
    __call = function(tbl,str) return process_options_string(str) end,
    __index = {
        open = open,
        quit = quit,
        error = error,
        assert = xassert,
    }
})

return lapp
