alda-rb 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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'