#!/usr/bin/env ruby # diru is a tool for moving around in the users directory # structure. diru provides input for the shell cd command for entering # a new directory. However some commands do not produce input for cd. # # diru has hub (daemon) which creates new service (server) threads for # diru clients. Before user can start using diru, a server should be # created. # # diru has server side and client side. The server maintains # information about users directories and also the state of the # queries issued by the client. Client is what the end user uses. # # diru client command has to be integrated to the currently used # shell in order to force the shell to change the current # directory. Lets assume that the shell alias/function that user is # actually using is "dr" for now. If diru returns success, then the # diru output should be used as new directory. If diru status is # failure, then directory should not be changed. # # Example of diru use: # shell> dr / build src # # This command means that the client sends to the server a request for # directory search. The search starts from Diru root ("/") and the # rest of the path should include the words "build", and # "src". If path with this spec is found, the shell directory gets # updated. # # Perform: # shell> dr @ doc # or # shell> dr i # # for some documentation. # # Diru setup: # # Start hub (deamon) # * shell> diru --hub # # Open server for client. # * shell> diru -s # # Use client # * shell> diru -p 41115 -c r # # # Feature list: # * User root dir (downwards) search # * Current directory (downwards) dir search # * Bookmarking directories # * Left-over search with multiple matches # * Fast jump to specific common directories # * Peer dir jump # * Server state reset # * Revert mode, unknown commands to cd # require 'como' include Como require 'drb' require 'yaml' require 'fileutils' # require 'byebug' Spec.command( 'diru', 'Tero Isannainen', '2017', [ [ :switch, 'hub', nil, "H: Start hub." ], [ :opt_single, 'hport', nil, "H: Hub port (default: DIRU_HUB_PORT or 41114)." ], [ :switch, 'hkill', nil, "H: Kill hub." ], [ :switch, 'nodaemon', nil, "H: No daemon for server." ], [ :opt_any, 'server', '-s', "S: Open server for user (default: $USER)." ], [ :opt_single, 'kill', '-k', "S: Close server." ], [ :switch, 'list', '-l', "S: List servers." ], [ :opt_single, 'options', '-o', "SC: Options File." ], [ :opt_single, 'port', '-p', "SC: Server port for client (default: DIRU_PORT or ~/.diru.prt)." ], [ :switch, 'change', '-c', "C: Change dir (i.e. target is to change dir)." ], [ :exclusive, 'template', '-t', "C: Display Options File template." ], [ :exclusive, 'cmddoc', '-d', "C: Display command documentation." ], [ :default, nil, nil, "C: Client commands (try: diru i)." ], ] ) # ------------------------------------------------------------ # Common: # ------------------------------------------------------------ # Diru common features. module Diru # Default port. DIRU_HUB_PORT = ENV['DIRU_HUB_PORT'] || 41114 # Load yaml configuration file (if exists). def Diru.load_conf( conf_file ) conf = {} if File.exist?( conf_file ) conf = YAML.load( File.read( conf_file ) ) end conf end # See: Diru.load_conf def load_conf( conf_file ) @conf = Diru.load_conf( conf_file ) end # Error message display. def Diru.error( msg ) STDERR.puts "Diru Error: #{msg}" exit( false ) end # Warning message display. def Diru.warn( msg ) STDERR.puts "Diru Warning: #{msg}" end # List the dir content (default: pwd). def Diru.list( dir = '.', glob = '*' ) Dir.glob( "#{dir}/#{glob}" ) end # List the dir files (default: pwd). def Diru.list_files( dir = '.', glob = '*' ) Diru.list( dir, glob ).select do |i| File.file?(i) == true end end # Find file entry from directory hierarchy upwards. def Diru.find_upper_file( file, dir = Dir.pwd ) found = Diru.list_files( dir, file ) if found.empty? dir = File.dirname( dir ) if dir == "/" raise RuntimeError, "Could not find file \"#{file}\"!" else return Diru.find_upper_file( file, dir ) end else return found[0] end end # Find file entry from directory hierarchy upwards, exit if not found. def Diru.must_find_upper_file( file, dir = Dir.pwd ) begin Diru.find_upper_file( file, dir ) rescue RuntimeError STDERR.puts "Could not find file \"#{file}\"!" exit( false ) end end def Diru.get_opts_file( file = nil ) opts_file = nil if Opt['options'].given opts_file = Opt['options'].value elsif file opts_file = file elsif ENV['DIRU_OPTS'] opts_file = ENV['DIRU_OPTS'] else opts_file = "#{ENV['HOME']}/.diru.yml" end opts_file end end # Diru Server State. class Search include Diru def initialize( root, user, opts_file = nil ) # Server root. @root = root if @root == '/' @rootpart = '' else @rootpart = @root end # Change directory to DIRU root. Dir.chdir( @root ) # User of server. @user = user # List of old matches. @old = [] # Numbered bookmarks. @book = [] # Numbered history. @hist = [] # Favorites. @fav = {} # History limit. @histlimit = 20 # Sync period for options (and data if no @dsync). @sync = 10 # Sync period for data. @dsync = nil # Options File. @opts_file = opts_file # Temporary storage (scratch pad). @scratch = nil # Glob pattern for DB build. @glob = "**/*" @logging = false # if Opt['log'].given # @logging = true # end # Data update thread. @th_data = nil # Options update thread. @th_opts = nil # Lock for DB access. @datalock = Mutex.new @optslock = Mutex.new # Initial data update. update_conf update_data start_th_data start_th_opts end # Return opts_file. def opts_file @opts_file end # Return root. def root @root end # Return user. def user @user end # Add bookmark (if not already). def abook( dir ) idx = @book.index dir unless idx @book.unshift dir end end # Delete bookmark. def dbook( idx ) @book.delete_at idx end # Get bookmark. def gbook( idx ) @book[ idx ] end # Get all bookmarks. def book @book end # Reset bookmarks. def rbook @book = [] end # Save bookmarks to file. def savebook( file ) File.write( file, @book.join("\n") + "\n" ) end # Load bookmarks from file. def loadbook( file ) @book = File.read( file ).split("\n") end # Add history. def ahist( dir ) if dir != @hist[0] if @hist.length >= @histlimit @hist = [ dir ] + @hist[ 0..(@histlimit-2) ] else @hist = [ dir ] + @hist end end end # Get history. def ghist( idx ) @hist[ idx ] end # Get all historys. def hist @hist end # Reset history. def rhist @hist = [] end # Get (and update) favorite. def gfav( tag ) "#{@rootpart}/#{@fav[ tag ]}" end # Return the whole fav. def fav @fav end # Update options. def update_conf if @opts_file load_conf( @opts_file ) if @conf[:dsync] && @conf[:dsync] >= 0 @dsync = @conf[:dsync] end if @conf[:sync] && @conf[:sync] >= 1 @sync = @conf[:sync] @dsync = @sync unless @dsync end if @dsync == 0 kill_th_data else unless @th_data start_th_data end end if @conf[:hist] && ( @conf[:hist] >= 2 ) @histlimit = @conf[:hist] else @histlimit = 20 end if @conf[ :favs ] @fav = @conf[ :favs ] end end end # Set scratch dir. def settmp( dir ) @scratch = dir end # Get scratch dir. def gettmp @scratch end def abs2rel( path ) if path == @root '' else rem = "#{@rootpart}/" rel = path.dup rel[rem] = '' rel end end # Match dir/pattern with glob (fnmatch) from DB. def match( dir, pattern ) list = [] if dir begin dir = abs2rel( dir ) rescue return [] end r = dir + '*' + pattern else r = pattern end @datalock.synchronize do @data.each do |i| if File.fnmatch( r, i ) list.push "#{@rootpart}/#{i}" end end end # Update old list. @old = list.rotate list end # Match dir/pattern with regex from DB. def rematch( dir, pattern ) list = [] if dir begin dir = abs2rel( dir ) rescue return [] end r = dir + '.*' + pattern else r = pattern end @datalock.synchronize do @data.each do |i| if i.match( r ) list.push "#{@rootpart}/#{i}" end end end # Update old list. @old = list.rotate list end # Get next match from old. def next ret = @old[0] @old.rotate! ret end # Make directory. def mk_dir( dir ) rel = abs2rel( dir ) unless @data.index( rel ) @data = @data.push( rel ).sort FileUtils.mkdir_p dir end end # Make directory in sync. def mk_dir_sync( dir ) @datalock.synchronize do mk_dir dir end end # Remove directory. def rm_dir( dir ) rel = abs2rel( dir ) if @data.index( rel ) @data.delete( rel ) FileUtils.rmdir dir end end # Remove directory in sync. def rm_dir_sync( dir ) @datalock.synchronize do rm_dir dir end end # Update directory DB. # # Relative dir data under Project root. # test # test/dir_0 # ... def update_data @data = Dir.glob( @glob ).select{|ent| File.directory?( ent )} @data = @data.sort end # Update DB with MT sync. def update_data_sync @datalock.synchronize do update_data end end # Update DB with MT sync. def update_opts_sync @optslock.synchronize do update_conf end end # Kill search and cleanup resources. def kill kill_th_data kill_th_opts end # Reset dynamic state. def reset log "reset" @old = [] @book = [] @hist = [] update_data_sync end # Start data thread. def start_th_data unless @dsync == 0 @th_data = Thread.new do loop do # puts "data update..." sleep @dsync update_data_sync end end end end # Kill data thread. def kill_th_data Thread.kill( @th_data ) if @th_data @th_data = nil end # Start opts thread. def start_th_opts @th_opts = Thread.new do loop do sleep @sync update_opts_sync end end end # Kill opts thread. def kill_th_opts Thread.kill( @th_opts ) if @th_opts @th_opts = nil end # Enable logging. def logon @logging = true end # Disable logging. def logoff @logging = false end # Log events. def log( msg ) if @logging fh = File.open( "#{ENV['HOME']}/.diru.log", "a" ) fh.puts "#{Time.timestamp()}: #{msg}" fh.close end end end # ------------------------------------------------------------ # Server program. # ------------------------------------------------------------ # Diru hub. class Hub # Start in daemon or direct mode. def Hub.start( port, daemon = true ) if daemon p = fork do begin Hub.new( port ) rescue Diru.error "Could not start Hub!" exit( false ) end end Process.detach( p ) else begin Hub.new( port ) rescue Diru.error "Could not start Hub!" exit( false ) end end end # Server collection. @@servers = {} attr_reader :port def initialize( port ) @port = port # Start the service @hub = DRb.start_service( "druby://localhost:#{@port}", self ) DRb.thread.join end # Kill hub. def kill @th = Thread.new do STDERR.puts "Diru Hub: Exiting sooooon..." sleep 3 kill_servers sleep 3 STDERR.puts "Diru Hub: Exit done..." @hub.stop_service exit( false ) end end # Create new server and return port for the server. def get_server( root, user, opts_file ) 50.times do |i| if @@servers[ @port+1+i ] == nil port = @port+1+i s = Service.new( root, user, port, opts_file ) @@servers[ port ] = s return port end end 0 end # List all server ports. def list_servers @@servers.keys.map{ |i| [ i, @@servers[i].root, @@servers[i].user ] } end # Kill all servers. def kill_servers @@servers.keys.each do |s| kill_server( s ) end @@servers = {} end # Kill server with the given port. def kill_server( s_port ) s = @@servers[ s_port ] if s s.kill @@servers.delete( s_port ) else nil end end end # diru client service (server) thread. class Service attr_reader :root attr_reader :user # Initialize and start service. def initialize( root, user, port, opts_file ) @root = root @user = user @port = port @drb = nil @opts_file = opts_file start self end # Kill service. def kill @search.kill Thread.kill( @th ) @drb.stop_service end # Start service. def start @th = Thread.new do # Create "front" object. @search = Search.new( @root, @user, @opts_file ) # Start the service @drb = DRb::DRbServer.new( "druby://localhost:#{@port}", @search ) @drb.thread.join end end end if Opt['template'].given puts %q{--- :hist: 20 :sync: 10 :dsync: 0 :favs: f: dir_0/dir_0_4/dir_0_4_0 g: dir_1/dir_1_2 :peers: - - "(.*/dir_0/.*)/dir_0_2_0" - "\\1" - - "(.*/dir_0/.*)/dir_0_1_0" - "\\1/dir_0_1_1" } exit( false ) end # Keep in sync with README. if Opt['cmddoc'].given STDERR.puts %q{ Search commands: * "dr r" - change to Project root dir. * "dr r " - change to (somewhere) under Project root dir (glob). * "dr t " - change to (somewhere) under current dir (glob). * "dr e " - change to (somewhere) under Project root dir (regexp). Bookmark commands: * "dr b" - display bookmarks. * "dr b ." - add current dir to bookmarks. * "dr b !" - reset (clear) bookmarks. * "dr b s " - store bookmarks to . * "dr b l " - load bookmarks from . * "dr b d " - delete bookmark with . * "dr b " - change dir to bookmark . History commands: * "dr h" - display history. * "dr h ." - add current dir to history. * "dr h !" - reset (clear) history. * "dr h ," - reference last history item. * "dr h " - change dir to history . Scratch Pad commands: * "dr s ." - store current dir to Scratch Pad. * "dr s " - store to Scratch Pad. * "dr s" - change dir to Scratch Pad dir. Misc commands: * "dr p" - jump to peer dir, i.e. the peer of current (from options). * "dr c " - issue RPC command to server (reset etc., see below). * "dr f" - list favorites dirs (from options). * "dr f " - change dir to favorite . * "dr d m " - make directory. * "dr d r " - remove directory. * "dr i" - show command info. * "dr " - change to given dir (must be under current). * "dr" - change to next "Left-over" directory. } exit( false ) end hport = nil if Opt['hport'].given hport = Opt['hport'].value.to_i else hport = Diru::DIRU_HUB_PORT end if Opt['hub'].given # Hub: h = Hub.start( hport, !Opt['nodaemon'].given ) exit( true ) end if false # For hub maintenance (irb): require 'drb' hub = DRbObject.new( nil, "druby://localhost:41114" ) hub.list_servers end if false # Test client require 'drb' @port = 41115 @search = DRbObject.new( nil, "druby://localhost:#{@port}" ) end if Opt['hkill'].given begin hub = DRbObject.new( nil, "druby://localhost:#{hport}" ) hub.kill rescue Diru.error "Could not kill Hub!" end exit( true ) end if Opt['server'].given # Setup client server. hub = DRbObject.new( nil, "druby://localhost:#{hport}" ) root = nil opts_file = nil if ENV['DIRU_ROOT'] root = ENV['DIRU_ROOT'] else # First search for .diru_root_dir file. begin root = File.dirname( Diru.find_upper_file( '.diru_root_dir' ) ) rescue root = nil end unless root # Next search for .diru.yml file. begin yml_file = Diru.find_upper_file( '.diru.yml' ) root = File.dirname( yml_file ) opts_file = yml_file rescue Diru.error "Could not find user directory root!" end end end opts_file = Diru.get_opts_file( opts_file ) if Opt['server'].value.any? user = Opt['server'].value[0] else user = ENV['USER'] end begin s_port = hub.get_server( root, user, opts_file ) rescue Diru.error "Access to Hub failed!" end if s_port == 0 Diru.error "Could not start server!" else # File.write( port_file, s_port.to_s ) if port_file puts "Using server port: #{s_port}..." end exit( true ) end if Opt['kill'].given hub = DRbObject.new( nil, "druby://localhost:#{hport}" ) port = Opt['kill'].value.to_i begin hub.kill_server port rescue Diru.error "Access to Hub failed!" end exit( true ) end if Opt['list'].given hub = DRbObject.new( nil, "druby://localhost:#{hport}" ) begin hub.list_servers.each do |i| puts format( "%-12.d %-12s %s", i[0], i[2], i[1] ) end rescue Diru.error "Access to Hub failed!" end exit( true ) end # ------------------------------------------------------------ # Client program. # ------------------------------------------------------------ class Client include Diru attr_reader :pwd attr_accessor :enable_out def initialize( port ) @port = port # Create a DRbObject instance that is connected to server. All methods # executed on this object will be executed to the remote one. @search = DRbObject.new( nil, "druby://localhost:#{@port}" ) load_conf( @search.opts_file ) @pwd = Dir.pwd @enable_out = true end # Perform cd (i.e. return true) and store current dir to history. def do_cd( dir ) # Store current to history before change. @search.ahist( @pwd ) # Local copy of new dir for command-sequence mode. @pwd = dir # Directory info for shell, unless disabled for # command-sequence mode. puts dir if @enable_out true end # No directory change, i.e. return false. def no_cd false end # Process command. def command( input ) if input.empty? # Refer to last search list. do_cd @search.next else cmd = input[0] arg = input[1..-1] # Shell special chars to avoid: # * ? [ ] ' " \ $ ; & ( ) | ^ < > # Thus allowed: # ! % + , - . / : = @ _ ~ # ^ ^ ^ ^ ^ ^ ^ ^ ^ # Decode user command. ret = \ case cmd when 'r'; if arg.empty? disp @search.root else disp @search.match( nil, "*"+arg.join('*')+"*" ) end when 't'; disp @search.match( @pwd, "*"+arg.join('*')+"*" ) when 'e'; disp @search.rematch( nil, arg.join('.*') ) when 'q'; disp @search.rematch( nil, arg.join('.*') ) when 'b'; if arg[0] == nil lbook( @search.book ) no_cd elsif arg[0] == '.' @search.abook( @pwd ) no_cd elsif arg[0] == '!' @search.rbook no_cd elsif arg[0] == 's' @search.savebook( arg[1] ) no_cd elsif arg[0] == 'l' @search.loadbook( arg[1] ) no_cd elsif arg[0] == 'd' @search.dbook( arg[1].to_i ) no_cd else disp @search.gbook( arg[0].to_i ) end when 'h'; if arg[0] == nil lbook( @search.hist ) no_cd elsif arg[0] == '.' @search.ahist( @pwd ) no_cd elsif arg[0] == '!' @search.rhist no_cd elsif arg[0] == ',' disp @search.ghist( 0 ) else disp @search.ghist( arg[0].to_i ) end when 's'; if arg[0] == nil disp @search.gettmp elsif arg[0] if arg[0] == '.' dir = @pwd else dir = arg[0] end disp @search.settmp( dir ) no_cd else no_cd end when 'p'; disp peer when 'c'; rpc( arg ) when 'f'; if arg[0] == nil @search.fav.each do |k,v| STDERR.puts format( "%-6s %s", k, v ) end no_cd else fav_map( arg ) end when 'd'; # Mkdir: # dr d m # Rmdir: # dr d r if arg.length == 2 if arg[0] == 'm' abs = File.absolute_path( arg[1] ) if abs.match( @search.root ) # New subdir to Project. @search.mk_dir_sync( abs ) else FileUtils.mkdir_p dir end elsif arg[0] == 'r' abs = File.absolute_path( arg[1] ) if abs.match( @search.root ) # New subdir to Project. @search.rm_dir_sync( abs ) else FileUtils.rmdir dir end end end no_cd when 'i'; rpc( [ 'doc' ] ) else if Opt['change'].given all = [cmd] + arg do_cd "#{all.join(' ')}" else no_cd end end # exit( ret ) return ret end end # Display directories. One to STDOUT and others to STDERR. def disp( resp ) if resp == nil no_cd elsif resp.kind_of? Array if resp.any? if resp.length > 1 resp[1..-1].each do |i| STDERR.puts i end end do_cd resp[0] else Diru.warn "Dir not found!" no_cd end else do_cd resp end end # Display directories without CD. def show( resp ) if resp == nil # NOP elsif resp.kind_of? Array if resp.any? resp.each do |i| STDERR.puts i end end else STDERR.puts resp end no_cd end # Get peer directory. Search list of re-pairs. Match either of the # pair, and switch to the pair dir. # def peer # cur = Dir.pwd cur = @pwd peers = @conf[ :peers ] peers.each do |pair| re = Regexp.new( pair[0] ) if ( re.match( cur ) ) return cur.sub( re, pair[1] ) end end nil end # Display short command doc. def doc STDERR.puts " r - search from root (or to root) t - search from this (current) e - search from root (with regexp) q - query from root (with regexp) b - bookmark access h - history access s - scratch pad access p - peer of current c - command (reset, doc etc.) f - favorites d - directory mutation i - short info " end # List bookmarks with index number, in reverse order. def lbook( book ) idx = book.length-1 (0..idx).to_a.reverse.each do |i| STDERR.puts format( "%2d: %s", i, book[i] ) end end # Remove Procedure Call towards server. # # Example: # shell> dr @ rbook # def rpc( arg ) case arg[0] when 'reset'; @search.reset when 'abook'; @search.abook( @pwd ) when 'lbook'; lbook( @search.book ) when 'rbook'; @search.rbook when 'doc'; doc when 'sync'; @search.update_opts_sync when 'dsync'; @search.update_data_sync else return no_cd end no_cd end # Direct directory jump. def fav_map( arg ) ret = @search.gfav( arg[0] ) if ret disp ret else no_cd end end end if Opt['port'].given port = Opt['port'].value.to_i elsif ENV['DIRU_PORT'] port = ENV['DIRU_PORT'].to_i elsif File.exist?( "#{ENV['HOME']}/.diru.prt" ) port = File.read( "#{ENV['HOME']}/.diru.prt" ).to_i else Diru.error "Server port info missing..." end # User command content. input = Opt[nil].value begin client = Client.new( port ) rescue Diru.error "Server not available!" exit( false ) end # Collect command-sequence (if any). cmds = [] si = 0 ei = 0 while ( ei = input.index "," ) cmds.push input[si...ei] si = ei+1 input = input[si..-1] end # Add the tail command, i.e. the only if no "+" used. cmds.push input ret = false # Disable shell output for all dir changes except last. if cmds.length > 1 client.enable_out = false end cmds.each_with_index do |cmd, i| if i == cmds.length-1 # Enable output for shell for the last dir change. client.enable_out = true end begin ret = client.command( cmd ) rescue Diru.error "Command failure!" exit( false ) end end exit( ret )