-- test bed for luash concept require 'pl' stringx.import() local lexer = require 'pl.lexer' require 'lfs' local exec = os.execute local write = io.write local append,concat = table.insert,table.concat local match = sip.match local getrest = lexer.getrest local ops = require 'pl.operator' local array = require 'pl.array2d' local match = sip.match_at_start local dirs = List() local res = {} local pwd = lfs.currentdir() local home = '^'..path.expanduser('~')..path.sep local homerep = '~'..path.sep local expandvars local script_dir = path.dirname(path.abspath(arg[0])) --print('script',script_dir) -- should check here whether we can actually write to this; if not -- use ~/.luash function get_save_dir() return script_dir end local function home_path(p) return p:gsub(home,homerep) end -- defining terminal input. We try to use readline on Unix systems --- -- This works if Lua (or LuaJIT) has been linked with readline; dynamic -- linking of libreadline.so is broken on Red Hat systems. local rl,readline,saveline if not path.is_windows then local status = pcall(require,'alien') if not status then print 'alien not found; no readline support possible' else rl = alien.default if not rl.readline then print 'no readline present' rl = nil else rl.readline:types('string','string') rl.add_history:types('void','string') readline = rl.readline saveline = rl.add_history end end end if not rl then readline = function(prompt) write(prompt) return io.read() end saveline = function(s) end end function term_in(prompt) return function() local line = readline(prompt) saveline(line) return line end end --~ _DEBUG = true local script if arg[1] ~= 'debug' then script = arg[1] TEST = arg[2] == 'debug' else TEST = arg[1] == 'debug' end -- global for a purpose! Allows loaded modules to add commands. commands,command_help = {},{} variables = {} alias = {} context = nil show_context = nil -- this deals with the pesky requirement that Windows paths with spaces must be quoted. local function quote_if_necessary (v) v = tostring(v) if not v then return '' else if v:find ' ' and not v:startswith '"' then v = '"'..v..'"' end end return v end --------------------- Event Management ------------------------------------ -- [supported events] -- 'leave-dir' -- 'enter-dir' -- 'context-changed' -- 'load' -- 'interactive-start' -- 'interactive-end' local events,disabled = {},{} function add_event (type,handler) if not events[type] then events[type] = List() end events[type]:append(handler) end function enable_event (type,on_off) disabled[type] = not on_off end function remove_event (type,handler) local ee = events[type] if ee then ee:remove_value(handler) end end function raise_event (type,arg) if events[type] and not disabled[type] then for f in events[type]:iter() do local ret = f(arg) if ret then return ret end end end end -------------------------------------------------------- -- basic error management. By default, throw an error, since all commands -- are executed using pcall. function errorsh (msg,op) msg = '!'..msg if not op then error(msg) else io.write(msg,'\n') end end function set_directory (dir) raise_event('leave-dir',pwd) lfs.chdir(dir) pwd = lfs.currentdir() local hpwd = home_path(pwd) print(hpwd) variables.pwd = pwd raise_event('enter-dir',pwd) if path.is_windows then exec('title '..hpwd) --WIN32 else io.write("\027]2;"..hpwd.."\007") end local i = dirs:index(pwd) if i then dirs:remove_value(pwd) end dirs:append(pwd) end set_directory(pwd) ----------------- formatters for common types ------------------------- -- [formatters] These are used by list, show, etc. The basic types are -- file, path, date, size. Your extensions are free to override these defaults. formatters = {path=tostring} -- ensure that we are never suprised by unknown field types! setmetatable(formatters,{ __index = function(t,v) t[v] = tostring return t[v] end }) -- [displaying filenames] Passing the -n flag to any command that produces files -- will cause the filename without directory to be shown. function formatters.file (val,flags) if flags.n then val = path.basename(val) else val = home_path(val) end return val end -- [displaying paths] The home path is unexpanded. function formatters.path(val,flags) return home_path(val) end -- [displaying dates] Default to using %c, which is the prefered data/time representation. -- You can specify your own format using the dateformat variable. Uses os.date, which takes -- the same format specifiers as strftime. function formatters.date (val,flags) return os.date(variables.dateformat or '%c',val) end -- [displaying file sizes] Gives results in K upto 500K, thereafter in Meg (one decimal place precision) -- The fileformat variable controls the output format (default is %6.1f) function formatters.size (val,flags) if not val then return '' end local fmt = variables.fileformat or '%6.1f' val = val/1024 local postfix if val > 500 then val = fmt:format(val/1024) postfix = 'M' else val = fmt:format(val) postfix = 'K' end return val..postfix end ------------- Context management ------------------------ -- [contexts] The context is the dataset created by the last 'list' command, or any function -- which creates a dataset (see the example locate.lua to see how). luash assigns special -- meaning to the _slots_ file,path,date and size. The special pseudo-variables $1,$2 etc -- refer to the file slot of the dataset, if possible, and otherwise fall back to the path -- slot. -- -- The 'show' command creates a special show -- context, so that you can repeatedly try various queries. Once you like what you see, then -- the 'accept' command makes this temporary context into the main context. local function get_context_slot (name,throw_error) check_context() local slot = tablex.find(context.fieldnames,name) if not slot then local msg = 'no '..name..'s available' if throw_error then errorsh(msg) else return nil,msg end end return slot end -- [file pseudo-variable]. $files will give you all the files in the current context, -- in a form suitable for use by a command. Use 'echo $files` to see what the result -- will be. This variable is actually a _function_, which is called each time a -- substitution takes place. local function files () local slot = get_context_slot("file",true) local res = array.column(context,slot) --tablex.imap(ops.array,context,slot) res = tablex.imap(quote_if_necessary,res) return concat(res,' ') end function prepare_data (res) data.new(res) res.formatters = tablex.index_by(formatters,res.fieldnames) end local is_type = utils.is_type function set_context (res,rtype,flags) flags = flags or {} if is_type(res,'table') and #res > 0 then if not res.fieldnames and is_type(res,List) then if not rtype:find ',' then res = seq.copy_tuples(res) end res.fieldnames = rtype end if res.fieldnames then prepare_data(res) context = res if not flags.q then dump_data(context,flags) else print('context contains '..#context..' items') end raise_event('context-changed',context) end end end function check_context () if not context then errorsh('no context') end end function get_value_from_context (rest,kind) local idx = tonumber(rest) local slot,err = get_context_slot(kind) if not slot then return nil,err end if idx >= 1 and idx <= #context then return true,context[idx][slot] else return nil,'not a valid index' end end function question (msg,default) io.write(msg..' ['..tostring(default)..'] ') local line = io.read() if line == '' then return default else local t = type(default) if t == 'boolean' then return line == 'true' or line:startswith('y') elseif t == 'number' then return tonumber(line) or errorsh('bad input') else return line end end end local rjust = string.rjust local function justify (s,sz) return rjust(s,sz+2) end function dump_data (d,flags) local outf = io.stdout local tmpfile,chop local interactive = flags._interactive if interactive and not path.is_windows then local width = tonumber(os.getenv 'COLUMNS') or 80 chop = function(s) return s:sub(1,width) end else chop = function(s) return s end end if #d > (variables.maxdump or 25) then print('There were '..#d..' items') if not interactive or question('edit result?',true) then tmpfile = path.tmpname() outf,err = io.open(tmpfile,'w') else return end end -- convert our dataset into strings using the column formatters local outs = array.map2('()',1,2,d.formatters,d,flags) -- get the maximum column widths local maxlens = array.reduce_cols(math.max,array.map('#',outs)) -- can now right justify each line appropriately if variables.columns then local row = tablex.map2(justify,d.fieldnames,maxlens) local line = ' '..concat(row) outf:write(chop(line),'\n') end for i,row in ipairs(outs) do row = tablex.map2(justify,row,maxlens) local line = ('%2d '):format(i)..concat(row) outf:write(chop(line),'\n') end if tmpfile then outf:close() if interactive then exec(alias.edit..' '..tmpfile) else print('output written to '..tmpfile) end end end ---------------------------- Standard Lua Commands ------------------------- command_help.accept = 'accept temporary context as primary context' function commands.accept () context = show_context or errorsh('no temporary context') end local function set_directory_to_file_or_path(rest) if path.isdir(rest) then set_directory(rest) else if path.isfile(rest) then set_directory(path.dirname(rest)) else return false end end return true end command_help.cd = 'change to directory. Without parameters, shows directory list' function commands.cd (args) local rest = args[1] if not rest then -- cd with no parameters return dirs,'path' end if not set_directory_to_file_or_path(rest) then rest = expandvars(rest) if set_directory_to_file_or_path(rest) then return end else return end print 'not a directory' end command_help.switch = 'toggle current directory' function commands.switch () if #dirs > 1 then set_directory(dirs[#dirs-1]) end end function module_path(file) if path.extension(file) == '' then file = file..'.lua' end if path.dirname(file) == '' then file = path.join(script_dir,file) end return file end command_help.load = 'load Lua script' function commands.load (args) local file = module_path(args[1]) dofile(file) if not args.n then raise_event('load',file) end end command_help.copy = 'copy files(s) to destination' function commands.copy (args,flags) local dest if #args == 1 then -- i.e more like DOS copy than cp dest = '.' else dest = args[#args] end local isdir = path.isdir(dest) local basename = path.basename if not isdir and #args > 2 then print("destination directory does not exist") return end local fails = {} local join = path.join local function copyfile (f1,f2) --print('copy '..f1..' to '..f2) if not dir.copyfile(f1,f2) then append(fails,f2) end end if #args > 2 then for i = 1,#args-1 do local f = args[i] copyfile(f,join(dest,basename(f))) end else copyfile(args[1],isdir and join(dest,basename(args[1])) or args[2]) end if #fails > 0 then print(#fails..' files failed to copy') tablex.foreach(fails,print) end end local function file_attribs (f,check) if check and not path.exists(f) then return nil end return {f,path.getsize(f),path.isdir(f) and 'D' or 'F',path.getmtime(f)} end local function file_attribs_flags (flags,f) return flags.l and file_attribs(f) or {f} end command_help.list = 'list directory contents given a file mask. -l means "long format"' function commands.list (args,flags) local where_idx = args:index 'where' or args:index '?' if where_idx then flags.l = true condn = args:slice(where_idx) args = args:slice(1,where_idx-1) end local d if flags.l then d = {fieldnames = 'file,size,type,date'} else d = {fieldnames = 'file'} end if #args == 0 then add_dir_entries(nil,flags,d) else for _,a in ipairs(args) do add_dir_entries(a,flags,d) end end if where_idx then prepare_data(d) return process_where_clause(d,d.fieldnames,condn,1) else return d end end function add_dir_entries (a,flags,d) if a and path.exists(a) then append(d,file_attribs_flags(flags,a)) return end local sep = path.sep local pat = a or '.'..sep..'*' if pat:at(1)=='.' and pat:at(2)~=sep then pat = '*'..pat end local folder,mask if path.isdir(pat) then folder = pat else folder,mask = path.splitpath(pat) end if not folder or folder == '' then folder = '.' end if mask == '' then mask = '*' end if not path.isdir(folder) then errorsh("directory does not exist: "..folder) end for i,f in ipairs(dir.getdirectories(folder,mask)) do if folder == '.' then f = f:sub(3) end -- get rid of './' append(d,file_attribs_flags(flags,f)) end for i,f in ipairs(dir.getfiles(folder,mask)) do if folder == '.' then f = f:sub(3) end -- get rid of './' append(d,file_attribs_flags(flags,f)) end end command_help.fileinfo = 'show sizes and dates of given files' function commands.fileinfo (args,flags) if #args == 0 then args = List.split(variables.files) end return args:map(file_attribs,true),'file,size,date' end command_help.info = 'var cmds alias context' function commands.info (args,flags) local arg = args[1] local sep,tbl,filter if arg == 'alias' then tbl = alias sep = ' is ' if flags.d then filter = function(s) return s:startswith 'cd ' end end elseif arg == 'vars' then tbl = variables sep = ' = ' if flags.d then filter = path.isdir end elseif arg == 'cmds' then tbl = command_help sep = ' ' elseif arg == 'context' then check_context() utils.printf("context has %d items and fields %s\n",#context,concat(context.fieldnames,' ')) return else -- [extending the info command] If a module subscrbes to the 'info-required' -- event it can handle unknown info subcommands. It must then return true. if not raise_event('info-required',arg) then print('not one of '..command_help.info) end return end if tbl then for k,v in pairs(tbl) do if not filter or (filter and type(v) == 'string' and filter(v)) then if type(v) == 'function' then v = '' elseif v:find ' ' then v = '"'..v..'"' end print(k..sep..v) end end return end end function where_substitution (condn) return condn:gsub('[%d%.]+[K|M]',function(s) local tp = s:at(-1) local fact = 1024 s = s:sub(1,-2) if tp == 'M' then fact = 1024*fact end return tostring(tonumber(s)*fact) end) end command_help.show = 'Filter current context with condition and sets temporary context; type "show -h" for more help' local show_usage = [[ usage: show where where fields must come from current context and condition is a Lua expression using these fields. File sizes like 22K and 1.5M are understood. ]] function commands.show (args,flags) local concat = table.concat if #args == 0 then if flags.h then print (show_usage) else check_context() dump_data(context,flags) end return end where_idx = args:index 'where' check_context() if where_idx or #args > 0 then local fields = where_idx and args:slice(1,where_idx-1) or args local res = process_where_clause(context,fields,args,where_idx) if res then prepare_data(res) dump_data(res,flags) show_context = res end else dump_data(context,flags) end end local query_condition_injects = {path} function process_where_clause (cntxt,fieldlist,args,where_idx) local condn,Q if where_idx then condn = args:slice(where_idx+1):join ' ' condn = where_substitution(condn) Q = fieldlist:join ','..' where '..condn else Q = args:join ',' end local query,err = cntxt:select(Q,query_condition_injects) if not query then print('error: '..err) return end -- create a new dataset from our query. This is a useful pattern with the data module. local res = seq.copy_tuples(query) res.fieldnames = fieldlist return res end ------------ Custom Lexical Scanner ------------------- local yield = coroutine.yield local matches = { -- not interested in space... {'^%s+',function(t) return end}, -- common pattern with expanded path with spaces next to filename { '^"[^"]+"%S+', function(t) return yield('string',t:sub(2):gsub('"','')) end}, -- a double-quoted string {'^"[^"]+"',function(t) return yield('string',t:sub(2,-2)) end}, -- otherwise, just a token {'^%S+',function(t) return yield('token',t) end}, } local function scanner (line) return lexer.scan(line,matches,{}) end --------------------------------------------------------- -- [variable expansion] First search the environment, then look at any luash variables. -- Quote anything with spaces, and return the empty string if not found (like sh) -- If a variable is a function, then it is called for the string value. Note that -- the resulting string is not quoted, since it may not be a file path. -- If it in addition returns an extra value of 'true', then the value is cached, -- replacing the function with the returned value. expandvars = function (v) local subst,status if v:isdigit() then status,subst = get_value_from_context(v,'file') if not status then status,subst = get_value_from_context(v,'path') end if not status then errorsh('no file or path') end print(v..' = '..subst) else subst = os.getenv(v) or variables[v] if type(subst) == 'function' then subst,status = subst() if status then variables[v] = subst end return subst end end return quote_if_necessary(subst) end local function expanduser (v) return v:at(1)..quote_if_necessary(path.expanduser(v:at(2))) end function expand_string (line) line = line:gsub('%$([%w_]+)',expandvars) line = line:gsub('%s~',expanduser) return line:gsub('([%$~]) ','%1') end function substitute_alias_parameters (tok,subst,tn,nxt) local parms = {} -- [alias parameters] An alias definition may have parameters -- of the form @1,@2,etc. return subst:gsub('@%d+',function(s) local t,v if tn then t,v = tn,nxt tn = nil parms[s] = v elseif parms[s] then v = parms[s] else t,v = tok() parms[s] = v end if not v then errorsh('expecting alias parameter!') end return quote_if_necessary(v) end) end local fields function process_command_line (line,prompt) local tn,nxt,t,cmd,tok,eline line = line:strip() eline = raise_event ('command',line) if eline then line = eline end if line == '' or line:at(1) == '#' then return true end line = expand_string(line) tok = scanner(line) t,cmd = tok() if not lexer.finished then tn,nxt = tok() end -- [aliases] 'name is subst': any alias is substituted before command processing. -- 'a is' will remove an existing alias. if nxt == 'is' then if commands[cmd] then print('cannot mask a Lua command') else local val = getrest(tok):lstrip() if val == '' and alias[cmd] then val = nil end alias[cmd] = val end elseif nxt == '=' then -- [variable assignment] is to the rest of the line. -- Numerical values will be converted, if possible -- 'var =' will remove a variable, if defined. local val = getrest(tok):lstrip() if val == '' and variables[cmd] then variables[cmd] = nil else val = tonumber(val) or val variables[cmd] = val end else if alias[cmd] then local subst = alias[cmd] -- [alias data specification] if a non-Lua command outputs text in the correct format -- for the data reader, then one can specify the fields used. local res = {} if match('@(fields=$() $',subst,res) then fields = res[1] subst = res[2] end subst = expand_string(subst) if tn then --lexer.insert(tok,tn,nxt) local k subst,k = substitute_alias_parameters(tok,subst,tn,nxt) if k > 0 then tn = nil end end local tokens = seq.copy2(scanner(subst)) if tn then append(tokens,{tn,nxt}) end lexer.insert(tok,tokens) _,cmd = tok() elseif tn then lexer.insert(tok,tn,nxt) end -- [Command line parsing for Lua commands] -- A parameter begining with '-' is considered a flag. If it further -- contains '=' then split into flag name and value. These go into the -- flags argument. otherwise, parms go into the args argument. -- The flags table always contains a field _interactive, which is not -- true in batch mode. local fn = commands[cmd] local args,flags = {},{_interactive=prompt} if not fn then append(args,quote_if_necessary(cmd)) end local tn,v = tok() while v do if fn then if v:at(1)=='-' then v = v:sub(2) local parm,value = v:splitv('=') if value then flags[parm] = value else flags[v] = true end else append(args,v) end else if tn == 'string' then v = '"'..v..'"' end append(args,v) end tn,v = tok() end if fn then local status,res,rtype = pcall(fn,List(args),flags) if not status then print('error: '..(res:match('!(.+)') or res)) return prompt -- basically kills us if we're not interactive else if res then set_context(res,rtype,flags) end end elseif cmd == 'exit' then return false else local line = concat(args,' ') -- [non-blocking execution] Use '&' like sh. On Windows this -- results in 'start ' being prepended to the command. if line:at(#line) == '&' and path.is_windows then line = 'start '..line:sub(1,-2) end if variables.echo then print(line) end if not fields then variables.res = exec(line) else local f = io.popen(line,'r') if #fields == 0 then fields = nil end local d,err = data.read(f,{fieldnames=fields,last_field_collect=true}) f:close() fields = nil if d then set_context(d,nil,{_interactive=true}) else print('data: '..err) end end end end return true end function run_shell (f,prompt) local ln,iter = 1 if prompt then raise_event('interactive-start') iter = term_in(prompt) else iter = f:lines() end for line in iter do if TEST then process_command_line(line,prompt) else local status,err = pcall(process_command_line,line,prompt) if not status then errorsh (err,true) -- keep going if we're interactive, otherwise bail out if not prompt then print('error occured at line '..ln) break end else if not err then break end end end ln = ln + 1 end if prompt then raise_event('interactive-end') end end function run_script (script) local f,err = io.open(script) if not f then return print(err) end run_shell(f) f:close() end if not TEST then local luashrc = path.expanduser(path.join('~','.luashrc')) local profile = io.open(luashrc) if profile then run_shell (profile) profile:close() end commands.load {'persist'; n = true} if not alias.edit then alias.edit = path.is_windows and 'start notepad' or 'vim' end raise_event('shell-load') -- necessary to initialize them here, otherwise persist will clobber them. variables.LUASHRC = luashrc variables.files = files if script then run_script(script) else run_shell(io.stdin,'$> ') end raise_event('shell-unload') else s = [[ expr is lua -e print(@1) expr 10+20 ]] run_shell(stringio.open(s)) end