alda-rb 0.2.1 → 0.3.1

Sign up to get free protection for your applications and to get access to all the features.
data/lib/alda-rb/repl.rb CHANGED
@@ -1,17 +1,10 @@
1
1
  require 'colorize'
2
2
  require 'irb/ruby-lex'
3
3
  require 'json'
4
- require 'readline'
4
+ require 'reline'
5
5
  require 'stringio'
6
-
7
- ##
8
- # :call-seq:
9
- # repl() -> nil
10
- #
11
- # Start a REPL session.
12
- def Alda.repl
13
- Alda::REPL.new.run
14
- end
6
+ require 'bencode'
7
+ require 'socket'
15
8
 
16
9
  ##
17
10
  # An instance of this class is an \REPL session.
@@ -32,101 +25,309 @@ end
32
25
  # your previous input, run <tt>puts history</tt>.
33
26
  #
34
27
  # Unlike \IRB, this \REPL does not print the result of
35
- # the executed codes. Use +p+ if you want.
28
+ # the executed codes. Use +p+ or +puts+ if you want.
36
29
  #
37
30
  # +Interrupt+ and +SystemExit+ exceptions are rescued and
38
31
  # will not cause the process terminating.
39
32
  # +exit+ terminates the \REPL session instead of the process.
40
33
  #
41
- # To start an \REPL session in a ruby program, use Alda::repl.
34
+ # To start an \REPL session in a ruby program, use #run.
42
35
  # To start an \REPL session conveniently from command line,
43
- # run command <tt>ruby -ralda-rb -e "Alda.repl"</tt>.
36
+ # run command <tt>alda-irb</tt>.
37
+ # For details about this command line tool, run <tt>alda-irb --help</tt>.
44
38
  #
45
- # $ ruby -ralda-rb -e "Alda.repl"
46
- # > puts status
47
- # [27713] Server up (2/2 workers available, backend port: 33245)
48
- # > piano_ c d e f
49
- # [piano: c d e f]
39
+ # $ alda-irb
40
+ # > p processes.last
41
+ # {:id=>"dus", :port=>34317, :state=>nil, :expiry=>nil, :type=>:repl_server}
42
+ # > piano_; c d e f
43
+ # piano: [c d e f]
50
44
  # > 5.times do
51
- # > c
52
- # > end
45
+ # . c
46
+ # > end
53
47
  # c c c c c
54
- # > puts history
55
- # [piano: c d e f]
48
+ # > score_text
49
+ # piano: [c d e f]
56
50
  # c c c c c
57
51
  # > play
52
+ # Playing...
58
53
  # > save 'temp.alda'
59
54
  # > puts `cat temp.alda`
60
- # [piano: c d e f]
55
+ # piano: [c d e f]
61
56
  # c c c c c
62
57
  # > system 'rm temp.alda'
63
58
  # > exit
59
+ #
60
+ # Notice that there is a significant difference between \Alda 1 \REPL and \Alda 2 \REPL.
61
+ # In short, \Alda 2 has a much more powerful \REPL than \Alda 1,
62
+ # so it dropped the <tt>--history</tt> option in the <tt>alda play</tt> command line interface
63
+ # ({alda-lang/alda#367}[https://github.com/alda-lang/alda/issues/367]).
64
+ # It has an nREPL server, and this class simply functions by sending messages to the nREPL server.
65
+ # However, for \Alda 1, this class maintains necessary information
66
+ # in the memory of the Ruby program,
67
+ # and the \REPL is implemented by repeatedly running <tt>alda play</tt> in command line.
68
+ # Therefore, this class functions differently for \Alda 1 and \Alda 2
69
+ # and you thus should not modify Alda::generation during an \REPL session.
70
+ #
71
+ # It is also possible to use this class as a Ruby wrapper of APIs of the \Alda nREPL server
72
+ # in \Alda 2.
73
+ # In this usage, you never need to call #run, and you call #message or #raw_message instead.
74
+ #
75
+ # repl = Alda::REPL.new
76
+ # repl.message :eval_and_play, code: 'piano: c d e f' # => nil
77
+ # repl.message :eval_and_play, code: 'g a b > c' # => nil
78
+ # repl.message :score_text # => "piano: [c d e f]\ng a b > c\n"
79
+ # repl.message :eval_and_play, code: 'this will cause an error' # (raises Alda::NREPLServerError)
64
80
  class Alda::REPL
65
81
 
66
82
  ##
67
83
  # The score object used in Alda::REPL.
68
84
  #
69
85
  # Includes Alda, so it can refer to alda commandline.
86
+ # However, the methods Alda::Score#play, Alda::Score#parse and Alda::Score#export
87
+ # are still retained instead of being overridden by the included module.
70
88
  #
71
89
  # When you are in an \REPL session, you are actually
72
90
  # in an instance of this class,
73
91
  # so you can call the instance methods down here
74
92
  # when you play with an \REPL.
75
- class TempScore < Alda::Score
93
+ class TempScore < ::Alda::Score
76
94
  include Alda
77
95
 
78
- Score.instance_methods(false).each do |meth|
79
- define_method meth, Score.instance_method(meth)
96
+ %i[play parse export].each do |meth|
97
+ define_method meth, Alda::Score.instance_method(meth)
80
98
  end
81
99
 
100
+ ##
101
+ # :call-seq:
102
+ # new(session) -> TempScore
103
+ #
104
+ # Creates a new TempScore for the given \REPL session specified by +session+.
105
+ # It is called in Alda::REPL::new.
82
106
  def initialize session
83
107
  super()
84
108
  @session = session
85
109
  end
86
110
 
111
+ ##
112
+ # :call-seq:
113
+ # to_s -> String
114
+ #
115
+ # Overrides Alda::Score#to_s.
116
+ # Returns the history.
117
+ #
118
+ # $ alda-irb
119
+ # > harmonica_; a b c
120
+ # harmonica: [a b c]
121
+ # > guitar_; c g e
122
+ # guitar: [c g e]
123
+ # > p to_s
124
+ # "harmonica: [a b c]\nguitar: [c g e]\n"
87
125
  def to_s
88
- history
89
- end
90
-
91
- def history
92
- @session.history.to_s
126
+ @session.history
93
127
  end
94
128
 
129
+ ##
130
+ # :call-seq:
131
+ # clear_history() -> nil
132
+ #
133
+ # Clears all the modifications that have been made to the score
134
+ # and start a new one.
135
+ # See #score for an example.
95
136
  def clear_history
96
137
  @session.clear_history
97
138
  end
139
+ alias new clear_history
140
+ alias new_score clear_history
98
141
 
142
+ ##
143
+ # :call-seq:
144
+ # get_binding() -> Binding
145
+ #
146
+ # Returns a Binding for the instance eval local environment of this score.
147
+ # Different callings of this method will return different bindings,
148
+ # and they do not share local variables.
149
+ # This method is called in Alda::REPL::new.
150
+ #
151
+ # $ alda-irb
152
+ # > p get_binding.receiver == self
153
+ # true
99
154
  def get_binding
100
155
  binding
101
156
  end
102
157
 
158
+ ##
159
+ # :call-seq:
160
+ # score() -> nil
161
+ #
162
+ # Print the history (all \Alda code of the score).
163
+ #
164
+ # $ alda-irb
165
+ # > violin_; a b
166
+ # violin: [a b]
167
+ # > score
168
+ # violin: [a b]
169
+ # > clear_history
170
+ # > score
171
+ # > viola_; c
172
+ # viola: c
173
+ # > score
174
+ # viola: c
103
175
  def score
104
- puts history
176
+ print @session.color ? @session.history.blue : @session.history
177
+ nil
105
178
  end
179
+ alias score_text score
106
180
 
181
+ ##
182
+ # :call-seq:
183
+ # map() -> nil
184
+ #
185
+ # Prints a data representation of the score.
186
+ # This is the output that you get when you call Alda::Score#parse.
107
187
  def map
108
- puts JSON.generate JSON.parse(parse),
109
- indent: ' ', space: ' ', object_nl: ?\n, array_nl: ?\n
188
+ json = Alda.v1? ? parse : @session.message(:score_data)
189
+ json = JSON.generate JSON.parse(json), indent: ' ', space: ' ', object_nl: ?\n, array_nl: ?\n
190
+ puts @session.color ? json.blue : json
191
+ end
192
+ alias score_data map
193
+
194
+ ##
195
+ # :call-seq:
196
+ # score_events() -> nil
197
+ #
198
+ # Prints the parsed events output of the score.
199
+ # This is the output that you get when you call Alda::Score#parse with <tt>output: :events</tt>.
200
+ def score_events
201
+ json = Alda.v1? ? parse(output: :events) : @session.message(:score_events)
202
+ json = JSON.generate JSON.parse(json), indent: ' ', space: ' ', object_nl: ?\n, array_nl: ?\n
203
+ puts @session.color ? json.blue : json
110
204
  end
111
205
 
112
206
  alias quit exit
113
- alias new clear_history
114
207
  end
115
208
 
116
209
  ##
117
- # The history.
118
- attr_reader :history
210
+ # The host of the nREPL server. Only useful in \Alda 2.
211
+ attr_reader :host
212
+
213
+ ##
214
+ # The port of the nREPL server. Only useful in \Alda 2.
215
+ attr_reader :port
216
+
217
+ ##
218
+ # Whether the output should be colored.
219
+ attr_accessor :color
220
+
221
+ ##
222
+ # Whether a preview of what \Alda code will be played everytime you input ruby codes.
223
+ attr_accessor :preview
224
+
225
+ ##
226
+ # Whether to use Reline for input.
227
+ # When it is false, the \REPL session will be less buggy but less powerful.
228
+ attr_accessor :reline
119
229
 
120
230
  ##
121
231
  # :call-seq:
122
- # new() -> Alda::REPL
232
+ # new(**opts) -> Alda::REPL
123
233
  #
124
234
  # Creates a new Alda::REPL.
125
- def initialize
235
+ # The parameter +color+ specifies whether the output should be colored (sets #color).
236
+ # The parameter +preview+ specifies whether a preview of what \Alda code will be played
237
+ # everytime you input ruby codes (sets #preview).
238
+ # The parameter +reline+ specifies whether to use Reline for input.
239
+ #
240
+ # The +opts+ are passed to the command line of <tt>alda repl</tt>.
241
+ # Available options are +host+, +port+, etc.
242
+ # Run <tt>alda repl --help</tt> for more info.
243
+ # If +port+ is specified and +host+ is not or is specified to be <tt>"localhost"</tt>
244
+ # or <tt>"127.0.0.1"</tt>, then this method will try to connect to an existing
245
+ # \Alda REPL server.
246
+ # A new one will be started only if no existing server is found.
247
+ #
248
+ # The +opts+ are ignored in \Alda 1.
249
+ def initialize color: true, preview: true, reline: true, **opts
126
250
  @score = TempScore.new self
127
251
  @binding = @score.get_binding
128
- @lex = RubyLex.new
129
- @history = StringIO.new
252
+ # IRB once changed the API of RubyLex#initialize. Take care of that.
253
+ @lex = RubyLex.new *(RubyLex.instance_method(:initialize).arity == 0 ? [] : [@binding])
254
+ @color = color
255
+ @preview = preview
256
+ @reline = reline
257
+ setup_repl opts
258
+ end
259
+
260
+ ##
261
+ # :call-seq:
262
+ # setup_repl(opts) -> nil
263
+ #
264
+ # Sets up the \REPL session.
265
+ # This method is called in ::new.
266
+ # After you #terminate the session,
267
+ # you cannot use the \REPL anymore unless you call this method again.
268
+ def setup_repl opts
269
+ if Alda.v1?
270
+ @history = StringIO.new
271
+ else
272
+ @port = (opts.fetch :port, -1).to_i
273
+ @host = opts.fetch :host, 'localhost'
274
+ unless @port.positive? && %w[localhost 127.0.0.1].include?(@host) &&
275
+ Alda.processes.any? { _1[:port] == @port && _1[:type] == :repl_server }
276
+ Alda.env(ALDA_DISABLE_SPAWNING: :no) { @nrepl_pipe = Alda.pipe :repl, **opts, server: true }
277
+ /nrepl:\/\/[a-zA-Z0-9._\-]+:(?<port>\d+)/ =~ @nrepl_pipe.gets
278
+ @port = port.to_i
279
+ Process.detach @nrepl_pipe.pid
280
+ end
281
+ @socket = TCPSocket.new @host, @port
282
+ @bencode_parser = BEncode::Parser.new @socket
283
+ end
284
+ nil
285
+ end
286
+
287
+ ##
288
+ # :call-seq:
289
+ # raw_message(contents) -> Hash
290
+ #
291
+ # Sends a message to the nREPL server and returns the response.
292
+ # The parameter +contents+ is a Hash or a JSON string.
293
+ #
294
+ # repl = Alda::REPL.new
295
+ # repl.raw_message op: 'describe' # => {"ops"=>...}
296
+ def raw_message contents
297
+ Alda::GenerationError.assert_generation [:v2]
298
+ contents = JSON.parse contents if contents.is_a? String
299
+ @socket.write contents.bencode
300
+ @bencode_parser.parse!
301
+ end
302
+
303
+ ##
304
+ # :call-seq:
305
+ # message(op, **params) -> String or Hash
306
+ #
307
+ # Sends a message to the nREPL server with the following format,
308
+ # with +op+ being the operation name (the +op+ field in the message),
309
+ # and +params+ being the parameters (other fields in the message).
310
+ # Then, this method analyzes the response.
311
+ # If there is an error, raises Alda::NREPLServerError.
312
+ # Otherwise, if the response contains only one field, return the content of that field (a String).
313
+ # Otherwise, return the whole response as a Hash.
314
+ #
315
+ # repl = Alda::REPL.new
316
+ # repl.message :eval_and_play, code: 'piano: c d e f' # => nil
317
+ # repl.message :eval_and_play, code: 'g a b > c' # => nil
318
+ # repl.message :score_text # => "piano: [c d e f]\ng a b > c\n"
319
+ # repl.message :eval_and_play, code: 'this will cause an error' # (raises Alda::NREPLServerError)
320
+ def message op, **params
321
+ result = raw_message op: Alda::Utils.snake_to_slug(op), **params
322
+ result.transform_keys! { Alda::Utils.slug_to_snake _1 }
323
+ if (status = result.delete :status).include? 'error'
324
+ raise Alda::NREPLServerError.new @host, @port, result.delete(:problems), status
325
+ end
326
+ case result.size
327
+ when 0 then nil
328
+ when 1 then result.values.first
329
+ else result
330
+ end
130
331
  end
131
332
 
132
333
  ##
@@ -134,10 +335,11 @@ class Alda::REPL
134
335
  # run() -> nil
135
336
  #
136
337
  # Runs the session.
137
- # Includes the start, the main loop, and the termination.
338
+ # Includes the start (#start), the main loop, and the termination (#terminate).
138
339
  def run
139
340
  start
140
341
  while code = rb_code
342
+ next if code.empty?
141
343
  break unless process_rb_code code
142
344
  end
143
345
  terminate
@@ -159,18 +361,46 @@ class Alda::REPL
159
361
  # It can intelligently continue reading if the code is not complete yet.
160
362
  def rb_code
161
363
  result = ''
364
+ indent = 0
162
365
  begin
163
- buf = Readline.readline '> '.green, true
164
- return unless buf
165
- result.concat buf, ?\n
166
- ltype, indent, continue, block_open = @lex.check_state result
366
+ result.concat readline(indent).tap { return unless _1 }, ?\n
367
+ # IRB once changed the API of RubyLex#check_state. Take care of that.
368
+ opts = @lex.method(:check_state).arity.positive? ? {} : { context: @binding }
369
+ ltype, indent, continue, block_open = @lex.check_state result, **opts
167
370
  rescue Interrupt
168
371
  $stdout.puts
169
- retry
372
+ return ''
170
373
  end while ltype || indent.nonzero? || continue || block_open
171
374
  result
172
375
  end
173
376
 
377
+ ##
378
+ # :call-seq:
379
+ # readline(indent = 0) -> String
380
+ #
381
+ # Prompts the user to input a line.
382
+ # The parameter +indent+ is the indentation level.
383
+ # Twice the number of spaces is already in the input field before the user fills in
384
+ # if #reline is true.
385
+ # The prompt hint is different for zero +indent+ and nonzero +indent+.
386
+ # Returns the user input.
387
+ def readline indent = 0
388
+ prompt = indent.nonzero? ? '. ' : '> '
389
+ prompt = prompt.green if @color
390
+ if @reline
391
+ Reline.pre_input_hook = -> do
392
+ Reline.insert_text ' ' * indent
393
+ Reline.redisplay
394
+ Reline.pre_input_hook = nil
395
+ end
396
+ Reline.readline prompt, true
397
+ else
398
+ $stdout.print prompt
399
+ $stdout.flush
400
+ $stdin.gets chomp: true
401
+ end
402
+ end
403
+
174
404
  ##
175
405
  # :call-seq:
176
406
  # process_rb_code(code) -> true or false
@@ -182,18 +412,16 @@ class Alda::REPL
182
412
  @score.clear
183
413
  begin
184
414
  @binding.eval code
185
- rescue StandardError, ScriptError => e
415
+ rescue StandardError, ScriptError, Interrupt => e
186
416
  $stderr.print e.full_message
187
417
  return true
188
- rescue Interrupt
189
- return true
190
418
  rescue SystemExit
191
419
  return false
192
420
  end
193
421
  code = @score.events_alda_codes
194
422
  unless code.empty?
195
- $stdout.puts code.yellow
196
- play_score code
423
+ $stdout.puts @color ? code.yellow : code
424
+ try_command { play_score code }
197
425
  end
198
426
  true
199
427
  end
@@ -202,12 +430,15 @@ class Alda::REPL
202
430
  # :call-seq:
203
431
  # try_command() { ... } -> obj
204
432
  #
205
- # Tries to run the block and rescue Alda::CommandLineError.
433
+ # Run the block.
434
+ # In \Alda 1, catches Alda::CommandLineError.
435
+ # In \Alda 2, catches Alda::NREPLServerError.
436
+ # If an error is caught, prints the error message (in red if #color is true).
206
437
  def try_command
207
438
  begin
208
439
  yield
209
- rescue Alda::CommandLineError => e
210
- puts e.message.red
440
+ rescue Alda.v1? ? Alda::CommandLineError : Alda::NREPLServerError => e
441
+ puts @color ? e.message.red : e.message
211
442
  end
212
443
  end
213
444
 
@@ -215,11 +446,15 @@ class Alda::REPL
215
446
  # :call-seq:
216
447
  # play_score(code) -> nil
217
448
  #
218
- # Plays the score by sending +code+ to command line alda.
449
+ # Appends +code+ to the history and plays the +code+ as \Alda code.
450
+ # In \Alda 1, plays the score by sending +code+ to command line alda.
451
+ # In \Alda 2, sends +code+ to the nREPL server for evaluating and playing.
219
452
  def play_score code
220
- try_command do
453
+ if Alda.v1?
221
454
  Alda.play code: code, history: @history
222
455
  @history.puts code
456
+ else
457
+ message :eval_and_play, code: code
223
458
  end
224
459
  end
225
460
 
@@ -228,18 +463,52 @@ class Alda::REPL
228
463
  # terminate() -> nil
229
464
  #
230
465
  # Terminates the REPL session.
231
- # Currently just clears #history.
466
+ # In \Alda 1, just calls #clear_history.
467
+ # In \Alda 2, sends a SIGINT to the nREPL server if it was spawned by the Ruby program.
232
468
  def terminate
233
- clear_history
469
+ if Alda.v1?
470
+ clear_history
471
+ else
472
+ if @nrepl_pipe
473
+ if Alda::Utils.win_platform?
474
+ unless IO.popen(['taskkill', '/f', '/pid', @nrepl_pipe.pid.to_s], &:read).include? 'SUCCESS'
475
+ Alda::Warning.warn 'failed to kill nREPL server; may become zombie process'
476
+ end
477
+ else
478
+ Process.kill :INT, @nrepl_pipe.pid
479
+ end
480
+ @nrepl_pipe.close
481
+ end
482
+ @socket.close
483
+ end
484
+ end
485
+
486
+ ##
487
+ # :call-seq:
488
+ # history() -> String
489
+ #
490
+ # In \Alda 1, it is the same as an attribute reader.
491
+ # In \Alda 2, it asks the nREPL server for its score text and returns it.
492
+ def history
493
+ if Alda.v1?
494
+ @history
495
+ else
496
+ try_command { message :score_text }
497
+ end
234
498
  end
235
499
 
236
500
  ##
237
501
  # :call-seq:
238
502
  # clear_history() -> nil
239
503
  #
240
- # Clears #history.
504
+ # In \Alda 1, clears #history.
505
+ # In \Alda 2, askes the nREPL server to clear its history (start a new score).
241
506
  def clear_history
242
- @history = StringIO.new
507
+ if Alda.v1?
508
+ @history = StringIO.new
509
+ else
510
+ try_command { message :new_score }
511
+ end
243
512
  nil
244
513
  end
245
514
  end
@@ -0,0 +1,47 @@
1
+ ##
2
+ # Some useful functions.
3
+ module Alda::Utils
4
+
5
+ ##
6
+ # :call-seq:
7
+ # warn(message) -> nil
8
+ #
9
+ # Prints a warning message to standard error, appended by a newline.
10
+ # The message is prefixed with the filename and lineno of the caller
11
+ # (the lowest level where the file is not an alda-rb source file).
12
+ def warn message
13
+ location = caller_locations.find { !_1.path.start_with? __dir__ }
14
+ Warning.warn "#{location.path}:#{location.lineno}: #{message}\n"
15
+ end
16
+
17
+ ##
18
+ # :call-seq:
19
+ # win_platform? -> true or false
20
+ #
21
+ # Returns whether the current platform is Windows.
22
+ def win_platform?
23
+ Gem.win_platform?
24
+ end
25
+
26
+ ##
27
+ # :call-seq:
28
+ # snake_to_slug(sym) -> String
29
+ #
30
+ # Converts a snake_case Symbol to a slug-case String.
31
+ # The inverse of ::slug_to_snake.
32
+ def snake_to_slug sym
33
+ sym.to_s.gsub ?_, ?-
34
+ end
35
+
36
+ ##
37
+ # :call-seq:
38
+ # slug_to_snake(str) -> Symbol
39
+ #
40
+ # Converts a slug-case String to a snake_case Symbol.
41
+ # The inverse of ::snake_to_slug.
42
+ def slug_to_snake str
43
+ str.to_s.gsub(?-, ?_).to_sym
44
+ end
45
+
46
+ module_function :warn, :win_platform?, :snake_to_slug, :slug_to_snake
47
+ end
@@ -7,5 +7,5 @@ module Alda
7
7
  # The version number of alda-rb.
8
8
  #
9
9
  # The same as that in alda-rb gem spec.
10
- VERSION = '0.2.1'
10
+ VERSION = '0.3.1'
11
11
  end
data/lib/alda-rb.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'alda-rb/version'
4
4
  require 'alda-rb/patches'
5
+ require 'alda-rb/utils'
5
6
  require 'alda-rb/error'
6
7
  require 'alda-rb/commandline'
7
8
  require 'alda-rb/event_list'