autobuild 1.17.0 → 1.21.0

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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +107 -0
  3. data/.travis.yml +3 -2
  4. data/Gemfile +2 -1
  5. data/Rakefile +1 -4
  6. data/autobuild.gemspec +18 -13
  7. data/bin/autobuild +4 -3
  8. data/lib/autobuild.rb +4 -5
  9. data/lib/autobuild/build_logfile.rb +6 -4
  10. data/lib/autobuild/config.rb +104 -41
  11. data/lib/autobuild/configurable.rb +32 -18
  12. data/lib/autobuild/environment.rb +126 -120
  13. data/lib/autobuild/exceptions.rb +48 -31
  14. data/lib/autobuild/import/archive.rb +134 -82
  15. data/lib/autobuild/import/cvs.rb +28 -24
  16. data/lib/autobuild/import/darcs.rb +13 -16
  17. data/lib/autobuild/import/git-lfs.rb +37 -30
  18. data/lib/autobuild/import/git.rb +246 -182
  19. data/lib/autobuild/import/hg.rb +23 -18
  20. data/lib/autobuild/import/svn.rb +48 -29
  21. data/lib/autobuild/importer.rb +534 -499
  22. data/lib/autobuild/mail_reporter.rb +77 -77
  23. data/lib/autobuild/package.rb +200 -122
  24. data/lib/autobuild/packages/autotools.rb +47 -42
  25. data/lib/autobuild/packages/cmake.rb +77 -65
  26. data/lib/autobuild/packages/dummy.rb +9 -8
  27. data/lib/autobuild/packages/genom.rb +1 -1
  28. data/lib/autobuild/packages/gnumake.rb +74 -31
  29. data/lib/autobuild/packages/import.rb +2 -6
  30. data/lib/autobuild/packages/orogen.rb +32 -31
  31. data/lib/autobuild/packages/pkgconfig.rb +2 -2
  32. data/lib/autobuild/packages/python.rb +12 -8
  33. data/lib/autobuild/packages/ruby.rb +22 -17
  34. data/lib/autobuild/parallel.rb +50 -46
  35. data/lib/autobuild/pkgconfig.rb +25 -13
  36. data/lib/autobuild/progress_display.rb +149 -64
  37. data/lib/autobuild/rake_task_extension.rb +12 -7
  38. data/lib/autobuild/reporting.rb +51 -26
  39. data/lib/autobuild/subcommand.rb +72 -65
  40. data/lib/autobuild/test.rb +9 -7
  41. data/lib/autobuild/test_utility.rb +12 -10
  42. data/lib/autobuild/timestamps.rb +28 -23
  43. data/lib/autobuild/tools.rb +17 -16
  44. data/lib/autobuild/utility.rb +67 -23
  45. data/lib/autobuild/version.rb +1 -1
  46. metadata +53 -37
@@ -1,5 +1,3 @@
1
- require 'thread'
2
-
3
1
  module Autobuild
4
2
  # This is a rewrite of the Rake task invocation code to use parallelism
5
3
  #
@@ -23,9 +21,11 @@ def initialize(level)
23
21
  wio.fcntl(Fcntl::F_SETFD, 0)
24
22
  put(level)
25
23
  end
24
+
26
25
  def get(token_count = 1)
27
26
  @rio.read(token_count)
28
27
  end
28
+
29
29
  def put(token_count = 1)
30
30
  @wio.write(" " * token_count)
31
31
  end
@@ -36,7 +36,6 @@ def initialize(level = Autobuild.parallel_build_level)
36
36
  @available_workers = Array.new
37
37
  @finished_workers = Queue.new
38
38
  @workers = Array.new
39
-
40
39
  end
41
40
 
42
41
  def wait_for_worker_to_end(state)
@@ -46,9 +45,12 @@ def wait_for_worker_to_end(state)
46
45
  if error
47
46
  if available_workers.size != workers.size
48
47
  if finished_task.respond_to?(:package) && finished_task.package
49
- Autobuild.error "got an error processing #{finished_task.package.name}, waiting for pending jobs to end"
48
+ Autobuild.error "got an error processing "\
49
+ "#{finished_task.package.name}, "\
50
+ "waiting for pending jobs to end"
50
51
  else
51
- Autobuild.error "got an error doing parallel processing, waiting for pending jobs to end"
52
+ Autobuild.error "got an error doing parallel processing, "\
53
+ "waiting for pending jobs to end"
52
54
  end
53
55
  end
54
56
  begin
@@ -61,16 +63,14 @@ def wait_for_worker_to_end(state)
61
63
  state.process_finished_task(finished_task)
62
64
  end
63
65
 
64
- def discover_dependencies(all_tasks, reverse_dependencies, t)
65
- if t.already_invoked?
66
- return
67
- end
66
+ def discover_dependencies(all_tasks, reverse_dependencies, task)
67
+ return if task.already_invoked?
68
+ return if all_tasks.include?(task) # already discovered or being discovered
68
69
 
69
- return if all_tasks.include?(t) # already discovered or being discovered
70
- all_tasks << t
70
+ all_tasks << task
71
71
 
72
- t.prerequisite_tasks.each do |dep_t|
73
- reverse_dependencies[dep_t] << t
72
+ task.prerequisite_tasks.each do |dep_t|
73
+ reverse_dependencies[dep_t] << task
74
74
  discover_dependencies(all_tasks, reverse_dependencies, dep_t)
75
75
  end
76
76
  end
@@ -83,8 +83,9 @@ class ProcessingState
83
83
  attr_reader :queue
84
84
  attr_reader :priorities
85
85
 
86
- def initialize(reverse_dependencies)
86
+ def initialize(reverse_dependencies, completion_callback: proc { })
87
87
  @reverse_dependencies = reverse_dependencies
88
+ @completion_callback = completion_callback
88
89
  @processed = Set.new
89
90
  @active_tasks = Set.new
90
91
  @priorities = Hash.new
@@ -101,7 +102,7 @@ def push(task, base_priority = 1)
101
102
  end
102
103
 
103
104
  def find_task
104
- if task = queue.sort_by { |t, p| p }.first
105
+ if (task = queue.min_by { |_t, p| p })
105
106
  priorities[task.first] = task.last
106
107
  task.first
107
108
  end
@@ -143,16 +144,19 @@ def process_finished_task(task)
143
144
  push(candidate, priorities[task])
144
145
  end
145
146
  end
147
+
148
+ @completion_callback.call(task)
146
149
  end
147
150
 
148
151
  def trivial_task?(task)
149
- (task.kind_of?(Autobuild::SourceTreeTask) || task.kind_of?(Rake::FileTask)) && task.actions.empty?
152
+ (task.kind_of?(Autobuild::SourceTreeTask) ||
153
+ task.kind_of?(Rake::FileTask)) && task.actions.empty?
150
154
  end
151
155
  end
152
156
 
153
157
  # Invokes the provided tasks. Unlike the rake code, this is a toplevel
154
158
  # algorithm that does not use recursion
155
- def invoke_parallel(required_tasks)
159
+ def invoke_parallel(required_tasks, completion_callback: proc { })
156
160
  tasks = Set.new
157
161
  reverse_dependencies = Hash.new { |h, k| h[k] = Set.new }
158
162
  required_tasks.each do |t|
@@ -161,13 +165,12 @@ def invoke_parallel(required_tasks)
161
165
  # The queue is the set of tasks for which all prerequisites have
162
166
  # been successfully executed (or where not needed). I.e. it is the
163
167
  # set of tasks that can be queued for execution.
164
- state = ProcessingState.new(reverse_dependencies)
168
+ state = ProcessingState.new(reverse_dependencies,
169
+ completion_callback: completion_callback)
165
170
  tasks.each do |t|
166
- if state.ready?(t)
167
- state.push(t)
168
- end
171
+ state.push(t) if state.ready?(t)
169
172
  end
170
-
173
+
171
174
  # Build a reverse dependency graph (i.e. a mapping from a task to
172
175
  # the tasks that depend on it)
173
176
 
@@ -175,9 +178,9 @@ def invoke_parallel(required_tasks)
175
178
  # topological sort since we would then have to scan all tasks each
176
179
  # time for tasks that have no currently running prerequisites
177
180
 
178
- while true
181
+ loop do
179
182
  pending_task = state.pop
180
- if !pending_task
183
+ unless pending_task
181
184
  # If we have pending workers, wait for one to be finished
182
185
  # until either they are all finished or the queue is not
183
186
  # empty anymore
@@ -186,17 +189,19 @@ def invoke_parallel(required_tasks)
186
189
  pending_task = state.pop
187
190
  end
188
191
 
189
- if !pending_task && available_workers.size == workers.size
190
- break
191
- end
192
+ break if !pending_task && available_workers.size == workers.size
192
193
  end
193
194
 
194
- if state.trivial_task?(pending_task)
195
- Worker.execute_task(pending_task)
195
+ bypass_task = pending_task.disabled? ||
196
+ pending_task.already_invoked? ||
197
+ !pending_task.needed?
198
+
199
+ if bypass_task
200
+ pending_task.already_invoked = true
196
201
  state.process_finished_task(pending_task)
197
202
  next
198
- elsif pending_task.already_invoked? || !pending_task.needed?
199
- pending_task.already_invoked = true
203
+ elsif state.trivial_task?(pending_task)
204
+ Worker.execute_task(pending_task)
200
205
  state.process_finished_task(pending_task)
201
206
  next
202
207
  end
@@ -204,9 +209,7 @@ def invoke_parallel(required_tasks)
204
209
  # Get a job server token
205
210
  job_server.get
206
211
 
207
- while !finished_workers.empty?
208
- wait_for_worker_to_end(state)
209
- end
212
+ wait_for_worker_to_end(state) until finished_workers.empty?
210
213
 
211
214
  # We do have a job server token, so we are allowed to allocate a
212
215
  # new worker if none are available
@@ -222,9 +225,9 @@ def invoke_parallel(required_tasks)
222
225
  end
223
226
 
224
227
  not_processed = tasks.find_all { |t| !t.already_invoked? }
225
- if !not_processed.empty?
228
+ unless not_processed.empty?
226
229
  cycle = resolve_cycle(tasks, not_processed, reverse_dependencies)
227
- raise "cycle in task graph: #{cycle.map(&:name).sort.join(", ")}"
230
+ raise "cycle in task graph: #{cycle.map(&:name).sort.join(', ')}"
228
231
  end
229
232
  end
230
233
 
@@ -232,7 +235,7 @@ def resolve_cycle(all_tasks, tasks, reverse_dependencies)
232
235
  cycle = tasks.dup
233
236
  chain = []
234
237
  next_task = tasks.first
235
- while true
238
+ loop do
236
239
  task = next_task
237
240
  chain << task
238
241
  tasks.delete(next_task)
@@ -244,10 +247,12 @@ def resolve_cycle(all_tasks, tasks, reverse_dependencies)
244
247
  true
245
248
  end
246
249
  end
247
- if !next_task
248
- Autobuild.fatal "parallel processing stopped prematurely, but no cycle is present in the remaining tasks"
249
- Autobuild.fatal "remaining tasks: #{cycle.map(&:name).join(", ")}"
250
- Autobuild.fatal "known dependencies at initialization time that could block the processing of the remaining tasks"
250
+ unless next_task
251
+ Autobuild.fatal "parallel processing stopped prematurely, "\
252
+ "but no cycle is present in the remaining tasks"
253
+ Autobuild.fatal "remaining tasks: #{cycle.map(&:name).join(', ')}"
254
+ Autobuild.fatal "known dependencies at initialization time that "\
255
+ "could block the processing of the remaining tasks"
251
256
  reverse_dependencies.each do |parent_task, parents|
252
257
  if cycle.include?(parent_task)
253
258
  parents.each do |p|
@@ -255,13 +260,14 @@ def resolve_cycle(all_tasks, tasks, reverse_dependencies)
255
260
  end
256
261
  end
257
262
  end
258
- Autobuild.fatal "known dependencies right now that could block the processing of the remaining tasks"
263
+ Autobuild.fatal "known dependencies right now that could block "\
264
+ "the processing of the remaining tasks"
259
265
  all_tasks.each do |p|
260
266
  (cycle & p.prerequisite_tasks).each do |t|
261
267
  Autobuild.fatal " #{p}: #{t}"
262
268
  end
263
269
  end
264
- raise "failed to resolve cycle in #{cycle.map(&:name).join(", ")}"
270
+ raise "failed to resolve cycle in #{cycle.map(&:name).join(', ')}"
265
271
  end
266
272
  end
267
273
  chain
@@ -301,7 +307,7 @@ def do_task(task)
301
307
  end
302
308
 
303
309
  def last_result
304
- return @last_finished_task, @last_error
310
+ [@last_finished_task, @last_error]
305
311
  end
306
312
 
307
313
  def queue(task)
@@ -321,5 +327,3 @@ class << self
321
327
  attr_accessor :parallel_task_manager
322
328
  end
323
329
  end
324
-
325
-
@@ -2,10 +2,16 @@
2
2
  class PkgConfig
3
3
  class NotFound < RuntimeError
4
4
  attr_reader :name
5
- def initialize(name); @name = name end
6
- def to_s; "#{name} is not available to pkg-config" end
5
+
6
+ def initialize(name)
7
+ @name = name
8
+ end
9
+
10
+ def to_s
11
+ "#{name} is not available to pkg-config"
12
+ end
7
13
  end
8
-
14
+
9
15
  # The module name
10
16
  attr_reader :name
11
17
  # The module version
@@ -14,30 +20,36 @@ def to_s; "#{name} is not available to pkg-config" end
14
20
  # Create a PkgConfig object for the package +name+
15
21
  # Raises PkgConfig::NotFound if the module does not exist
16
22
  def initialize(name)
17
- if !system("pkg-config --exists #{name}")
18
- raise NotFound.new(name)
23
+ unless system("pkg-config --exists #{name}")
24
+ raise NotFound.new(name), "pkg-config package '#{name}' not found"
19
25
  end
20
-
26
+
21
27
  @name = name
22
28
  @version = `pkg-config --modversion #{name}`.chomp.strip
23
29
  @actions = Hash.new
24
30
  @variables = Hash.new
25
31
  end
26
32
 
27
- ACTIONS = %w{cflags cflags-only-I cflags-only-other
28
- libs libs-only-L libs-only-l libs-only-other static}
33
+ ACTIONS = %w[cflags cflags-only-I cflags-only-other
34
+ libs libs-only-L libs-only-l libs-only-other static].freeze
29
35
  ACTIONS.each do |action|
30
- define_method(action.gsub(/-/, '_')) do
36
+ define_method(action.tr('-', '_')) do
31
37
  @actions[action] ||= `pkg-config --#{action} #{name}`.chomp.strip
32
38
  end
33
39
  end
34
40
 
41
+ def respond_to_missing?(varname, _include_all)
42
+ varname =~ /^\w+$/
43
+ end
44
+
35
45
  def method_missing(varname, *args, &proc)
36
46
  if args.empty?
37
- @variables[varname] ||= `pkg-config --variable=#{varname} #{name}`.chomp.strip
38
- else
39
- super(varname, *args, &proc)
47
+ unless (value = @variables[varname])
48
+ value = `pkg-config --variable=#{varname} #{name}`.chomp.strip
49
+ @variables[varname] = value
50
+ end
51
+ return value
40
52
  end
53
+ super
41
54
  end
42
55
  end
43
-
@@ -1,51 +1,106 @@
1
+ require "concurrent/atomic/atomic_boolean"
2
+ require "concurrent/array"
3
+
1
4
  module Autobuild
2
5
  # Management of the progress display
3
6
  class ProgressDisplay
4
7
  def initialize(io, color: ::Autobuild.method(:color))
5
8
  @io = io
6
- #@cursor = Blank.new
7
9
  @cursor = TTY::Cursor
8
10
  @last_formatted_progress = []
9
- @progress_messages = []
11
+ @progress_messages = Concurrent::Array.new
10
12
 
11
13
  @silent = false
12
14
  @color = color
13
- @progress_enabled = true
14
15
  @display_lock = Mutex.new
16
+
17
+ @next_progress_display = Time.at(0)
18
+ @progress_mode = :single_line
19
+ @progress_period = 0.1
20
+
21
+ @message_queue = Queue.new
22
+ @forced_progress_display = Concurrent::AtomicBoolean.new(false)
15
23
  end
16
24
 
17
- attr_writer :silent
25
+ def synchronize(&block)
26
+ result = @display_lock.synchronize(&block)
27
+ refresh_display
28
+ result
29
+ end
30
+
31
+ # Set the minimum time between two progress messages
32
+ #
33
+ # @see period
34
+ def progress_period=(period)
35
+ @progress_period = Float(period)
36
+ end
37
+
38
+ # Minimum time between two progress displays
39
+ #
40
+ # This does not affect normal messages
41
+ #
42
+ # @return [Float]
43
+ attr_reader :progress_period
44
+
45
+ # Valid progress modes
46
+ #
47
+ # @see progress_mode=
48
+ PROGRESS_MODES = %I[single_line newline off]
49
+
50
+ # Sets how progress messages will be displayed
51
+ #
52
+ # @param [String] the new mode. Can be either 'single_line', where a
53
+ # progress message replaces the last one, 'newline' which displays
54
+ # each on a new line or 'off' to disable progress messages altogether
55
+ def progress_mode=(mode)
56
+ mode = mode.to_sym
57
+ unless PROGRESS_MODES.include?(mode)
58
+ raise ArgumentError,
59
+ "#{mode} is not a valid mode, expected one of "\
60
+ "#{PROGRESS_MODES.join(", ")}"
61
+ end
62
+ @progress_mode = mode
63
+ end
64
+
65
+ # Return the current display mode
66
+ #
67
+ # @return [Symbol]
68
+ # @see mode=
69
+ attr_reader :progress_mode
18
70
 
19
71
  def silent?
20
72
  @silent
21
73
  end
22
74
 
75
+ def silent=(flag)
76
+ @silent = flag
77
+ end
78
+
23
79
  def silent
24
- @silent, silent = true, @silent
80
+ silent = @silent
81
+ @silent = true
25
82
  yield
26
83
  ensure
27
84
  @silent = silent
28
85
  end
29
86
 
30
- attr_writer :progress_enabled
87
+ # @deprecated use progress_mode= instead
88
+ def progress_enabled=(flag)
89
+ self.progress_mode = flag ? :single_line : :off
90
+ end
31
91
 
92
+ # Whether progress messages will be displayed at all
32
93
  def progress_enabled?
33
- !@silent && @progress_enabled
94
+ !@silent && (@progress_mode != :off)
34
95
  end
35
96
 
36
97
  def message(message, *args, io: @io, force: false)
37
98
  return if silent? && !force
38
99
 
39
- if args.last.respond_to?(:to_io)
40
- io = args.pop
41
- end
100
+ io = args.pop if args.last.respond_to?(:to_io)
101
+ @message_queue << [message, args, io]
42
102
 
43
- @display_lock.synchronize do
44
- io.print "#{@cursor.column(1)}#{@cursor.clear_screen_down}#{@color.call(message, *args)}\n"
45
- io.flush if @io != io
46
- display_progress
47
- @io.flush
48
- end
103
+ refresh_display
49
104
  end
50
105
 
51
106
  def progress_start(key, *args, done_message: nil)
@@ -54,13 +109,13 @@ def progress_start(key, *args, done_message: nil)
54
109
  formatted_message = @color.call(*args)
55
110
  @progress_messages << [key, formatted_message]
56
111
  if progress_enabled?
57
- @display_lock.synchronize do
58
- display_progress
59
- end
112
+ @forced_progress_display.make_true
60
113
  else
61
114
  message " #{formatted_message}"
62
115
  end
63
116
 
117
+ refresh_display
118
+
64
119
  if block_given?
65
120
  begin
66
121
  result = yield
@@ -74,76 +129,101 @@ def progress_start(key, *args, done_message: nil)
74
129
  end
75
130
 
76
131
  def progress(key, *args)
77
- @display_lock.synchronize do
78
- found = false
79
- @progress_messages.map! do |msg_key, msg|
80
- if msg_key == key
81
- found = true
82
- [msg_key, @color.call(*args)]
83
- else
84
- [msg_key, msg]
85
- end
132
+ found = false
133
+ @progress_messages.map! do |msg_key, msg|
134
+ if msg_key == key
135
+ found = true
136
+ [msg_key, @color.call(*args)]
137
+ else
138
+ [msg_key, msg]
86
139
  end
87
- @progress_messages << [key, @color.call(*args)] unless found
88
- display_progress
89
140
  end
141
+ @progress_messages << [key, @color.call(*args)] unless found
142
+
143
+ refresh_display
90
144
  end
91
145
 
92
146
  def progress_done(key, display_last = true, message: nil)
93
- changed = @display_lock.synchronize do
94
- current_size = @progress_messages.size
95
- @progress_messages.delete_if do |msg_key, msg|
96
- if msg_key == key
97
- if display_last && !message
98
- message = msg
99
- end
100
- true
101
- end
147
+ current_size = @progress_messages.size
148
+ @progress_messages.delete_if do |msg_key, msg|
149
+ if msg_key == key
150
+ message = msg if display_last && !message
151
+ true
102
152
  end
103
- current_size != @progress_messages.size
104
153
  end
154
+ changed = current_size != @progress_messages.size
105
155
 
106
156
  if changed
107
157
  if message
108
158
  message(" #{message}")
109
- # Note: message calls display_progress already
159
+ # Note: message updates the display already
110
160
  else
111
- @display_lock.synchronize do
112
- display_progress
113
- end
161
+ refresh_display
114
162
  end
115
163
  true
116
164
  end
117
165
  end
118
166
 
167
+ def refresh_display
168
+ return unless @display_lock.try_lock
169
+
170
+ begin
171
+ refresh_display_under_lock
172
+ ensure
173
+ @display_lock.unlock
174
+ end
175
+ end
176
+
177
+ def refresh_display_under_lock
178
+ # Display queued messages
179
+ until @message_queue.empty?
180
+ message, args, io = @message_queue.pop
181
+ if @progress_mode == :single_line
182
+ io.print @cursor.clear_screen_down
183
+ end
184
+ io.puts @color.call(message, *args)
185
+
186
+ io.flush if @io != io
187
+ end
188
+
189
+ # And re-display the progress
190
+ display_progress(consider_period: @forced_progress_display.false?)
191
+ @forced_progress_display.make_false
192
+ @io.flush
193
+ end
119
194
 
120
- def display_progress
195
+ def display_progress(consider_period: true)
121
196
  return unless progress_enabled?
197
+ return if consider_period && (@next_progress_display > Time.now)
122
198
 
123
- formatted = format_grouped_messages(@progress_messages.map(&:last), indent: " ")
124
- @io.print @cursor.clear_screen_down
125
- @io.print formatted.join("\n")
126
- if formatted.size > 1
127
- @io.print "#{@cursor.up(formatted.size - 1)}#{@cursor.column(0)}"
199
+ formatted = format_grouped_messages(
200
+ @progress_messages.map(&:last),
201
+ indent: " "
202
+ )
203
+ if @progress_mode == :newline
204
+ @io.print formatted.join("\n")
205
+ @io.print "\n"
128
206
  else
207
+ @io.print @cursor.clear_screen_down
208
+ @io.print formatted.join("\n")
209
+ @io.print @cursor.up(formatted.size - 1) if formatted.size > 1
129
210
  @io.print @cursor.column(0)
130
211
  end
131
212
  @io.flush
213
+ @next_progress_display = Time.now + @progress_period
132
214
  end
133
215
 
134
216
  def find_common_prefix(msg, other_msg)
135
- msg = msg.split(" ")
136
- other_msg = other_msg.split(" ")
217
+ msg = msg.split(' ')
218
+ other_msg = other_msg.split(' ')
137
219
  msg.each_with_index do |token, idx|
138
220
  if other_msg[idx] != token
139
221
  prefix = msg[0..(idx - 1)].join(" ")
140
- if !prefix.empty?
141
- prefix << " "
142
- end
222
+ prefix << ' ' unless prefix.empty?
143
223
  return prefix
144
224
  end
145
225
  end
146
- return msg.join(" ")
226
+ msg.join(' ')
147
227
  end
148
228
 
149
229
  def group_messages(messages)
@@ -152,19 +232,22 @@ def group_messages(messages)
152
232
  groups = Array.new
153
233
  groups << ["", (0...messages.size)]
154
234
  messages.each_with_index do |msg, idx|
155
- prefix, grouping = nil, false
235
+ prefix = nil
236
+ grouping = false
156
237
  messages[(idx + 1)..-1].each_with_index do |other_msg, other_idx|
157
238
  other_idx += idx + 1
158
239
  prefix ||= find_common_prefix(msg, other_msg)
159
- break if !other_msg.start_with?(prefix)
240
+ break unless other_msg.start_with?(prefix)
160
241
 
161
242
  if grouping
162
243
  break if prefix != groups.last[0]
244
+
163
245
  groups.last[1] << other_idx
164
246
  else
165
247
  current_prefix, current_group = groups.last
166
- if prefix.size > current_prefix.size # create a new group from there
167
- groups.last[1] = (current_group.first..[idx-1,current_group.last].min)
248
+ if prefix.size > current_prefix.size # create a new group
249
+ group_end_index = [idx - 1, current_group.last].min
250
+ groups.last[1] = (current_group.first..group_end_index)
168
251
  groups << [prefix, [idx, other_idx]]
169
252
  grouping = true
170
253
  else break
@@ -179,13 +262,14 @@ def group_messages(messages)
179
262
  groups.map do |prefix, indexes|
180
263
  indexes = indexes.to_a
181
264
  next if indexes.empty?
265
+
182
266
  range = (prefix.size)..-1
183
267
  [prefix, indexes.map { |i| messages[i][range] }]
184
268
  end.compact
185
269
  end
186
270
 
187
- def format_grouped_messages(messages, indent: " ", width: TTY::Screen.width)
188
- groups = group_messages(messages)
271
+ def format_grouped_messages(raw_messages, indent: " ", width: TTY::Screen.width)
272
+ groups = group_messages(raw_messages)
189
273
  groups.each_with_object([]) do |(prefix, messages), lines|
190
274
  if prefix.empty?
191
275
  lines.concat(messages.map { |m| "#{indent}#{m.strip}" })
@@ -197,10 +281,11 @@ def format_grouped_messages(messages, indent: " ", width: TTY::Screen.width)
197
281
  msg = messages.shift.strip
198
282
  margin = messages.empty? ? 1 : 2
199
283
  if lines.last.size + margin + msg.size > width
200
- lines << "".dup
284
+ lines.last << ","
285
+ lines << +""
201
286
  lines.last << indent << indent << msg
202
287
  else
203
- lines.last << " " << msg
288
+ lines.last << ", " << msg
204
289
  end
205
290
  end
206
291
  lines.last << "," unless messages.empty?