csc360-a1-shell/ruby/shell/job_control.rb
Sami Samhuri 4f4e97475b
[ruby] Modernize Ruby shell parsing and expansion, add C compat test mode (#4)
Replace Ruby's old wordexp-like command splitting with a tokenizer and
parser that understands ; and && while honoring quotes and nesting.

Implement richer expansions for command substitution, arithmetic,
parameter defaults (${var:-...}), brace expansion, and escaped
dollar/backtick behavior via shared quote-state handling.

Expand the test suite with parser/expansion edge cases, escaping
parity checks, builtin usage validation, and job-control refresh tests.

Keep C green by adding a compat test profile for c/Makefile test and
by returning nonzero on builtin failures in -c mode, including clearer
`bg` usage output.
2026-02-07 15:18:41 -08:00

113 lines
3.2 KiB
Ruby

require "English"
require "shell/colours"
require "shell/job"
require "shell/logger"
module Shell
class JobControl
include Colours
attr_reader :logger
def initialize(logger: nil, refresh_line: nil)
@jobs_by_pid = {}
@logger = logger || Logger.instance
@refresh_line = refresh_line || -> { Readline.refresh_line }
end
def exec_command(cmd, args, background: false)
unless (path = resolve_executable(cmd))
warn "#{red("[ERROR]")} command not found: #{cmd}"
return -2
end
pid = fork { exec([path, cmd], *args) }
if background
job = Job.new(next_job_id, pid, cmd, args)
@jobs_by_pid[pid] = job
puts white("Running job ") + yellow(job.id) + white(" (pid #{pid}) in background")
Process.waitpid(pid, Process::WNOHANG)
0
else
begin
Process.waitpid(pid)
$CHILD_STATUS.exitstatus
rescue Errno::ECHILD => e
# FIXME: why does this happen? doesn't seem to be a real problem
logger.verbose "#{yellow(e.message)} but child was just forked 🧐"
0
end
end
rescue => e
warn "#{red("[ERROR]")} #{e.message} #{e.inspect}"
-5
end
def kill(job_id)
job = @jobs_by_pid.values.detect { |j| j.id == job_id }
if job.nil?
logger.warn "No job found with ID #{job_id}"
return
end
Process.kill("TERM", job.pid)
rescue Errno::ESRCH
logger.warn "No such proccess: #{job.pid}"
end
def list
@jobs_by_pid.values.sort_by(&:id)
end
def format_job(job)
args = job.args.join(" ")
"#{yellow(job.id)}: #{white("(pid ", job.pid, ")")} #{green(job.cmd)} #{args}"
end
def trap_sigchld
# handler for SIGCHLD when a child's state changes
Signal.trap("CHLD") do |_signo|
pid = begin
Process.waitpid(-1, Process::WNOHANG)
rescue Errno::ECHILD
nil
end
if pid.nil?
# no-op
elsif (job = @jobs_by_pid[pid])
@jobs_by_pid.delete(pid)
time = Time.now.strftime("%T")
job_text = yellow("job ", job.id, " exited")
args = job.args.join(" ")
puts "\n[#{time}] #{job_text} #{white("(pid ", job.pid, ")")}: #{green(job.cmd)} #{args}"
else
warn "\n#{yellow("[WARN]")} No job found for child with PID #{pid}"
end
@refresh_line.call
end
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
end