csc360-a1-shell/ruby/main.rb

175 lines
4.4 KiB
Ruby
Executable file

#!/usr/bin/env ruby -w
require 'English'
require 'open3'
require 'readline'
require 'wordexp'
require './builtins'
require './colours'
require './logger'
require './job'
class Shell
attr_reader :logger, :options
def initialize(args = ARGV)
@builtins = Builtins.new(self)
@jobs_by_pid = {}
@logger = Logger.instance
@options = parse_options(args)
logger.verbose "Options: #{options.inspect}"
end
def main
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)
Readline::HISTORY.pop if line.nil? || line.strip.empty?
status = process_command(line)
end
end
end
# Looks like this: /path/to/somewhere%
def prompt(pwd)
"#{BLUE}#{pwd}#{WHITE}% #{CLEAR}"
end
def parse_options(args)
options = {
verbose: false,
}
while (arg = args.shift)
case arg
when '-c'
options[:command] = args.shift
when '-v', '--verbose'
options[:verbose] = true
else
logger.warn "#{RED}[ERROR]#{CLEAR} Unknown argument: #{arg}"
exit 1
end
end
options
end
def trap_sigchld
# handler for SIGCHLD when a child's state changes
Signal.trap('CHLD') do |_signo|
pid = Process.waitpid(-1, Process::WNOHANG)
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
warn "\n#{YELLOW}[WARN]#{CLEAR} No job found for child with PID #{pid}"
end
end
end
def print_logs
logger.logs.each do |log|
message = "#{log.message}#{CLEAR}"
case log.level
when :verbose
warn message if options[:verbose]
else
warn message
end
end
logger.clear
end
def process_command(line)
exit 0 if line.nil? # EOF, ctrl-d
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}"
if @builtins.builtin?(cmd)
logger.verbose "Executing builtin #{cmd}"
@builtins.exec(cmd, args)
else
logger.verbose "Shelling out for #{cmd}"
exec_command(cmd, args)
end
rescue Errno => e
warn "#{RED}[ERROR]#{CLEAR} #{e.message}"
-1
end
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
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
Shell.new(ARGV).main if $PROGRAM_NAME == __FILE__