autobuild 1.17.0 → 1.21.0

Sign up to get free protection for your applications and to get access to all the features.
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?