require 'colorize' require 'irb/ruby-lex' require 'json' require 'reline' require 'stringio' require 'bencode' require 'socket' ## # An instance of this class is an \REPL session. # # It provides an Alda::REPL::TempScore for you to operate on. # To see what methods you can call in an \REPL session, # see instance methods of Alda::REPL::TempScore. # # The session uses "> " to indicate your input. # Your input should be ruby codes, and the codes will be # sent to an Alda::REPL::TempScore and executed. # # After executing the ruby codes, if the score is not empty, # it is played, and the translated alda codes are printed. # # Note that every time your ruby codes input is executed, # the score is cleared beforehand. To check the result of # your previous input, run puts history. # # Unlike \IRB, this \REPL does not print the result of # the executed codes. Use +p+ or +puts+ if you want. # # +Interrupt+ and +SystemExit+ exceptions are rescued and # will not cause the process terminating. # +exit+ terminates the \REPL session instead of the process. # # To start an \REPL session in a ruby program, use #run. # To start an \REPL session conveniently from command line, # run command alda-irb. # For details about this command line tool, run alda-irb --help. # # $ alda-irb # > p processes.last # {:id=>"dus", :port=>34317, :state=>nil, :expiry=>nil, :type=>:repl_server} # > piano_; c d e f # piano: [c d e f] # > 5.times do # . c # > end # c c c c c # > score_text # piano: [c d e f] # c c c c c # > play # Playing... # > save 'temp.alda' # > puts `cat temp.alda` # piano: [c d e f] # c c c c c # > system 'rm temp.alda' # > exit # # Notice that there is a significant difference between \Alda 1 \REPL and \Alda 2 \REPL. # In short, \Alda 2 has a much more powerful \REPL than \Alda 1, # so it dropped the --history option in the alda play command line interface # ({alda-lang/alda#367}[https://github.com/alda-lang/alda/issues/367]). # It has an nREPL server, and this class simply functions by sending messages to the nREPL server. # However, for \Alda 1, this class maintains necessary information # in the memory of the Ruby program, # and the \REPL is implemented by repeatedly running alda play in command line. # Therefore, this class functions differently for \Alda 1 and \Alda 2 # and you thus should not modify Alda::generation during an \REPL session. # # It is also possible to use this class as a Ruby wrapper of APIs of the \Alda nREPL server # in \Alda 2. # In this usage, you never need to call #run, and you call #message or #raw_message instead. # # repl = Alda::REPL.new # repl.message :eval_and_play, code: 'piano: c d e f' # => nil # repl.message :eval_and_play, code: 'g a b > c' # => nil # repl.message :score_text # => "piano: [c d e f]\ng a b > c\n" # repl.message :eval_and_play, code: 'this will cause an error' # (raises Alda::NREPLServerError) class Alda::REPL ## # The score object used in Alda::REPL. # # Includes Alda, so it can refer to alda commandline. # However, the methods Alda::Score#play, Alda::Score#parse and Alda::Score#export # are still retained instead of being overridden by the included module. # # When you are in an \REPL session, you are actually # in an instance of this class, # so you can call the instance methods down here # when you play with an \REPL. class TempScore < ::Alda::Score include Alda %i[play parse export].each do |meth| define_method meth, Alda::Score.instance_method(meth) end ## # :call-seq: # new(session) -> TempScore # # Creates a new TempScore for the given \REPL session specified by +session+. # It is called in Alda::REPL::new. def initialize session super() @session = session end ## # :call-seq: # to_s -> String # # Overrides Alda::Score#to_s. # Returns the history. # # $ alda-irb # > harmonica_; a b c # harmonica: [a b c] # > guitar_; c g e # guitar: [c g e] # > p to_s # "harmonica: [a b c]\nguitar: [c g e]\n" def to_s @session.history end ## # :call-seq: # clear_history() -> nil # # Clears all the modifications that have been made to the score # and start a new one. # See #score for an example. def clear_history @session.clear_history end alias new clear_history alias new_score clear_history ## # :call-seq: # get_binding() -> Binding # # Returns a Binding for the instance eval local environment of this score. # Different callings of this method will return different bindings, # and they do not share local variables. # This method is called in Alda::REPL::new. # # $ alda-irb # > p get_binding.receiver == self # true def get_binding binding end ## # :call-seq: # score() -> nil # # Print the history (all \Alda code of the score). # # $ alda-irb # > violin_; a b # violin: [a b] # > score # violin: [a b] # > clear_history # > score # > viola_; c # viola: c # > score # viola: c def score print @session.color ? @session.history.blue : @session.history nil end alias score_text score ## # :call-seq: # map() -> nil # # Prints a data representation of the score. # This is the output that you get when you call Alda::Score#parse. def map json = Alda.v1? ? parse : @session.message(:score_data) json = JSON.generate JSON.parse(json), indent: ' ', space: ' ', object_nl: ?\n, array_nl: ?\n puts @session.color ? json.blue : json end alias score_data map ## # :call-seq: # score_events() -> nil # # Prints the parsed events output of the score. # This is the output that you get when you call Alda::Score#parse with output: :events. def score_events json = Alda.v1? ? parse(output: :events) : @session.message(:score_events) json = JSON.generate JSON.parse(json), indent: ' ', space: ' ', object_nl: ?\n, array_nl: ?\n puts @session.color ? json.blue : json end alias quit exit end ## # The host of the nREPL server. Only useful in \Alda 2. attr_reader :host ## # The port of the nREPL server. Only useful in \Alda 2. attr_reader :port ## # Whether the output should be colored. attr_accessor :color ## # Whether a preview of what \Alda code will be played everytime you input ruby codes. attr_accessor :preview ## # Whether to use Reline for input. # When it is false, the \REPL session will be less buggy but less powerful. attr_accessor :reline ## # :call-seq: # new(**opts) -> Alda::REPL # # Creates a new Alda::REPL. # The parameter +color+ specifies whether the output should be colored (sets #color). # The parameter +preview+ specifies whether a preview of what \Alda code will be played # everytime you input ruby codes (sets #preview). # The parameter +reline+ specifies whether to use Reline for input. # # The +opts+ are passed to the command line of alda repl. # Available options are +host+, +port+, etc. # Run alda repl --help for more info. # If +port+ is specified and +host+ is not or is specified to be "localhost" # or "127.0.0.1", then this method will try to connect to an existing # \Alda REPL server. # A new one will be started only if no existing server is found. # # The +opts+ are ignored in \Alda 1. def initialize color: true, preview: true, reline: true, **opts @score = TempScore.new self @binding = @score.get_binding # IRB once changed the API of RubyLex#initialize. Take care of that. @lex = RubyLex.new *(RubyLex.instance_method(:initialize).arity == 0 ? [] : [@binding]) @color = color @preview = preview @reline = reline setup_repl opts end ## # :call-seq: # setup_repl(opts) -> nil # # Sets up the \REPL session. # This method is called in ::new. # After you #terminate the session, # you cannot use the \REPL anymore unless you call this method again. def setup_repl opts if Alda.v1? @history = StringIO.new else @port = (opts.fetch :port, -1).to_i @host = opts.fetch :host, 'localhost' unless @port.positive? && %w[localhost 127.0.0.1].include?(@host) && Alda.processes.any? { _1[:port] == @port && _1[:type] == :repl_server } Alda.env(ALDA_DISABLE_SPAWNING: :no) { @nrepl_pipe = Alda.pipe :repl, **opts, server: true } /nrepl:\/\/[a-zA-Z0-9._\-]+:(?\d+)/ =~ @nrepl_pipe.gets @port = port.to_i Process.detach @nrepl_pipe.pid end @socket = TCPSocket.new @host, @port @bencode_parser = BEncode::Parser.new @socket end nil end ## # :call-seq: # raw_message(contents) -> Hash # # Sends a message to the nREPL server and returns the response. # The parameter +contents+ is a Hash or a JSON string. # # repl = Alda::REPL.new # repl.raw_message op: 'describe' # => {"ops"=>...} def raw_message contents Alda::GenerationError.assert_generation [:v2] contents = JSON.parse contents if contents.is_a? String @socket.write contents.bencode @bencode_parser.parse! end ## # :call-seq: # message(op, **params) -> String or Hash # # Sends a message to the nREPL server with the following format, # with +op+ being the operation name (the +op+ field in the message), # and +params+ being the parameters (other fields in the message). # Then, this method analyzes the response. # If there is an error, raises Alda::NREPLServerError. # Otherwise, if the response contains only one field, return the content of that field (a String). # Otherwise, return the whole response as a Hash. # # repl = Alda::REPL.new # repl.message :eval_and_play, code: 'piano: c d e f' # => nil # repl.message :eval_and_play, code: 'g a b > c' # => nil # repl.message :score_text # => "piano: [c d e f]\ng a b > c\n" # repl.message :eval_and_play, code: 'this will cause an error' # (raises Alda::NREPLServerError) def message op, **params result = raw_message op: Alda::Utils.snake_to_slug(op), **params result.transform_keys! { Alda::Utils.slug_to_snake _1 } if (status = result.delete :status).include? 'error' raise Alda::NREPLServerError.new @host, @port, result.delete(:problems), status end case result.size when 0 then nil when 1 then result.values.first else result end end ## # :call-seq: # run() -> nil # # Runs the session. # Includes the start (#start), the main loop, and the termination (#terminate). def run start while code = rb_code next if code.empty? break unless process_rb_code code end terminate end ## # :call-seq: # start() -> nil # # Starts the session. Currently does nothing. def start end ## # :call-seq: # rb_code() -> String # # Reads and returns the next Ruby codes input in the \REPL session. # It can intelligently continue reading if the code is not complete yet. def rb_code result = '' indent = 0 begin result.concat readline(indent).tap { return unless _1 }, ?\n # IRB once changed the API of RubyLex#check_state. Take care of that. opts = @lex.method(:check_state).arity.positive? ? {} : { context: @binding } ltype, indent, continue, block_open = @lex.check_state result, **opts rescue Interrupt $stdout.puts return '' end while ltype || indent.nonzero? || continue || block_open result end ## # :call-seq: # readline(indent = 0) -> String # # Prompts the user to input a line. # The parameter +indent+ is the indentation level. # Twice the number of spaces is already in the input field before the user fills in # if #reline is true. # The prompt hint is different for zero +indent+ and nonzero +indent+. # Returns the user input. def readline indent = 0 prompt = indent.nonzero? ? '. ' : '> ' prompt = prompt.green if @color if @reline Reline.pre_input_hook = -> do Reline.insert_text ' ' * indent Reline.redisplay Reline.pre_input_hook = nil end Reline.readline prompt, true else $stdout.print prompt $stdout.flush $stdin.gets chomp: true end end ## # :call-seq: # process_rb_code(code) -> true or false # # Processes the Ruby codes read. # Sends it to a score and sends the result to command line alda. # Returns +false+ for breaking the \REPL main loop, +true+ otherwise. def process_rb_code code @score.clear begin @binding.eval code rescue StandardError, ScriptError, Interrupt => e $stderr.print e.full_message return true rescue SystemExit return false end code = @score.events_alda_codes unless code.empty? $stdout.puts @color ? code.yellow : code try_command { play_score code } end true end ## # :call-seq: # try_command() { ... } -> obj # # Run the block. # In \Alda 1, catches Alda::CommandLineError. # In \Alda 2, catches Alda::NREPLServerError. # If an error is caught, prints the error message (in red if #color is true). def try_command begin yield rescue Alda.v1? ? Alda::CommandLineError : Alda::NREPLServerError => e puts @color ? e.message.red : e.message end end ## # :call-seq: # play_score(code) -> nil # # Appends +code+ to the history and plays the +code+ as \Alda code. # In \Alda 1, plays the score by sending +code+ to command line alda. # In \Alda 2, sends +code+ to the nREPL server for evaluating and playing. def play_score code if Alda.v1? Alda.play code: code, history: @history @history.puts code else message :eval_and_play, code: code end end ## # :call-seq: # terminate() -> nil # # Terminates the REPL session. # In \Alda 1, just calls #clear_history. # In \Alda 2, sends a SIGINT to the nREPL server if it was spawned by the Ruby program. def terminate if Alda.v1? clear_history else if @nrepl_pipe if Alda::Utils.win_platform? unless IO.popen(['taskkill', '/f', '/pid', @nrepl_pipe.pid.to_s], &:read).include? 'SUCCESS' Alda::Warning.warn 'failed to kill nREPL server; may become zombie process' end else Process.kill :INT, @nrepl_pipe.pid end @nrepl_pipe.close end @socket.close end end ## # :call-seq: # history() -> String # # In \Alda 1, it is the same as an attribute reader. # In \Alda 2, it asks the nREPL server for its score text and returns it. def history if Alda.v1? @history else try_command { message :score_text } end end ## # :call-seq: # clear_history() -> nil # # In \Alda 1, clears #history. # In \Alda 2, askes the nREPL server to clear its history (start a new score). def clear_history if Alda.v1? @history = StringIO.new else try_command { message :new_score } end nil end end