csc360-a1-shell/ruby/shell/repl.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

88 lines
2.4 KiB
Ruby

begin
require "readline"
rescue LoadError
require "reline"
end
require "shell/builtins"
require "shell/colours"
require "shell/job_control"
require "shell/logger"
require "shell/string_parser"
require "shell/word_expander"
module Shell
class REPL
include Colours
attr_reader :builtins, :job_control, :logger, :options, :word_expander
attr_accessor :precmd_hook
def initialize(builtins: nil, job_control: nil, logger: nil, word_expander: nil)
logger ||= Logger.instance
job_control ||= JobControl.new(logger: logger)
builtins ||= Builtins.new(job_control: job_control)
word_expander ||= WordExpander.new
@builtins = builtins
@job_control = job_control
@logger = logger
@options = {}
@word_expander = word_expander
end
def start(options: nil)
@options = options || {}
job_control.trap_sigchld
add_to_history = true
status = 0
loop do
precmd_hook&.call
print "#{red(status)} " 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
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}"
commands = parse_line(line)
result = 0
commands.each do |entry|
case entry
in StringParser::Command[text:, op:]
command = text
next if command.strip.empty?
next if op == :and && result != 0
args = word_expander.expand(command)
program = args.shift
logger.verbose "Parsed command: #{program} #{args.inspect}"
if builtins.builtin?(program)
logger.verbose "Executing builtin #{program}"
result = builtins.exec(program, args)
else
logger.verbose "Shelling out for #{program}"
result = job_control.exec_command(program, args)
end
else
raise ArgumentError, "Unknown parsed command node: #{entry.inspect}"
end
end
result
rescue => e
warn "#{red("[ERROR]")} #{e.message}"
-1
end
# Looks like this: /path/to/somewhere%
def prompt(pwd) = "#{blue(pwd)}#{white("%")} #{CLEAR}"
def parse_line(line) = StringParser.split_commands(line)
end
end