diff --git a/CHANGELOG.md b/CHANGELOG.md index f32650d8..1864e027 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ### New features + - `utils.quote_arg` will now optionally take an array of arguments and escape + them all into a single string. + - `app.parse_args` now accepts a 3rd parameter with a list of valid flags and aliasses + - `app.script_name` returns the name of the current script (previously a private function) ### Changes @@ -20,6 +24,7 @@ - `compat.execute` now handles the Windows exitcode -1 properly - `types.is_empty` would return true on spaces always, indepedent of the parameter - `types.to_bool` will now compare case-insensitive for the extra passed strings + - `app.require_here` will now properly handle an absolute base path ## 1.6.0 (2018-11-23) diff --git a/examples/testapp.lua b/examples/testapp.lua deleted file mode 100644 index b0dbf5f2..00000000 --- a/examples/testapp.lua +++ /dev/null @@ -1,5 +0,0 @@ --- shows how a script can get a private file path --- the output on my Windows machine is: --- C:\Documents and Settings\steve\.testapp\test.txt -local app = require 'pl.app' -print(app.appfile 'test.txt') diff --git a/lua/pl/app.lua b/lua/pl/app.lua index bf9621ba..d5e13c95 100644 --- a/lua/pl/app.lua +++ b/lua/pl/app.lua @@ -10,9 +10,14 @@ local path = require 'pl.path' local app = {} -local function check_script_name () - if _G.arg == nil then error('no command line args available\nWas this run from a main script?') end - return _G.arg[0] +--- return the name of the current script running. +-- The name will be the name as passed on the command line +-- @return string filename +function app.script_name() + if _G.arg and _G.arg[0] then + return _G.arg[0] + end + return utils.raise("No script name found") end --- add the current script's path to the Lua module path. @@ -24,7 +29,7 @@ end -- @string base optional base directory. -- @treturn string the current script's path with a trailing slash function app.require_here (base) - local p = path.dirname(check_script_name()) + local p = path.dirname(app.script_name()) if not path.isabs(p) then p = path.join(path.currentdir(),p) end @@ -32,7 +37,12 @@ function app.require_here (base) p = p..path.sep end if base then - p = p..base..path.sep + base = path.normcase(base) + if path.isabs(base) then + p = base .. path.sep + else + p = p..base..path.sep + end end local so_ext = path.is_windows and 'dll' or 'so' local lsep = package.path:find '^;' and '' or ';' @@ -45,16 +55,24 @@ end --- return a suitable path for files private to this application. -- These will look like '~/.SNAME/file', with '~' as with expanduser and -- SNAME is the name of the script without .lua extension. +-- If the directory does not exist, it will be created. -- @string file a filename (w/out path) -- @return a full pathname, or nil --- @return 'cannot create' error -function app.appfile (file) - local sname = path.basename(check_script_name()) +-- @return cannot create directory error +-- @usage +-- -- when run from a script called 'testapp' (on Windows): +-- local app = require 'pl.app' +-- print(app.appfile 'test.txt') +-- -- C:\Documents and Settings\steve\.testapp\test.txt +function app.appfile(file) + local sfullname, err = app.script_name() + if not sfullname then return utils.raise(err) end + local sname = path.basename(sfullname) local name = path.splitext(sname) local dir = path.join(path.expanduser('~'),'.'..name) if not path.isdir(dir) then local ret = path.mkdir(dir) - if not ret then return utils.raise ('cannot create '..dir) end + if not ret then return utils.raise('cannot create '..dir) end end return path.join(dir,file) end @@ -74,38 +92,141 @@ function app.platform() end --- return the full command-line used to invoke this script. --- Any extra flags occupy slots, so that `lua -lpl` gives us `{[-2]='lua',[-1]='-lpl'}` +-- It will not include the scriptname itself, see `app.script_name`. -- @return command-line -- @return name of Lua program used -function app.lua () - local args = _G.arg or error "not in a main program" - local imin = 0 - for i in pairs(args) do - if i < imin then imin = i end +-- @usage +-- -- execute: lua -lluacov -e 'print(_VERSION)' myscript.lua +-- +-- -- myscript.lua +-- print(require("pl.app").lua())) --> lua -lluacov -e 'print(_VERSION)' +function app.lua() + local args = _G.arg + if not args then + return utils.raise "not in a main program" end - local cmd, append = {}, table.insert - for i = imin,-1 do - append(cmd, utils.quote_arg(args[i])) + + local cmd = {} + local i = -1 + while true do + table.insert(cmd, 1, args[i]) + if not args[i-1] then + return utils.quote_arg(cmd), args[i] + end + i = i - 1 end - return table.concat(cmd,' '),args[imin] end --- parse command-line arguments into flags and parameters. -- Understands GNU-style command-line flags; short (`-f`) and long (`--flag`). --- These may be given a value with either '=' or ':' (`-k:2`,`--alpha=3.2`,`-n2`); --- note that a number value can be given without a space. +-- +-- These may be given a value with either '=' or ':' (`-k:2`,`--alpha=3.2`,`-n2`), +-- a number value can be given without a space. If the flag is marked +-- as having a value, then a space-separated value is also accepted (`-i hello`), +-- see the `flags_with_values` argument). +-- -- Multiple short args can be combined like so: ( `-abcd`). +-- +-- When specifying the `flags_valid` parameter, its contents can also contain +-- aliasses, to convert short/long flags to the same output name. See the +-- example below. +-- +-- Note: if a flag is repeated, the last value wins. -- @tparam {string} args an array of strings (default is the global `arg`) --- @tab flags_with_values any flags that take values, e.g. `{out=true}` +-- @tab flags_with_values any flags that take values, either list or hash +-- table e.g. `{ out=true }` or `{ "out" }`. +-- @tab flags_valid (optional) flags that are valid, either list or hashtable. +-- If not given, everything +-- will be accepted(everything in `flags_with_values` will automatically be allowed) -- @return a table of flags (flag=value pairs) -- @return an array of parameters -- @raise if args is nil, then the global `args` must be available! -function app.parse_args (args,flags_with_values) +-- @usage +-- -- Simple form: +-- local flags, params = app.parse_args(nil, +-- { "hello", "world" }, -- list of flags taking values +-- { "l", "a", "b"}) -- list of allowed flags (value ones will be added) +-- +-- -- More complex example using aliasses: +-- local valid = { +-- long = "l", -- if 'l' is specified, it is reported as 'long' +-- new = { "n", "old" }, -- here both 'n' and 'old' will go into 'new' +-- } +-- local values = { +-- "value", -- will automatically be added to the allowed set of flags +-- "new", -- will mark 'n' and 'old' as requiring a value as well +-- } +-- local flags, params = app.parse_args(nil, values, valid) +-- +-- -- command: myapp.lua -l --old:hello --value world param1 param2 +-- -- will yield: +-- flags = { +-- long = true, -- input from 'l' +-- new = "hello", -- input from 'old' +-- value = "world", -- allowed because it was in 'values', note: space separated! +-- } +-- params = { +-- [1] = "param1" +-- [2] = "param2" +-- } +function app.parse_args (args,flags_with_values, flags_valid) if not args then args = _G.arg - if not args then error "Not in a main program: 'arg' not found" end + if not args then utils.raise "Not in a main program: 'arg' not found" end + end + + local with_values = {} + for k,v in pairs(flags_with_values or {}) do + if type(k) == "number" then + k = v + end + with_values[k] = true + end + + local valid + if not flags_valid then + -- if no allowed flags provided, we create a table that always returns + -- the keyname, no matter what you look up + valid = setmetatable({},{ __index = function(_, key) return key end }) + else + valid = {} + for k,aliasses in pairs(flags_valid) do + if type(k) == "number" then -- array/list entry + k = aliasses + end + if type(aliasses) == "string" then -- single alias + aliasses = { aliasses } + end + if type(aliasses) == "table" then -- list of aliasses + -- it's the alternate name, so add the proper mappings + for i, alias in ipairs(aliasses) do + valid[alias] = k + end + end + valid[k] = k + end + do + local new_with_values = {} -- needed to prevent "invalid key to 'next'" error + for k,v in pairs(with_values) do + if not valid[k] then + valid[k] = k -- add the with_value entry as a valid one + new_with_values[k] = true + else + new_with_values[valid[k]] = true --set, but by its alias + end + end + with_values = new_with_values + end end - flags_with_values = flags_with_values or {} + + -- now check that all flags with values are reported as such under all + -- of their aliasses + for k, main_alias in pairs(valid) do + if with_values[main_alias] then + with_values[k] = true + end + end + local _args = {} local flags = {} local i = 1 @@ -113,16 +234,20 @@ function app.parse_args (args,flags_with_values) local a = args[i] local v = a:match('^-(.+)') local is_long - if v then -- we have a flag + if not v then + -- we have a parameter + _args[#_args+1] = a + else + -- it's a flag if v:find '^-' then is_long = true v = v:sub(2) end - if flags_with_values[v] then + if with_values[v] then if i == #args or args[i+1]:find '^-' then return utils.raise ("no value for '"..v.."'") end - flags[v] = args[i+1] + flags[valid[v]] = args[i+1] i = i + 1 else -- a value can also be indicated with = or : @@ -136,7 +261,13 @@ function app.parse_args (args,flags_with_values) var = var:sub(1,1) else -- multiple short flags for i = 1,#var do - flags[var:sub(i,i)] = true + local f = var:sub(i,i) + if not valid[f] then + return utils.raise("unknown flag '"..f.."'") + else + f = valid[f] + end + flags[f] = true end val = nil -- prevents use of var as a flag below end @@ -145,11 +276,14 @@ function app.parse_args (args,flags_with_values) end end if val then + if not valid[var] then + return utils.raise("unknown flag '"..var.."'") + else + var = valid[var] + end flags[var] = val end end - else - _args[#_args+1] = a end i = i + 1 end diff --git a/lua/pl/import_into.lua b/lua/pl/import_into.lua index a04de735..6dd27413 100644 --- a/lua/pl/import_into.lua +++ b/lua/pl/import_into.lua @@ -27,9 +27,10 @@ return function(env) comprehension=true,xml=true,types=true, test = true, app = true, file = true, class = true, luabalanced = true, permute = true, template = true, - url = true, compat = true, List = true, Map = true, Set = true, - OrderedMap = true, MultiMap = true, Date = true, + url = true, compat = true, -- classes -- + List = true, Map = true, Set = true, + OrderedMap = true, MultiMap = true, Date = true, } rawset(env,'utils',require 'pl.utils') diff --git a/lua/pl/text.lua b/lua/pl/text.lua index 341e2bf3..e626e86c 100644 --- a/lua/pl/text.lua +++ b/lua/pl/text.lua @@ -25,6 +25,9 @@ local bind1,usplit,assert_arg = utils.bind1,utils.split,utils.assert_arg local is_callable = require 'pl.types'.is_callable local unpack = utils.unpack +local text = {} + + local function makelist(l) return setmetatable(l, require('pl.List')) end @@ -39,12 +42,6 @@ local function imap(f,t,...) return res end ---[[ -module ('pl.text',utils._module) -]] - -local text = {} - local function _indent (s,sp) local sl = split(s,'\n') return concat(imap(bind1('..',sp),sl),'\n')..'\n' @@ -78,7 +75,8 @@ end -- to that extent. -- @param s the string -- @param width the margin width, default 70 --- @return a list of lines +-- @return a list of lines (List object) +-- @see pl.List function text.wrap (s,width) assert_arg(1,s,'string') width = width or 70 diff --git a/lua/pl/utils.lua b/lua/pl/utils.lua index 1dfa0091..46871596 100644 --- a/lua/pl/utils.lua +++ b/lua/pl/utils.lua @@ -382,12 +382,30 @@ function utils.executeex(cmd, bin) return success, retcode, (outcontent or ""), (errcontent or "") end ---- Quote an argument of a command. --- Quotes a single argument of a command to be passed +--- Quote and escape an argument of a command. +-- Quotes a single (or list of) argument(s) of a command to be passed -- to `os.execute`, `pl.utils.execute` or `pl.utils.executeex`. --- @string argument the argument. --- @return quoted argument. +-- @param argument (string or table/list) the argument to quote. If a list then +-- all arguments in the list will be returned as a single string quoted. +-- @return quoted and escaped argument. +-- @usage +-- local options = utils.quote_arg { +-- "-lluacov", +-- "-e", +-- "utils = print(require('pl.utils')._VERSION", +-- } +-- -- returns: -lluacov -e 'utils = print(require('\''pl.utils'\'')._VERSION' function utils.quote_arg(argument) + if type(argument) == "table" then + -- encode an entire table + local r = {} + for i, arg in ipairs(argument) do + r[i] = utils.quote_arg(arg) + end + + return table.concat(r, " ") + end + -- only a single argument if is_windows then if argument == "" or argument:find('[ \f\t\v]') then -- Need to quote the argument. @@ -437,7 +455,7 @@ end --- String functions -- @section string-functions ---- escape any 'magic' characters in a string +--- escape any Lua 'magic' characters in a string -- @param s The input string function utils.escape(s) utils.assert_string(1,s) diff --git a/tests/test-app.lua b/tests/test-app.lua new file mode 100644 index 00000000..6ccca484 --- /dev/null +++ b/tests/test-app.lua @@ -0,0 +1,263 @@ +local app = require "pl.app" +local utils = require "pl.utils" +local path = require "pl.path" +local asserteq = require 'pl.test'.asserteq + +local quote = utils.quote_arg + +local _, cmd = app.lua() +cmd = cmd .. " " .. quote({"-lluacov", "-e", "package.path=[[./lua/?.lua;./lua/?/init.lua;]]..package.path"}) + +local function run_script(s, fname) + local tmpname = path.tmpname() + if fname then + tmpname = path.join(path.dirname(tmpname), fname) + end + assert(utils.writefile(tmpname, s)) + local success, code, stdout, stderr = utils.executeex(cmd.." "..tmpname) + os.remove(tmpname) + return success, code, stdout, stderr +end + +do -- app.script_name + + local success, code, stdout, stderr = run_script([[ + print(require("pl.app").script_name()) + ]], + "justsomescriptname.lua") + asserteq(stderr, "") + asserteq(stdout:match("(justsome.+)$"), "justsomescriptname.lua\n") + + + -- commandline, no scriptname + local success, code, stdout, stderr = run_script([[ + arg[0] = nil -- simulate no scriptname + local name, err = require("pl.app").script_name() + io.stdout:write(tostring(name)) + io.stderr:write(err) + ]]) + assert(stderr:find("No script name found")) + asserteq(stdout, "nil") + + + -- commandline, no args table + local success, code, stdout, stderr = run_script([[ + arg = nil -- simulate no arg table + local name, err = require("pl.app").script_name() + io.stdout:write(tostring(name)) + io.stderr:write(err) + ]]) + assert(stderr:find("No script name found")) + asserteq(stdout, "nil") +end + +do -- app.require_here + local cd = path.currentdir() --path.dirname(path.tmpname()) + + -- plain script name + local success, code, stdout, stderr = run_script([[ + arg[0] = "justsomescriptname.lua" + local p = package.path + require("pl.app").require_here() + print(package.path:sub(1, -#p-1)) + ]]) + asserteq(stderr, "") + stdout = path.normcase(stdout) + assert(stdout:find(path.normcase(cd.."/?.lua;"), 1, true)) + assert(stdout:find(path.normcase(cd.."/?/init.lua;"), 1, true)) + + + -- plain script name, with a relative base name + local success, code, stdout, stderr = run_script([[ + arg[0] = "justsomescriptname.lua" + local p = package.path + require("pl.app").require_here("basepath/to/somewhere") + print(package.path:sub(1, -#p-1)) + ]]) + asserteq(stderr, "") + stdout = path.normcase(stdout) + assert(stdout:find(path.normcase(cd.."/basepath/to/somewhere/?.lua;"), 1, true)) + assert(stdout:find(path.normcase(cd.."/basepath/to/somewhere/?/init.lua;"), 1, true)) + + + -- plain script name, with an absolute base name + local success, code, stdout, stderr = run_script([[ + arg[0] = "justsomescriptname.lua" + local p = package.path + require("pl.app").require_here("/basepath/to/somewhere") + print(package.path:sub(1, -#p-1)) + ]]) + asserteq(stderr, "") + stdout = path.normcase(stdout) + asserteq(stdout, path.normcase("/basepath/to/somewhere/?.lua;/basepath/to/somewhere/?/init.lua;\n")) + + + -- scriptname with a relative path + local success, code, stdout, stderr = run_script([[ + arg[0] = "relative/prefix/justsomescriptname.lua" + local p = package.path + require("pl.app").require_here() + print(package.path:sub(1, -#p-1)) + os.exit() + ]]) + asserteq(stderr, "") + stdout = path.normcase(stdout) + assert(stdout:find(path.normcase(cd.."/relative/prefix/?.lua;"), 1, true)) + assert(stdout:find(path.normcase(cd.."/relative/prefix/?/init.lua;"), 1, true)) + + + -- script with an absolute path + local success, code, stdout, stderr = run_script([[ + arg[0] = "/fixed/justsomescriptname.lua" + local p = package.path + require("pl.app").require_here() + print(package.path:sub(1, -#p-1)) + ]]) + asserteq(stderr, "") + stdout = path.normcase(stdout) + asserteq(stdout, path.normcase("/fixed/?.lua;/fixed/?/init.lua;\n")) + +end + + +do -- app.appfile + local success, code, stdout, stderr = run_script([[ + arg[0] = "some/path/justsomescriptname_for_penlight_testing.lua" + print(require("pl.app").appfile("filename.data")) + ]]) + asserteq(stderr, "") + stdout = path.normcase(stdout) + local fname = path.normcase(path.expanduser("~/.justsomescriptname_for_penlight_testing/filename.data")) + asserteq(stdout, fname .."\n") + assert(path.isdir(path.dirname(fname))) + path.rmdir(path.dirname(fname)) + +end + + +do -- app.lua + local success, code, stdout, stderr = run_script([[ + arg[0] = "justsomescriptname.lua" + local a,b = require("pl.app").lua() + print(a) + ]]) + asserteq(stderr, "") + asserteq(stdout, cmd .."\n") + +end + + +do -- app.parse_args + + -- no value specified + local args = utils.split("-a -b") + local t,s = app.parse_args(args, { a = true}) + asserteq(t, nil) + asserteq(s, "no value for 'a'") + + + -- flag that take a value, space separated + local args = utils.split("-a -b value -c") + local t,s = app.parse_args(args, { b = true}) + asserteq(t, { + a = true, + b = "value", + c = true, + }) + asserteq(s, {}) + + + -- flag_with_values specified as a list + local args = utils.split("-a -b value -c") + local t,s = app.parse_args(args, { "b" }) + asserteq(t, { + a = true, + b = "value", + c = true, + }) + asserteq(s, {}) + + + -- error on an unknown flag + local args = utils.split("-a -b value -c") + local t,s = app.parse_args(args, { b = true }, { "b", "c" }) + asserteq(t, nil) + asserteq(s, "unknown flag 'a'") + + + -- flag that doesn't take a value + local args = utils.split("-a -b:value") + local t,s = app.parse_args(args, {}) + asserteq(t, { + ["a"] = true, + ["b"] = "value" + }) + asserteq(s, {}) + + + -- many values, duplicates, and parameters mixed + local args = utils.split( + "-a -b -cde --long1 --ff:ffvalue --gg=ggvalue -h:hvalue -i=ivalue " .. + "-i=2ndvalue param -i:3rdvalue -j1 -k2 -1:hello remaining values") + local t,s = app.parse_args(args) + asserteq({ + i = "3rdvalue", + ["1"] = "hello", + ff = "ffvalue", + long1 = true, + c = true, + b = true, + gg = "ggvalue", + j = "1", + k = "2", + d = true, + h = "hvalue", + a = true, + e = true + }, t) + asserteq({ + "param", + "remaining", + "values" + }, s) + + + -- specify valid flags and aliasses + local args = utils.split("-a -b value -e -f3") + local t,s = app.parse_args(args, + { + "b", + f = true, + }, { + bully = "b", -- b with value will be reported as 'bully', alias as string + a = true, -- hash-type value + c = { "d", "e" }, -- e will be reported as c, aliasses as list/table + }) + asserteq(t, { + a = true, + bully = "value", + c = true, + f = "3", + }) + asserteq(s, {}) + + + -- error on an unknown flag, in a chain of short ones + local args = utils.split("-b value -cd") + local t,s = app.parse_args(args, { b = true }, { "b", "c" }) + asserteq(t, nil) + asserteq(s, "unknown flag 'd'") + + + -- flag, in a chain of short ones, gets converted to alias + local args = utils.split("-dbc") + local t,s = app.parse_args(args, nil, { "d", full_name = "b", "c" }) + asserteq(t, { + full_name = true, -- specified as b in a chain of short ones + c = true, + d = true, + }) + asserteq(s, {}) + +end + diff --git a/tests/test-args.lua b/tests/test-args.lua deleted file mode 100644 index 9d918749..00000000 --- a/tests/test-args.lua +++ /dev/null @@ -1,48 +0,0 @@ --- testing app.parse_args -local asserteq = require 'pl.test'.asserteq -local app = require 'pl.app' -local path = require 'pl.path' -local parse_args = app.parse_args - --- shows the use of plain flags, long and short: -local flags,args = parse_args({'-abc','--flag','-v','one'}) - -asserteq(flags,{a=true,b=true,c=true,flag=true,v=true}) -asserteq(args,{'one'}) - --- flags may be given values if the value follows or is separated by equals -flags,args = parse_args({'-n10','--out=20'}) - -asserteq(flags,{n='10',out='20'}) -asserteq(args,{}) - --- a flag can be explicitly specified as taking a value: -flags,args = parse_args({'-k','-b=23','-o','hello','--out'},{o=true}) - -asserteq(flags,{out=true,o="hello",k=true,b="23"}) -asserteq(args,{}) - -local ok,err = parse_args({'-n'},{n=true}) -asserteq(ok,nil) -asserteq(err, "no value for 'n'") - -ok,err = parse_args({'-n','-n'},{n=true}) -asserteq(ok,nil) -asserteq(err, "no value for 'n'") - --- modify this script's module path so it looks in the 'lua' subdirectory --- for its modules -app.require_here 'lua' - -asserteq(require 'foo.args'.answer(),42) -asserteq(require 'bar'.name(),'bar') - - -asserteq( - app.appfile 'config', - path.expanduser('~/.test-args/config'):gsub('/',path.sep) -) - - - -