-- lapp.lua
-- Simple command-line parsing using human-readable specification
-----------------------------
--~ -- 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
--------------------------------

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 help()
 print(usage)
 os.exit()
end

local function version()
 return {version = true}
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

local function open (file,opt)
   local val,err = io.open(file,opt)
   if not val then error(err,true) 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
               help()
           end
           if parm == "v" or parm == "version" then
             return version()
           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