diff --git a/ruby/builtins.rb b/ruby/builtins.rb index b162315..4df5efc 100644 --- a/ruby/builtins.rb +++ b/ruby/builtins.rb @@ -2,7 +2,8 @@ class Shell class Builtins attr_reader :logger - def initialize(logger = ShellLogger.instance) + def initialize(shell, logger = Logger.instance) + @shell = shell @logger = logger end @@ -18,8 +19,14 @@ class Shell ### Built-ins ### ################# + def builtin_bg(args) + cmd = args.shift + @shell.exec_command(cmd, args, background: true) + end + def builtin_cd(args) Dir.chdir args.first + 0 end def builtin_export(args) @@ -30,10 +37,12 @@ class Shell else ENV[name] = value_parts.join('=').gsub(/\$\w+/) { |m| ENV[m[1..]] || '' } end + 0 end def bulitin_pwd(_args) puts Dir.pwd + 0 end end end diff --git a/ruby/job.rb b/ruby/job.rb index 682f37b..ff546a0 100644 --- a/ruby/job.rb +++ b/ruby/job.rb @@ -1,3 +1,3 @@ class Shell - Job = Struct.new(:id, :cmd) + Job = Struct.new(:id, :pid, :cmd, :args) end diff --git a/ruby/logger.rb b/ruby/logger.rb new file mode 100644 index 0000000..fab0a21 --- /dev/null +++ b/ruby/logger.rb @@ -0,0 +1,41 @@ +require './colours' + +class Shell + # Queues up messages to be printed out when readline is waiting for input, to prevent + # mixing shell output with command output. + class Logger + Log = Struct.new(:level, :message) + + attr_reader :logs + + def self.instance + @instance ||= new + end + + def initialize + clear + end + + def log(message) + @logs << Log.new(:info, "#{WHITE}[INFO]#{CLEAR} #{message}") + end + alias info log + + def warn(message) + @logs << Log.new(:warning, "#{YELLOW}[WARN]#{CLEAR} #{message}") + end + + def error(message) + @logs << Log.new(:error, "#{RED}[ERROR]#{CLEAR} #{message}") + end + + def verbose(message) + @logs << Log.new(:verbose, "[VERBOSE] #{message}") + end + + def clear + @logs = [] + nil + end + end +end diff --git a/ruby/main.rb b/ruby/main.rb index 0e0efa0..af1e886 100755 --- a/ruby/main.rb +++ b/ruby/main.rb @@ -7,33 +7,35 @@ require 'wordexp' require './builtins' require './colours' -require './shell_logger' +require './logger' require './job' class Shell attr_reader :logger, :options def initialize(args = ARGV) - @builtins = Builtins.new + @builtins = Builtins.new(self) @jobs_by_pid = {} - @logger = ShellLogger.instance + @logger = Logger.instance @options = parse_options(args) logger.verbose "Options: #{options.inspect}" end def main - # this breaks Open3.capture3 so hold off until we fork + exec - # trap_sigchld + trap_sigchld if options[:command] logger.verbose "Executing command: #{options[:command]}" print_logs exit process_command(options[:command]) elsif $stdin.isatty add_to_history = true + status = 0 loop do print_logs + print "#{RED}#{status}#{CLEAR} " unless status.zero? line = Readline.readline(prompt(Dir.pwd), add_to_history) - process_command(line) + Readline::HISTORY.pop if line.nil? || line.strip.empty? + status = process_command(line) end end end @@ -63,13 +65,17 @@ class Shell def trap_sigchld # handler for SIGCHLD when a child's state changes - Signal.trap('CHLD') do |signo| - logger.info "SIGCHLD #{signo}" + Signal.trap('CHLD') do |_signo| pid = Process.waitpid(-1, Process::WNOHANG) - if (job = @jobs_by_pid[pid]) - logger.info "#{YELLOW}#{job.id}#{CLEAR}: #{WHITE}(pid #{pid})#{CLEAR} #{job.cmd}" + if pid.nil? + # no-op + elsif (job = @jobs_by_pid[pid]) + puts "\n#{YELLOW}#{job.id}#{CLEAR}: " \ + "#{WHITE}(pid #{pid})#{CLEAR} " \ + "#{GREEN}#{job.cmd}#{CLEAR} " \ + "#{job.args.inspect}" else - logger.warn "No job found for child with PID #{pid}" + warn "\n#{YELLOW}[WARN]#{CLEAR} No job found for child with PID #{pid}" end end end @@ -88,10 +94,10 @@ class Shell end def process_command(line) - logger.verbose "Processing command: #{line.inspect}" exit 0 if line.nil? # EOF, ctrl-d - return if line.empty? # no input, no-op + return 0 if line.strip.empty? # no input, no-op + logger.verbose "Processing command: #{line.inspect}" args = Wordexp.expand(line) cmd = args.shift logger.verbose "Parsed command: #{cmd} #{args.inspect}" @@ -100,20 +106,69 @@ class Shell @builtins.exec(cmd, args) else logger.verbose "Shelling out for #{cmd}" - status = exec_command(cmd, args) - print "#{RED}-#{status}-#{CLEAR} " unless status.zero? + exec_command(cmd, args) end + rescue Errno => e + warn "#{RED}[ERROR]#{CLEAR} #{e.message}" + -1 end - def exec_command(cmd, args) - # TODO: background execution using fork + exec, streaming output - out, err, status = Open3.capture3(cmd + ' ' + args.join(' ')) - puts out.chomp unless out.empty? - warn err.chomp unless err.empty? - status.exitstatus + def exec_command(cmd, args, background: false) + unless (path = resolve_executable(cmd)) + warn "#{RED}[ERROR]#{CLEAR} command not found: #{cmd}" + return -2 + end + + pid = fork + if pid + # parent + if background + job = Job.new(next_job_id, pid, cmd, args) + @jobs_by_pid[pid] = job + puts "Background job #{job.id} (pid #{pid})" + Process.waitpid(pid, Process::WNOHANG) + 0 + else + begin + Process.waitpid(pid) + $CHILD_STATUS.exitstatus + rescue Errno::ECHILD => e + # FIXME: why does this happen? + warn "#{YELLOW}[WARN]#{CLEAR} #{e.message} but child was just forked 🧐" + -3 + end + end + else + # child + exec([path, cmd], *args) + # if we make it here then exec failed + -4 + end rescue StandardError => e - logger.warn "#{RED}[ERROR]#{CLEAR} #{e.message}" - 1 + warn "#{RED}[ERROR]#{CLEAR} #{e.message} #{e.inspect}" + -5 + end + + # Return absolute and relative paths directly, or searches PATH for a + # matching executable with the given filename and returns its path. + # Returns nil when no such command was found. + def resolve_executable(path_or_filename) + # process absolute and relative paths directly + return path_or_filename if path_or_filename['/'] && \ + File.executable?(path_or_filename) + + filename = path_or_filename + ENV['PATH'].split(':').each do |dir| + path = File.join(dir, filename) + next unless File.exist?(path) + return path if File.executable?(path) + logger.warn "Found #{path} but it's not executable" + end + nil + end + + def next_job_id + (@jobs_by_pid.values.map(&:id).max || 0) + 1 end end diff --git a/ruby/shell_logger.rb b/ruby/shell_logger.rb deleted file mode 100644 index 060b040..0000000 --- a/ruby/shell_logger.rb +++ /dev/null @@ -1,39 +0,0 @@ -require './colours' - -# Queues up messages to be printed out when readline is waiting for input, to prevent -# mixing shell output with command output. -class ShellLogger - Log = Struct.new(:level, :message) - - attr_reader :logs - - def self.instance - @instance ||= new - end - - def initialize - clear - end - - def log(message) - @logs << Log.new(:info, "#{WHITE}[INFO]#{CLEAR} #{message}") - end - alias info log - - def warn(message) - @logs << Log.new(:warning, "#{YELLOW}[WARN]#{CLEAR} #{message}") - end - - def error(message) - @logs << Log.new(:error, "#{RED}[ERROR]#{CLEAR} #{message}") - end - - def verbose(message) - @logs << Log.new(:verbose, "[VERBOSE] #{message}") - end - - def clear - @logs = [] - nil - end -end