diff --git a/ruby/.rubocop.yml b/ruby/.rubocop.yml new file mode 100644 index 0000000..cb4c688 --- /dev/null +++ b/ruby/.rubocop.yml @@ -0,0 +1,26 @@ +AllCops: + NewCops: enable + +Layout/EmptyLineAfterGuardClause: + Enabled: false + +Layout/FirstHashElementIndentation: + EnforcedStyle: consistent + +Metrics/AbcSize: + Max: 25 + +Metrics/MethodLength: + Max: 20 + +Style/Documentation: + Enabled: false + +Style/FrozenStringLiteralComment: + Enabled: false + +Style/HashSyntax: + EnforcedShorthandSyntax: never + +Style/TrailingCommaInHashLiteral: + EnforcedStyleForMultiline: consistent_comma diff --git a/ruby/Gemfile b/ruby/Gemfile new file mode 100644 index 0000000..60f3b06 --- /dev/null +++ b/ruby/Gemfile @@ -0,0 +1,2 @@ +source 'https://rubygems.org' +ruby '3.1.0' diff --git a/ruby/Gemfile.lock b/ruby/Gemfile.lock new file mode 100644 index 0000000..395f44b --- /dev/null +++ b/ruby/Gemfile.lock @@ -0,0 +1,14 @@ +GEM + remote: https://rubygems.org/ + specs: + +PLATFORMS + arm64-darwin-21 + +DEPENDENCIES + +RUBY VERSION + ruby 3.1.0p0 + +BUNDLED WITH + 2.3.3 diff --git a/ruby/builtins.rb b/ruby/builtins.rb new file mode 100644 index 0000000..0881e39 --- /dev/null +++ b/ruby/builtins.rb @@ -0,0 +1,31 @@ +class Shell + def exec_builtin(name, args) + send(:"builtin_#{name}", args) + end + + def builtin?(name) + respond_to?(:"builtin_#{name}") + end + + ################# + ### Built-ins ### + ################# + + def builtin_cd(args) + Dir.chdir args.first + end + + def builtin_export(args) + # only supports one variable and doesn't support quoting + name, *value_parts = args.first.strip.split('=') + if name.nil? || name.empty? + logger.warn "#{RED}[ERROR]#{CLEAR} Invalid export command" + else + ENV[name] = value_parts.join('=').gsub(/\$\w+/) { |m| ENV[m[1..]] || '' } + end + end + + def bulitin_pwd(_args) + puts Dir.pwd + end +end diff --git a/ruby/colours.rb b/ruby/colours.rb new file mode 100644 index 0000000..a48cbbe --- /dev/null +++ b/ruby/colours.rb @@ -0,0 +1,9 @@ +class Shell + # These colours should be safe on dark and light backgrounds. + BLUE = "\033[1;34m".freeze + GREEN = "\033[1;32m".freeze + YELLOW = "\033[1;33m".freeze + RED = "\033[1;31m".freeze + WHITE = "\033[1;37m".freeze + CLEAR = "\033[0;m".freeze +end diff --git a/ruby/job.rb b/ruby/job.rb new file mode 100644 index 0000000..682f37b --- /dev/null +++ b/ruby/job.rb @@ -0,0 +1,3 @@ +class Shell + Job = Struct.new(:id, :cmd) +end diff --git a/ruby/main.rb b/ruby/main.rb new file mode 100755 index 0000000..28e3e96 --- /dev/null +++ b/ruby/main.rb @@ -0,0 +1,120 @@ +#!/usr/bin/env ruby -w + +require 'English' +require 'open3' + +require './builtins' +require './colours' +require './shell_logger' +require './job' + +class Shell + attr_reader :logger, :options + + def initialize(args) + @logger = ShellLogger.new + @options = parse_options(args) + @jobs_by_pid = {} + logger.verbose "options: #{options.inspect}" + end + + def main + # this breaks Open3.capture3 so hold off until we fork + exec + # trap_sigchld + if options[:command] + logger.verbose "Executing command: #{options[:command]}" + print_logs + exit exec_command(options[:command]) + elsif $stdin.isatty + loop do + # TODO: use readline instead + print_logs + print prompt(Dir.pwd) + process_command(gets&.chomp) + 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| + logger.info "SIGCHLD #{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}" + else + logger.warn "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 + puts message if options[:verbose] + when :warning + warn message + else + puts message + end + end + logger.clear + end + + def process_command(cmd) + # TODO: proper word splitting, pass arrays to built-ins + args = cmd&.split + argv0 = args&.first + case argv0 + when nil, 'exit' + exit 0 + when '' + # noop + when builtin?(argv0) + args.shift + exec_builtin(argv0, args) + else + status = exec_command(cmd) + print "#{RED}-#{status}-#{CLEAR} " unless status.zero? + end + # TODO: add to readline history + end + + def exec_command(cmd) + # TODO: background execution using fork + exec, streaming output + out, err, status = Open3.capture3(cmd) + puts out.chomp unless out.empty? + warn err.chomp unless err.empty? + status.exitstatus + rescue StandardError => e + logger.warn "#{RED}[ERROR]#{CLEAR} #{e.message}" + 1 + end +end + +Shell.new(ARGV).main if $PROGRAM_NAME == __FILE__ diff --git a/ruby/shell_logger.rb b/ruby/shell_logger.rb new file mode 100644 index 0000000..84ef5b8 --- /dev/null +++ b/ruby/shell_logger.rb @@ -0,0 +1,33 @@ +# 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, message) + end + alias info log + + def warn(message) + @logs << Log.new(:warning, message) + end + + def verbose(message) + @logs << Log.new(:verbose, message) + end + + def clear + @logs = [] + nil + end +end