• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

lunarmodules / Penlight / 21348394228

26 Jan 2026 06:27AM UTC coverage: 90.076% (+1.3%) from 88.798%
21348394228

push

github

web-flow
Merge c68a1d6de into 678de0ebb

0 of 19 new or added lines in 1 file covered. (0.0%)

28 existing lines in 4 files now uncovered.

5537 of 6147 relevant lines covered (90.08%)

726.78 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.67
/lua/pl/lapp.lua
1
--- Simple command-line parsing using human-readable specification.
2
-- Supports GNU-style parameters.
3
--
4
--      lapp = require 'pl.lapp'
5
--      local args = lapp [[
6
--      Does some calculations
7
--        -o,--offset (default 0.0)  Offset to add to scaled number
8
--        -s,--scale  (number)  Scaling factor
9
--        <number> (number) Number to be scaled
10
--      ]]
11
--
12
--      print(args.offset + args.scale * args.number)
13
--
14
-- Lines beginning with `'-'` are flags; there may be a short and a long name;
15
-- lines beginning with `'<var>'` are arguments.  Anything in parens after
16
-- the flag/argument is either a default, a type name or a range constraint.
17
--
18
-- See @{08-additional.md.Command_line_Programs_with_Lapp|the Guide}
19
--
20
-- Dependencies: `pl.sip`
21
-- @module pl.lapp
22

23
local status,sip = pcall(require,'pl.sip')
17✔
24
if not status then
17✔
25
    sip = require 'sip'
×
26
end
27
local match = sip.match_at_start
17✔
28
local append,tinsert = table.insert,table.insert
17✔
29

30
sip.custom_pattern('X','(%a[%w_%-]*)')
17✔
31

32
local function lines(s) return s:gmatch('([^\n]*)\n') end
629✔
33
local function lstrip(str)  return str:gsub('^%s+','')  end
4,029✔
34
local function strip(str)  return lstrip(str):gsub('%s+$','') end
2,877✔
35
local function at(s,k)  return s:sub(k,k) end
153✔
36

37
local lapp = {}
17✔
38

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

41
lapp.callback = false -- keep Strict happy
17✔
42

43
local filetypes = {
17✔
44
    stdin = {io.stdin,'file-in'}, stdout = {io.stdout,'file-out'},
17✔
45
    stderr = {io.stderr,'file-out'}
17✔
46
}
47

48
--- controls whether to dump usage on error.
49
-- Defaults to true
50
lapp.show_usage_error = true
17✔
51

52
--- quit this script immediately.
53
-- @string msg optional message
54
-- @bool no_usage suppress 'usage' display
55
function lapp.quit(msg,no_usage)
17✔
56
    if no_usage == 'throw' then
68✔
57
        error(msg)
68✔
58
    end
59
    if msg then
×
60
        io.stderr:write(msg..'\n\n')
×
61
    end
62
    if not no_usage then
×
63
        io.stderr:write(usage)
×
64
    end
65
    os.exit(1)
×
66
end
67

68
--- print an error to stderr and quit.
69
-- @string msg a message
70
-- @bool no_usage suppress 'usage' display
71
function lapp.error(msg,no_usage)
17✔
72
    if not lapp.show_usage_error then
68✔
73
        no_usage = true
×
74
    elseif lapp.show_usage_error == 'throw' then
68✔
75
        no_usage = 'throw'
68✔
76
    end
77
    lapp.quit(script..': '..msg,no_usage)
68✔
78
end
79

80
--- open a file.
81
-- This will quit on error, and keep a list of file objects for later cleanup.
82
-- @string file filename
83
-- @string[opt] opt same as second parameter of `io.open`
84
function lapp.open (file,opt)
17✔
85
    local val,err = io.open(file,opt)
34✔
86
    if not val then lapp.error(err,true) end
34✔
87
    append(open_files,val)
34✔
88
    return val
34✔
89
end
90

91
--- quit if the condition is false.
92
-- @bool condn a condition
93
-- @string msg message text
94
function lapp.assert(condn,msg)
17✔
95
    if not condn then
2,193✔
96
        lapp.error(msg)
51✔
97
    end
98
end
99

100
local function range_check(x,min,max,parm)
101
    lapp.assert(min <= x and max >= x,parm..' out of range')
34✔
102
end
103

104
local function xtonumber(s)
105
    local val = tonumber(s)
85✔
106
    if not val then lapp.error("unable to convert to number: "..s) end
85✔
107
    return val
68✔
108
end
109

110
local types = {}
17✔
111

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

114
local function convert_parameter(ps,val)
115
    if ps.converter then
1,156✔
116
        val = ps.converter(val)
44✔
117
    end
118
    if ps.type == 'number' then
1,156✔
119
        val = xtonumber(val)
105✔
120
    elseif builtin_types[ps.type] == 'file' then
1,071✔
121
        val = lapp.open(val,(ps.type == 'file-in' and 'r') or 'w' )
44✔
122
    elseif ps.type == 'boolean' then
1,037✔
123
        return val
187✔
124
    end
125
    if ps.constraint then
952✔
126
        ps.constraint(val)
221✔
127
    end
128
    return val
901✔
129
end
130

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

141
local function force_short(short)
142
    lapp.assert(#short==1,short..": short parameters should be one character")
1,207✔
143
end
144

145
-- deducing type of variable from default value;
146
local function process_default (sval,vtype)
147
    local val, success
148
    if not vtype or vtype == 'number' then
629✔
149
        val = tonumber(sval)
374✔
150
    end
151
    if val then -- we have a number!
629✔
152
        return val,'number'
221✔
153
    elseif filetypes[sval] then
408✔
154
        local ft = filetypes[sval]
102✔
155
        return ft[1],ft[2]
102✔
156
    else
157
        if sval == 'true' and not vtype then
306✔
158
            return true, 'boolean'
68✔
159
        end
160
        if sval:match '^["\']' then sval = sval:sub(2,-2) end
238✔
161

162
        local ps = types[vtype] or {}
238✔
163
        ps.type = vtype
238✔
164

165
        local show_usage_error = lapp.show_usage_error
238✔
166
        lapp.show_usage_error = "throw"
238✔
167
        success, val = pcall(convert_parameter, ps, sval)
308✔
168
        lapp.show_usage_error = show_usage_error
238✔
169
        if success then
238✔
170
          return val, vtype or 'string'
238✔
171
        end
172

173
        return sval,vtype or 'string'
×
174
    end
175
end
176

177
--- process a Lapp options string.
178
-- Usually called as `lapp()`.
179
-- @string str the options text
180
-- @tparam {string} args a table of arguments (default is `_G.arg`)
181
-- @return a table with parameter-value pairs
182
function lapp.process_options_string(str,args)
17✔
183
    local results = {}
612✔
184
    local varargs
185
    local arg = args or _G.arg
612✔
186
    open_files = {}
612✔
187
    parms = {}
612✔
188
    aliases = {}
612✔
189
    parmlist = {}
612✔
190

191
    local function check_varargs(s)
192
        local res,cnt = s:gsub('^%.%.%.%s*','')
1,496✔
193
        return res, (cnt > 0)
1,496✔
194
    end
195

196
    local function set_result(ps,parm,val)
197
        parm = type(parm) == "string" and parm:gsub("%W", "_") or parm -- so foo-bar becomes foo_bar in Lua
1,581✔
198
        if not ps.varargs then
1,581✔
199
            results[parm] = val
1,377✔
200
        else
201
            if not results[parm] then
204✔
202
                results[parm] = { val }
153✔
203
            else
204
                append(results[parm],val)
51✔
205
            end
206
        end
207
    end
208

209
    usage = str
612✔
210

211
    for _,a in ipairs(arg) do
1,955✔
212
      if a == "-h" or a == "--help" then
1,343✔
213
        return lapp.quit()
×
214
      end
215
    end
216

217

218
    for l in lines(str) do
2,594✔
219
        local line = l
1,802✔
220
        local res = {}
1,802✔
221
        local optparm,defval,vtype,constraint,rest
222
        line = lstrip(line)
2,332✔
223
        local function check(str)
224
            return match(str,line,res)
4,029✔
225
        end
226

227
        -- flags: either '-<short>', '-<short>,--<long>' or '--<long>'
228
        if check '-$v{short}, --$o{long} $' or check '-$v{short} $' or check '--$o{long} $' then
2,897✔
229
            if res.long then
1,496✔
230
                optparm = res.long:gsub('[^%w%-]','_')  -- I'm not sure the $o pattern will let anything else through?
561✔
231
                if #res.rest == 1 then optparm = optparm .. res.rest end
561✔
232
                if res.short then aliases[res.short] = optparm  end
561✔
233
            else
234
                optparm = res.short
935✔
235
            end
236
            if res.short and not lapp.slack then force_short(res.short) end
1,496✔
237
            res.rest, varargs = check_varargs(res.rest)
1,936✔
238
        elseif check '$<{name} $'  then -- is it <parameter_name>?
396✔
239
            -- so <input file...> becomes input_file ...
240
            optparm,rest = res.name:match '([^%.]+)(.*)'
153✔
241
            -- follow lua legal variable names
242
            optparm = optparm:sub(1,1):gsub('%A','_') .. optparm:sub(2):gsub('%W', '_')
243✔
243
            varargs = rest == '...'
153✔
244
            append(parmlist,optparm)
153✔
245
        end
246
        -- this is not a pure doc line and specifies the flag/parameter type
247
        if res.rest then
1,802✔
248
            line = res.rest
1,649✔
249
            res = {}
1,649✔
250
            local optional
251
            local defval_str
252
            -- do we have ([optional] [<type>] [default <val>])?
253
            if match('$({def} $',line,res) or match('$({def}',line,res) then
2,514✔
254
                local typespec = strip(res.def)
1,088✔
255
                local ftype, rest = typespec:match('^(%S+)(.*)$')
1,088✔
256
                rest = strip(rest)
1,408✔
257
                if ftype == 'optional' then
1,088✔
258
                    ftype, rest = rest:match('^(%S+)(.*)$')
34✔
259
                    rest = strip(rest)
44✔
260
                    optional = true
34✔
261
                end
262
                local default
263
                if ftype == 'default' then
1,088✔
264
                    default = true
255✔
265
                    if rest == '' then lapp.error("value must follow default") end
255✔
266
                else -- a type specification
267
                    if match('$f{min}..$f{max}',ftype,res) then
1,078✔
268
                        -- a numerical range like 1..10
269
                        local min,max = res.min,res.max
119✔
270
                        vtype = 'number'
119✔
271
                        constraint = function(x)
272
                            range_check(x,min,max,optparm)
34✔
273
                        end
274
                    elseif not ftype:match '|' then -- plain type
714✔
275
                        vtype = ftype
493✔
276
                    else
277
                        -- 'enum' type is a string which must belong to
278
                        -- one of several distinct values
279
                        local enums = ftype
221✔
280
                        local enump = '|' .. enums .. '|'
221✔
281
                        vtype = 'string'
221✔
282
                        constraint = function(s)
283
                            lapp.assert(enump:find('|'..s..'|', 1, true),
306✔
284
                              "value '"..s.."' not in "..enums
153✔
285
                            )
286
                        end
287
                    end
288
                end
289
                res.rest = rest
1,088✔
290
                typespec = res.rest
1,088✔
291
                -- optional 'default value' clause. Type is inferred as
292
                -- 'string' or 'number' if there's no explicit type
293
                if default or match('default $r{rest}',typespec,res) then
1,333✔
294
                    defval_str = res.rest
629✔
295
                    defval,vtype = process_default(res.rest,vtype)
814✔
296
                end
297
            else -- must be a plain flag, no extra parameter required
298
                defval = false
561✔
299
                vtype = 'boolean'
561✔
300
            end
301
            local ps = {
1,649✔
302
                type = vtype,
1,649✔
303
                defval = defval,
1,649✔
304
                defval_str = defval_str,
1,649✔
305
                required = defval == nil and not optional,
1,649✔
306
                comment = res.rest or optparm,
1,649✔
307
                constraint = constraint,
1,649✔
308
                varargs = varargs
1,649✔
309
            }
310
            varargs = nil
1,649✔
311
            if types[vtype] then
1,649✔
312
                local converter = types[vtype].converter
34✔
313
                if type(converter) == 'string' then
34✔
UNCOV
314
                    ps.type = converter
×
315
                else
316
                    ps.converter = converter
34✔
317
                end
318
                ps.constraint = types[vtype].constraint
34✔
319
            elseif not builtin_types[vtype] and vtype then
1,615✔
UNCOV
320
                lapp.error(vtype.." is unknown type")
×
321
            end
322
            parms[optparm] = ps
1,649✔
323
        end
324
    end
325
    -- cool, we have our parms, let's parse the command line args
326
    local iparm = 1
612✔
327
    local iextra = 1
612✔
328
    local i = 1
612✔
329
    local parm,ps,val
330
    local end_of_flags = false
612✔
331

332
    local function check_parm (parm)
333
        local eqi = parm:find '[=:]'
255✔
334
        if eqi then
255✔
335
            tinsert(arg,i+1,parm:sub(eqi+1))
88✔
336
            parm = parm:sub(1,eqi-1)
88✔
337
        end
338
        return parm,eqi
255✔
339
    end
340

341
    local function is_flag (parm)
342
        return parms[aliases[parm] or parm]
289✔
343
    end
344

345
    while i <= #arg do
1,462✔
346
        local theArg = arg[i]
935✔
347
        local res = {}
935✔
348
        -- after '--' we don't parse args and they end up in
349
        -- the array part of the result (args[1] etc)
350
        if theArg == '--' then
935✔
351
            end_of_flags = true
34✔
352
            iparm = #parmlist + 1
34✔
353
            i = i + 1
34✔
354
            theArg = arg[i]
34✔
355
            if not theArg then
34✔
356
                break
8✔
357
            end
358
        end
359
        -- look for a flag, -<short flags> or --<long flag>
360
        if not end_of_flags and (match('--$S{long}',theArg,res) or match('-$S{short}',theArg,res)) then
1,408✔
361
            if res.long then -- long option
748✔
362
                parm = check_parm(res.long)
132✔
363
            elseif #res.short == 1 or is_flag(res.short) then
701✔
364
                parm = res.short
493✔
365
            else
366
                local parmstr,eq = check_parm(res.short)
153✔
367
                if not eq then
153✔
368
                    parm = at(parmstr,1)
132✔
369
                    local flag = is_flag(parm)
102✔
370
                    if flag and flag.type ~= 'boolean' then
102✔
371
                    --if isdigit(at(parmstr,2)) then
372
                        -- a short option followed by a digit is an exception (for AW;))
373
                        -- push ahead into the arg array
374
                        tinsert(arg,i+1,parmstr:sub(2))
88✔
375
                    else
376
                        -- push multiple flags into the arg array!
377
                        for k = 2,#parmstr do
68✔
378
                            tinsert(arg,i+k-1,'-'..at(parmstr,k))
44✔
379
                        end
380
                    end
381
                else
382
                    parm = parmstr
51✔
383
                end
384
            end
385
            if aliases[parm] then parm = aliases[parm] end
748✔
386
            if not parms[parm] and (parm == 'h' or parm == 'help') then
748✔
UNCOV
387
                lapp.quit()
×
388
            end
389
        else -- a parameter
390
            parm = parmlist[iparm]
170✔
391
            if not parm then
170✔
392
               -- extra unnamed parameters are indexed starting at 1
393
               parm = iextra
51✔
394
               ps = { type = 'string' }
51✔
395
               parms[parm] = ps
51✔
396
               iextra = iextra + 1
51✔
397
            else
398
                ps = parms[parm]
119✔
399
            end
400
            if not ps.varargs then
170✔
401
                iparm = iparm + 1
136✔
402
            end
403
            val = theArg
170✔
404
        end
405
        ps = parms[parm]
918✔
406
        if not ps then lapp.error("unrecognized parameter: "..parm) end
918✔
407
        if ps.type ~= 'boolean' then -- we need a value! This should follow
918✔
408
            if not val then
731✔
409
                i = i + 1
561✔
410
                val = arg[i]
561✔
411
                theArg = val
561✔
412
            end
413
            lapp.assert(val,parm.." was expecting a value")
946✔
414
        else -- toggle boolean flags (usually false -> true)
415
            val = not ps.defval
187✔
416
        end
417
        ps.used = true
918✔
418
        val = convert_parameter(ps,val)
1,168✔
419
        set_result(ps,parm,val)
850✔
420
        if builtin_types[ps.type] == 'file' then
850✔
421
            set_result(ps,parm..'_name',theArg)
34✔
422
        end
423
        if lapp.callback then
850✔
424
            lapp.callback(parm,theArg,res)
51✔
425
        end
426
        i = i + 1
850✔
427
        val = nil
850✔
428
    end
429
    -- check unused parms, set defaults and check if any required parameters were missed
430
    for parm,ps in pairs(parms) do
1,972✔
431
        if not ps.used then
1,428✔
432
            if ps.required then lapp.error("missing required parameter: "..parm) end
629✔
433
            set_result(ps,parm,ps.defval)
629✔
434
            if builtin_types[ps.type] == "file" then
629✔
435
                set_result(ps, parm .. "_name", ps.defval_str)
68✔
436
            end
437
        end
438
    end
439
    return results
544✔
440
end
441

442
if arg then
17✔
443
    script = arg[0]
17✔
444
    script = script or rawget(_G,"LAPP_SCRIPT") or "unknown"
17✔
445
    -- strip dir and extension to get current script name
446
    script = script:gsub('.+[\\/]',''):gsub('%.%a+$','')
17✔
447
else
UNCOV
448
    script = "inter"
×
449
end
450

451

452
setmetatable(lapp, {
34✔
453
    __call = function(tbl,str,args) return lapp.process_options_string(str,args) end,
629✔
454
})
455

456

457
return lapp
17✔
458

459

STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc