summaryrefslogtreecommitdiff
path: root/macros/luatex/generic/penlight/pl/lapp.lua
blob: 56d1d9d72fad91fee406843e068c3a68138223d6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
--- Simple command-line parsing using human-readable specification.
-- Supports GNU-style parameters.
--
--      lapp = require 'pl.lapp'
--      local args = lapp [[
--      Does some calculations
--        -o,--offset (default 0.0)  Offset to add to scaled number
--        -s,--scale  (number)  Scaling factor
--        <number> (number) Number to be scaled
--      ]]
--
--      print(args.offset + args.scale * args.number)
--
-- Lines beginning with `'-'` are flags; there may be a short and a long name;
-- lines beginning with `'<var>'` are arguments.  Anything in parens after
-- the flag/argument is either a default, a type name or a range constraint.
--
-- See @{08-additional.md.Command_line_Programs_with_Lapp|the Guide}
--
-- Dependencies: `pl.sip`
-- @module pl.lapp

local status,sip = pcall(require,'pl.sip')
if not status then
    sip = require 'sip'
end
local match = sip.match_at_start
local append,tinsert = table.insert,table.insert

sip.custom_pattern('X','(%a[%w_%-]*)')

local function lines(s) return s:gmatch('([^\n]*)\n') end
local function lstrip(str)  return str:gsub('^%s+','')  end
local function strip(str)  return lstrip(str):gsub('%s+$','') end
local function at(s,k)  return s:sub(k,k) end

local lapp = {}

local open_files,parms,aliases,parmlist,usage,script

lapp.callback = false -- keep Strict happy

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

--- controls whether to dump usage on error.
-- Defaults to true
lapp.show_usage_error = true

--- quit this script immediately.
-- @string msg optional message
-- @bool no_usage suppress 'usage' display
function lapp.quit(msg,no_usage)
    if no_usage == 'throw' then
        error(msg)
    end
    if msg then
        io.stderr:write(msg..'\n\n')
    end
    if not no_usage then
        io.stderr:write(usage)
    end
    os.exit(1)
end

--- print an error to stderr and quit.
-- @string msg a message
-- @bool no_usage suppress 'usage' display
function lapp.error(msg,no_usage)
    if not lapp.show_usage_error then
        no_usage = true
    elseif lapp.show_usage_error == 'throw' then
        no_usage = 'throw'
    end
    lapp.quit(script..': '..msg,no_usage)
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 lapp.open (file,opt)
    local val,err = io.open(file,opt)
    if not val then lapp.error(err,true) end
    append(open_files,val)
    return val
end

--- quit if the condition is false.
-- @bool condn a condition
-- @string msg message text
function lapp.assert(condn,msg)
    if not condn then
        lapp.error(msg)
    end
end

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

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

local types = {}

local builtin_types = {string=true,number=true,['file-in']='file',['file-out']='file',boolean=true}

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

--- add a new type to Lapp. These appear in parens after the value like
-- a range constraint, e.g. '<ival> (integer) Process PID'
-- @string name name of type
-- @param converter either a function to convert values, or a Lua type name.
-- @func[opt] constraint optional function to verify values, should use lapp.error
-- if failed.
function lapp.add_type (name,converter,constraint)
    types[name] = {converter=converter,constraint=constraint}
end

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

-- deducing type of variable from default value;
local function process_default (sval,vtype)
    local val, success
    if not vtype or vtype == 'number' then
        val = tonumber(sval)
    end
    if val then -- we have a number!
        return val,'number'
    elseif filetypes[sval] then
        local ft = filetypes[sval]
        return ft[1],ft[2]
    else
        if sval == 'true' and not vtype then
            return true, 'boolean'
        end
        if sval:match '^["\']' then sval = sval:sub(2,-2) end

        local ps = types[vtype] or {}
        ps.type = vtype

        local show_usage_error = lapp.show_usage_error
        lapp.show_usage_error = "throw"
        success, val = pcall(convert_parameter, ps, sval)
        lapp.show_usage_error = show_usage_error
        if success then
          return val, vtype or 'string'
        end

        return sval,vtype or 'string'
    end
end

--- process a Lapp options string.
-- Usually called as `lapp()`.
-- @string str the options text
-- @tparam {string} args a table of arguments (default is `_G.arg`)
-- @return a table with parameter-value pairs
function lapp.process_options_string(str,args)
    local results = {}
    local varargs
    local arg = args or _G.arg
    open_files = {}
    parms = {}
    aliases = {}
    parmlist = {}

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

    local function set_result(ps,parm,val)
        parm = type(parm) == "string" and parm:gsub("%W", "_") or parm -- so foo-bar becomes foo_bar in Lua
        if not ps.varargs then
            results[parm] = val
        else
            if not results[parm] then
                results[parm] = { val }
            else
                append(results[parm],val)
            end
        end
    end

    usage = str

    for _,a in ipairs(arg) do
      if a == "-h" or a == "--help" then
        return lapp.quit()
      end
    end


    for line in lines(str) do
        local res = {}
        local optparm,defval,vtype,constraint,rest
        line = lstrip(line)
        local function check(str)
            return match(str,line,res)
        end

        -- flags: either '-<short>', '-<short>,--<long>' or '--<long>'
        if check '-$v{short}, --$o{long} $' or check '-$v{short} $' or check '--$o{long} $' then
            if res.long then
                optparm = res.long:gsub('[^%w%-]','_')  -- I'm not sure the $o pattern will let anything else through?
                if #res.rest == 1 then optparm = optparm .. res.rest end
                if res.short then aliases[res.short] = optparm  end
            else
                optparm = res.short
            end
            if res.short and not lapp.slack then force_short(res.short) end
            res.rest, varargs = check_varargs(res.rest)
        elseif check '$<{name} $'  then -- is it <parameter_name>?
            -- so <input file...> becomes input_file ...
            optparm,rest = res.name:match '([^%.]+)(.*)'
            optparm = optparm:gsub('%A','_')
            varargs = rest == '...'
            append(parmlist,optparm)
        end
        -- this is not a pure doc line and specifies the flag/parameter type
        if res.rest then
            line = res.rest
            res = {}
            local optional
            -- do we have ([optional] [<type>] [default <val>])?
            if match('$({def} $',line,res) or match('$({def}',line,res) then
                local typespec = strip(res.def)
                local ftype, rest = typespec:match('^(%S+)(.*)$')
                rest = strip(rest)
                if ftype == 'optional' then
                    ftype, rest = rest:match('^(%S+)(.*)$')
                    rest = strip(rest)
                    optional = true
                end
                local default
                if ftype == 'default' then
                    default = true
                    if rest == '' then lapp.error("value must follow default") end
                else -- a type specification
                    if match('$f{min}..$f{max}',ftype,res) then
                        -- a numerical range like 1..10
                        local min,max = res.min,res.max
                        vtype = 'number'
                        constraint = function(x)
                            range_check(x,min,max,optparm)
                        end
                    elseif not ftype:match '|' then -- plain type
                        vtype = ftype
                    else
                        -- 'enum' type is a string which must belong to
                        -- one of several distinct values
                        local enums = ftype
                        local enump = '|' .. enums .. '|'
                        vtype = 'string'
                        constraint = function(s)
                            lapp.assert(enump:match('|'..s..'|'),
                              "value '"..s.."' not in "..enums
                            )
                        end
                    end
                end
                res.rest = rest
                typespec = res.rest
                -- optional 'default value' clause. Type is inferred as
                -- 'string' or 'number' if there's no explicit type
                if default or match('default $r{rest}',typespec,res) then
                    defval,vtype = process_default(res.rest,vtype)
                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 and not optional,
                comment = res.rest or optparm,
                constraint = constraint,
                varargs = varargs
            }
            varargs = nil
            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
            elseif not builtin_types[vtype] and vtype then
                lapp.error(vtype.." is unknown type")
            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
    local end_of_flags = false

    local function check_parm (parm)
        local eqi = parm:find '[=:]'
        if eqi then
            tinsert(arg,i+1,parm:sub(eqi+1))
            parm = parm:sub(1,eqi-1)
        end
        return parm,eqi
    end

    local function is_flag (parm)
        return parms[aliases[parm] or parm]
    end

    while i <= #arg do
        local theArg = arg[i]
        local res = {}
        -- after '--' we don't parse args and they end up in
        -- the array part of the result (args[1] etc)
        if theArg == '--' then
            end_of_flags = true
            iparm = #parmlist + 1
            i = i + 1
            theArg = arg[i]
            if not theArg then
                break
            end
        end
        -- look for a flag, -<short flags> or --<long flag>
        if not end_of_flags and (match('--$S{long}',theArg,res) or match('-$S{short}',theArg,res)) then
            if res.long then -- long option
                parm = check_parm(res.long)
            elseif #res.short == 1 or is_flag(res.short) then
                parm = res.short
            else
                local parmstr,eq = check_parm(res.short)
                if not eq then
                    parm = at(parmstr,1)
                    local flag = is_flag(parm)
                    if flag and flag.type ~= 'boolean' then
                    --if isdigit(at(parmstr,2)) then
                        -- a short option followed by a digit is an exception (for AW;))
                        -- push ahead into the arg array
                        tinsert(arg,i+1,parmstr:sub(2))
                    else
                        -- push multiple flags into the arg array!
                        for k = 2,#parmstr do
                            tinsert(arg,i+k-1,'-'..at(parmstr,k))
                        end
                    end
                else
                    parm = parmstr
                end
            end
            if aliases[parm] then parm = aliases[parm] end
            if not parms[parm] and (parm == 'h' or parm == 'help') then
                lapp.quit()
            end
        else -- a parameter
            parm = parmlist[iparm]
            if not parm then
               -- extra unnamed parameters are indexed starting at 1
               parm = iextra
               ps = { type = 'string' }
               parms[parm] = ps
               iextra = iextra + 1
            else
                ps = parms[parm]
            end
            if not ps.varargs then
                iparm = iparm + 1
            end
            val = theArg
        end
        ps = parms[parm]
        if not ps then lapp.error("unrecognized parameter: "..parm) end
        if ps.type ~= 'boolean' then -- we need a value! This should follow
            if not val then
                i = i + 1
                val = arg[i]
                theArg = val
            end
            lapp.assert(val,parm.." was expecting a value")
        else -- toggle boolean flags (usually false -> true)
            val = not ps.defval
        end
        ps.used = true
        val = convert_parameter(ps,val)
        set_result(ps,parm,val)
        if builtin_types[ps.type] == 'file' then
            set_result(ps,parm..'_name',theArg)
        end
        if lapp.callback then
            lapp.callback(parm,theArg,res)
        end
        i = i + 1
        val = nil
    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 lapp.error("missing required parameter: "..parm) end
            set_result(ps,parm,ps.defval)
        end
    end
    return results
end

if arg then
    script = arg[0]
    script = script or rawget(_G,"LAPP_SCRIPT") or "unknown"
    -- strip dir and extension to get current script name
    script = script:gsub('.+[\\/]',''):gsub('%.%a+$','')
else
    script = "inter"
end


setmetatable(lapp, {
    __call = function(tbl,str,args) return lapp.process_options_string(str,args) end,
})


return lapp