mirror of
https://github.com/samsonjs/csc360-a1-shell.git
synced 2026-03-25 08:45:52 +00:00
175 lines
4.4 KiB
Ruby
Executable file
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__
|