scint 0.1.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 (72) hide show
  1. checksums.yaml +7 -0
  2. data/FEATURES.md +13 -0
  3. data/README.md +216 -0
  4. data/bin/bundler-vs-scint +233 -0
  5. data/bin/scint +35 -0
  6. data/bin/scint-io-summary +46 -0
  7. data/bin/scint-syscall-trace +41 -0
  8. data/lib/bundler/setup.rb +5 -0
  9. data/lib/bundler.rb +168 -0
  10. data/lib/scint/cache/layout.rb +131 -0
  11. data/lib/scint/cache/metadata_store.rb +75 -0
  12. data/lib/scint/cache/prewarm.rb +192 -0
  13. data/lib/scint/cli/add.rb +85 -0
  14. data/lib/scint/cli/cache.rb +316 -0
  15. data/lib/scint/cli/exec.rb +150 -0
  16. data/lib/scint/cli/install.rb +1047 -0
  17. data/lib/scint/cli/remove.rb +60 -0
  18. data/lib/scint/cli.rb +77 -0
  19. data/lib/scint/commands/exec.rb +17 -0
  20. data/lib/scint/commands/install.rb +17 -0
  21. data/lib/scint/credentials.rb +153 -0
  22. data/lib/scint/debug/io_trace.rb +218 -0
  23. data/lib/scint/debug/sampler.rb +138 -0
  24. data/lib/scint/downloader/fetcher.rb +113 -0
  25. data/lib/scint/downloader/pool.rb +112 -0
  26. data/lib/scint/errors.rb +63 -0
  27. data/lib/scint/fs.rb +119 -0
  28. data/lib/scint/gem/extractor.rb +86 -0
  29. data/lib/scint/gem/package.rb +62 -0
  30. data/lib/scint/gemfile/dependency.rb +30 -0
  31. data/lib/scint/gemfile/editor.rb +93 -0
  32. data/lib/scint/gemfile/parser.rb +275 -0
  33. data/lib/scint/index/cache.rb +166 -0
  34. data/lib/scint/index/client.rb +301 -0
  35. data/lib/scint/index/parser.rb +142 -0
  36. data/lib/scint/installer/extension_builder.rb +264 -0
  37. data/lib/scint/installer/linker.rb +226 -0
  38. data/lib/scint/installer/planner.rb +140 -0
  39. data/lib/scint/installer/preparer.rb +207 -0
  40. data/lib/scint/lockfile/parser.rb +251 -0
  41. data/lib/scint/lockfile/writer.rb +178 -0
  42. data/lib/scint/platform.rb +71 -0
  43. data/lib/scint/progress.rb +579 -0
  44. data/lib/scint/resolver/provider.rb +230 -0
  45. data/lib/scint/resolver/resolver.rb +249 -0
  46. data/lib/scint/runtime/exec.rb +141 -0
  47. data/lib/scint/runtime/setup.rb +45 -0
  48. data/lib/scint/scheduler.rb +392 -0
  49. data/lib/scint/source/base.rb +46 -0
  50. data/lib/scint/source/git.rb +92 -0
  51. data/lib/scint/source/path.rb +70 -0
  52. data/lib/scint/source/rubygems.rb +79 -0
  53. data/lib/scint/vendor/pub_grub/assignment.rb +20 -0
  54. data/lib/scint/vendor/pub_grub/basic_package_source.rb +169 -0
  55. data/lib/scint/vendor/pub_grub/failure_writer.rb +182 -0
  56. data/lib/scint/vendor/pub_grub/incompatibility.rb +150 -0
  57. data/lib/scint/vendor/pub_grub/package.rb +43 -0
  58. data/lib/scint/vendor/pub_grub/partial_solution.rb +121 -0
  59. data/lib/scint/vendor/pub_grub/rubygems.rb +45 -0
  60. data/lib/scint/vendor/pub_grub/solve_failure.rb +19 -0
  61. data/lib/scint/vendor/pub_grub/static_package_source.rb +61 -0
  62. data/lib/scint/vendor/pub_grub/strategy.rb +42 -0
  63. data/lib/scint/vendor/pub_grub/term.rb +105 -0
  64. data/lib/scint/vendor/pub_grub/version.rb +3 -0
  65. data/lib/scint/vendor/pub_grub/version_constraint.rb +129 -0
  66. data/lib/scint/vendor/pub_grub/version_range.rb +423 -0
  67. data/lib/scint/vendor/pub_grub/version_solver.rb +236 -0
  68. data/lib/scint/vendor/pub_grub/version_union.rb +178 -0
  69. data/lib/scint/vendor/pub_grub.rb +32 -0
  70. data/lib/scint/worker_pool.rb +114 -0
  71. data/lib/scint.rb +87 -0
  72. metadata +116 -0
@@ -0,0 +1,579 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Scint
4
+ class Progress
5
+ HIDDEN_TYPES = {
6
+ binstub: true,
7
+ }.freeze
8
+ STREAM_TYPES = {
9
+ download: true,
10
+ extract: true,
11
+ link: true,
12
+ build_ext: true,
13
+ }.freeze
14
+ SETUP_TYPES = {
15
+ fetch_index: true,
16
+ git_clone: true,
17
+ resolve: true,
18
+ }.freeze
19
+ BUILD_TAIL_MAX = 6
20
+ BUILD_TAIL_PREVIEW_LINES = 4
21
+ RENDER_HZ = 5
22
+ RENDER_INTERVAL = 1.0 / RENDER_HZ
23
+ MAX_DETAIL_ROWS_PER_PHASE = 4
24
+ MAX_LINE_LEN = 220
25
+ MIN_RENDER_WIDTH = 40
26
+ MAX_PANEL_ROWS = 14
27
+ SPINNER_FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
28
+ IDLE_MARK = "○".freeze
29
+ PANEL_PHASE_ORDER = %i[download extract build_ext link].freeze
30
+
31
+ PHASE_LABELS = {
32
+ fetch_index: "Fetching index",
33
+ git_clone: "Cloning",
34
+ resolve: "Resolving",
35
+ download: "Downloading",
36
+ extract: "Extraction",
37
+ link: "Installing",
38
+ build_ext: "Compiling",
39
+ }.freeze
40
+ PHASE_SUMMARY_LABELS = {
41
+ download: "Downloads",
42
+ extract: "Extraction",
43
+ link: "Installing",
44
+ build_ext: "Compiling",
45
+ }.freeze
46
+
47
+ def initialize(output: $stderr)
48
+ @output = output
49
+ @interactive = tty_output?(@output)
50
+ @render_width = detect_terminal_width(@output)
51
+ @build_tail_width = [@render_width - 28, 40].max
52
+ @mutex = Thread::Mutex.new
53
+ @started = 0
54
+ @completed = Hash.new(0)
55
+ @failed = Hash.new(0)
56
+ @total = Hash.new(0)
57
+ @active_jobs = {}
58
+ @build_tail = []
59
+ @build_tail_by_name = {}
60
+ @spinner_idx = 0
61
+ @live_rows = 0
62
+ @rendered_widths = []
63
+ @cursor_hidden = false
64
+ @phase_started_at = {}
65
+ @phase_finished = {}
66
+ @phase_elapsed = {}
67
+ @phase_reserved_detail_rows = Hash.new(0)
68
+ @setup_lines_printed = false
69
+ @setup_gap_printed = false
70
+ @render_stop = false
71
+ @render_thread = nil
72
+ end
73
+
74
+ def start
75
+ return unless @interactive
76
+
77
+ @mutex.synchronize do
78
+ start_render_thread_locked
79
+ end
80
+ nil
81
+ end
82
+
83
+ def stop
84
+ thread = nil
85
+ @mutex.synchronize do
86
+ @render_stop = true
87
+ thread = @render_thread
88
+ end
89
+ thread&.join
90
+
91
+ @mutex.synchronize do
92
+ begin
93
+ mark_completed_phase_elapsed_locked
94
+ render_live_locked if any_stream_activity?
95
+ if @interactive && @live_rows.positive?
96
+ move_cursor_down(@live_rows - 1)
97
+ @output.print "\r\n"
98
+ @output.flush if @output.respond_to?(:flush)
99
+ # Final frame is now in scrollback; clear live-block tracking so
100
+ # repeated stop calls do not move/overwrite previously rendered rows.
101
+ @live_rows = 0
102
+ @rendered_widths = []
103
+ end
104
+ ensure
105
+ show_cursor_locked
106
+ @render_thread = nil
107
+ end
108
+ end
109
+ end
110
+
111
+ def on_enqueue(job_id, type, name)
112
+ @mutex.synchronize do
113
+ @total[type] += 1 unless hidden_type?(type)
114
+ end
115
+ end
116
+
117
+ def on_start(job_id, type, name)
118
+ return if hidden_type?(type)
119
+
120
+ @mutex.synchronize do
121
+ @active_jobs[job_id] = { type: type, name: name }
122
+ @started += 1
123
+ emit_setup_gap_if_needed(type)
124
+ @phase_started_at[type] ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) if stream_type?(type)
125
+ if @interactive && stream_type?(type)
126
+ start_render_thread_locked
127
+ else
128
+ clear_live_block_locked
129
+ emit_setup_or_log_line(type, name)
130
+ end
131
+ end
132
+ end
133
+
134
+ def on_complete(job_id, type, name)
135
+ @mutex.synchronize do
136
+ @completed[type] += 1
137
+ active = @active_jobs.delete(job_id)
138
+ @build_tail_by_name.delete(active[:name]) if active
139
+ if !@interactive || !stream_type?(type)
140
+ emit_phase_completion_locked(type)
141
+ end
142
+ end
143
+ end
144
+
145
+ def on_fail(job_id, type, name, error)
146
+ return if hidden_type?(type)
147
+
148
+ @mutex.synchronize do
149
+ active = @active_jobs.delete(job_id)
150
+ @build_tail_by_name.delete(active[:name]) if active
151
+ @failed[type] += 1
152
+ clear_live_block_locked
153
+ label = PHASE_LABELS[type] || type.to_s
154
+ @output.puts "#{RED}FAILED#{RESET} #{label} #{BOLD}#{name}#{RESET}: #{error.message}"
155
+ if !@interactive
156
+ emit_phase_completion_locked(type)
157
+ end
158
+ end
159
+ end
160
+
161
+ # Accepts the latest build command output lines for one gem and prints
162
+ # a rolling tail so native build activity is visible without log spam.
163
+ def on_build_tail(name, lines)
164
+ cleaned = Array(lines).map { |line| line.to_s.strip }.reject(&:empty?)
165
+ return if cleaned.empty?
166
+
167
+ @mutex.synchronize do
168
+ truncated = cleaned.map { |line| truncate_plain(line, @build_tail_width) }
169
+ truncated.each { |line| @build_tail << "#{name}: #{line}" }
170
+ @build_tail = @build_tail.last(BUILD_TAIL_MAX)
171
+ @build_tail_by_name[name] ||= []
172
+ @build_tail_by_name[name].concat(truncated)
173
+ @build_tail_by_name[name] = @build_tail_by_name[name].last(BUILD_TAIL_MAX)
174
+
175
+ unless @interactive
176
+ @output.puts "#{DIM} build tail#{RESET}"
177
+ @build_tail.last(4).each do |line|
178
+ @output.puts "#{DIM} #{truncate_plain(line, 180)}#{RESET}"
179
+ end
180
+ end
181
+ end
182
+ end
183
+
184
+ def summary
185
+ total_completed = @completed.values.sum
186
+ total_failed = @failed.values.sum
187
+ if total_failed > 0
188
+ "#{total_completed} gems installed, #{total_failed} failed"
189
+ else
190
+ "#{total_completed} gems processed"
191
+ end
192
+ end
193
+
194
+ private
195
+
196
+ def hidden_type?(type)
197
+ HIDDEN_TYPES[type] == true
198
+ end
199
+
200
+ def render_live_locked
201
+ return unless @interactive
202
+ return unless any_stream_activity?
203
+
204
+ mark_completed_phase_elapsed_locked
205
+
206
+ lines = []
207
+ spinner = SPINNER_FRAMES[@spinner_idx % SPINNER_FRAMES.length]
208
+ @spinner_idx += 1
209
+
210
+ lines.concat(phase_lines(spinner))
211
+ lines = clamp_panel_rows(lines)
212
+
213
+ redraw_live_block_locked(lines)
214
+ end
215
+
216
+ def truncate_plain(text, max_len)
217
+ return text if text.length <= max_len
218
+
219
+ "#{text[0, max_len - 1]}…"
220
+ end
221
+
222
+ def stream_type?(type)
223
+ STREAM_TYPES[type] == true
224
+ end
225
+
226
+ def stream_active_or_pending?
227
+ return true if STREAM_TYPES.keys.any? { |type| !active_stream_jobs_for(type).empty? }
228
+
229
+ STREAM_TYPES.keys.any? do |type|
230
+ @total[type].positive? && (@completed[type] + @failed[type] < @total[type])
231
+ end
232
+ end
233
+
234
+ def any_stream_activity?
235
+ STREAM_TYPES.keys.any? do |type|
236
+ @total[type].positive? || @completed[type].positive? || @failed[type].positive? || !active_stream_jobs_for(type).empty?
237
+ end
238
+ end
239
+
240
+ def phase_lines(spinner)
241
+ PANEL_PHASE_ORDER.flat_map do |type|
242
+ phase_rows_for(type, spinner)
243
+ end
244
+ end
245
+
246
+ def phase_rows_for(type, spinner)
247
+ active = active_stream_jobs_for(type)
248
+ total = @total[type]
249
+ completed = @completed[type] + @failed[type]
250
+ total = completed if total < completed
251
+
252
+ label = PHASE_SUMMARY_LABELS[type] || (PHASE_LABELS[type] || type.to_s)
253
+ line = phase_status_line(type, active, label, completed, total, spinner)
254
+ detail_rows = phase_detail_rows_for(type, active)
255
+ [line, *reserve_phase_detail_space(type, detail_rows, total, completed, active)]
256
+ end
257
+
258
+ def phase_status_line(type, active_jobs, label, completed, total, spinner)
259
+ base = "#{label}... (#{completed}/#{total})"
260
+ if active_jobs.empty?
261
+ elapsed = @phase_elapsed[type]
262
+ elapsed_text = elapsed ? " · #{format_phase_elapsed(elapsed)}" : ""
263
+ "#{DIM}#{IDLE_MARK} #{base}#{elapsed_text}#{RESET}"
264
+ else
265
+ name = active_jobs.first[:name]
266
+ "#{spinner} #{base} · #{BOLD}#{name}#{RESET}"
267
+ end
268
+ end
269
+
270
+ def phase_detail_rows_for(type, active_jobs)
271
+ return [] if active_jobs.empty?
272
+
273
+ if type == :build_ext
274
+ compile_tail_rows_for(active_jobs.first[:name])
275
+ else
276
+ active_rows_for(type, active_jobs)
277
+ end
278
+ end
279
+
280
+ def reserve_phase_detail_space(type, details, total, completed, active_jobs)
281
+ if phase_complete?(type, total, completed, active_jobs)
282
+ @phase_reserved_detail_rows[type] = 0
283
+ return []
284
+ end
285
+
286
+ desired = [details.length, MAX_DETAIL_ROWS_PER_PHASE].min
287
+ @phase_reserved_detail_rows[type] = [@phase_reserved_detail_rows[type], desired].max
288
+ reserved = @phase_reserved_detail_rows[type]
289
+
290
+ rows = details.first(reserved)
291
+ while rows.length < reserved
292
+ rows << "#{DIM} #{RESET}"
293
+ end
294
+ rows
295
+ end
296
+
297
+ def phase_complete?(type, total, completed, active_jobs)
298
+ return false if total.zero?
299
+
300
+ if completed >= total && active_jobs.empty?
301
+ mark_phase_elapsed_locked(type)
302
+ return true
303
+ end
304
+
305
+ false
306
+ end
307
+
308
+ def active_rows_for(_type, active_jobs)
309
+ return [] if active_jobs.empty?
310
+ if active_jobs.length <= MAX_DETAIL_ROWS_PER_PHASE
311
+ return active_jobs.map { |job| " #{BOLD}#{job[:name]}#{RESET}" }
312
+ end
313
+
314
+ rows = active_jobs.first(MAX_DETAIL_ROWS_PER_PHASE - 1).map { |job| " #{BOLD}#{job[:name]}#{RESET}" }
315
+ remaining = active_jobs.length - rows.length
316
+ rows << "#{DIM} +#{remaining} more#{RESET}"
317
+ rows
318
+ end
319
+
320
+ def compile_tail_rows_for(name)
321
+ tail = @build_tail_by_name[name]
322
+ return [] if tail.nil? || tail.empty?
323
+
324
+ tail.last(BUILD_TAIL_PREVIEW_LINES).map do |line|
325
+ "#{DIM} #{truncate_plain(line, @build_tail_width)}#{RESET}"
326
+ end
327
+ end
328
+
329
+ def active_stream_jobs_for(type)
330
+ @active_jobs.values.select { |job| job[:type] == type }
331
+ end
332
+
333
+ def clear_live_block_locked
334
+ return unless @interactive
335
+ return if @live_rows.zero?
336
+
337
+ move_cursor_up(@live_rows - 1)
338
+ @live_rows.times do |idx|
339
+ prev_width = @rendered_widths[idx] || 0
340
+ @output.print "\r#{' ' * prev_width}"
341
+ @output.print "\n" if idx < (@live_rows - 1)
342
+ end
343
+ move_cursor_up(@live_rows - 1)
344
+ @output.print "\r"
345
+ @output.flush if @output.respond_to?(:flush)
346
+ @live_rows = 0
347
+ @rendered_widths = []
348
+ end
349
+
350
+ def redraw_live_block_locked(lines)
351
+ hide_cursor_locked
352
+ previous_rows = @live_rows
353
+ previous_widths = @rendered_widths.dup
354
+
355
+ move_cursor_up(previous_rows - 1) if previous_rows.positive?
356
+
357
+ max_rows = [previous_rows, lines.length].max
358
+ new_widths = []
359
+
360
+ max_rows.times do |idx|
361
+ line = idx < lines.length ? fit_line(lines[idx], @render_width) : ""
362
+ prev_width = previous_widths[idx] || 0
363
+ line_width = visible_width(line)
364
+ pad_len = prev_width > line_width ? (prev_width - line_width) : 0
365
+ pad = pad_len.positive? ? (" " * pad_len) : ""
366
+
367
+ @output.print "\r#{line}#{pad}"
368
+ @output.print "\n" if idx < (max_rows - 1)
369
+ new_widths << line_width
370
+ end
371
+
372
+ move_cursor_up(max_rows - lines.length) if max_rows > lines.length
373
+ @output.flush if @output.respond_to?(:flush)
374
+ @live_rows = lines.length
375
+ @rendered_widths = new_widths.first(lines.length)
376
+ end
377
+
378
+ def clamp_panel_rows(lines)
379
+ return lines if lines.length <= MAX_PANEL_ROWS
380
+
381
+ clipped = lines.first(MAX_PANEL_ROWS - 1)
382
+ clipped << "#{DIM}...#{RESET}"
383
+ clipped
384
+ end
385
+
386
+ def move_cursor_up(lines)
387
+ return if lines <= 0
388
+
389
+ @output.print "\e[#{lines}A"
390
+ end
391
+
392
+ def move_cursor_down(lines)
393
+ return if lines <= 0
394
+
395
+ @output.print "\e[#{lines}B"
396
+ end
397
+
398
+ def start_render_thread_locked
399
+ return if @render_thread&.alive?
400
+
401
+ @render_stop = false
402
+ @render_thread = Thread.new do
403
+ loop do
404
+ should_stop = false
405
+ @mutex.synchronize do
406
+ should_stop = @render_stop
407
+ unless should_stop
408
+ render_live_locked
409
+ should_stop = render_done_locked?
410
+ end
411
+ end
412
+ break if should_stop
413
+ sleep(RENDER_INTERVAL)
414
+ end
415
+ rescue StandardError
416
+ nil
417
+ end
418
+ end
419
+
420
+ def hide_cursor_locked
421
+ return if @cursor_hidden
422
+
423
+ @output.print "\e[?25l"
424
+ @output.flush if @output.respond_to?(:flush)
425
+ @cursor_hidden = true
426
+ end
427
+
428
+ def show_cursor_locked
429
+ return unless @cursor_hidden
430
+
431
+ @output.print "\e[?25h"
432
+ @output.flush if @output.respond_to?(:flush)
433
+ @cursor_hidden = false
434
+ end
435
+
436
+ def emit_setup_or_log_line(type, name)
437
+ label = PHASE_LABELS[type] || type.to_s
438
+ if SETUP_TYPES[type]
439
+ @output.puts "#{BOLD}#{label}#{RESET} #{name}"
440
+ @setup_lines_printed = true
441
+ else
442
+ @output.puts "#{GREEN}[#{@started}/#{@total.values.sum}]#{RESET} #{label} #{BOLD}#{name}#{RESET}"
443
+ end
444
+ end
445
+
446
+ def emit_setup_gap_if_needed(type)
447
+ return if @setup_gap_printed
448
+ return unless stream_type?(type)
449
+ return unless @setup_lines_printed
450
+
451
+ @output.puts
452
+ @setup_gap_printed = true
453
+ end
454
+
455
+ def emit_phase_completion_locked(type)
456
+ return unless stream_type?(type)
457
+ return if @phase_finished[type]
458
+
459
+ total = @total[type]
460
+ return if total.zero?
461
+
462
+ done = @completed[type] + @failed[type]
463
+ return if done < total
464
+
465
+ started_at = @phase_started_at[type] || Process.clock_gettime(Process::CLOCK_MONOTONIC)
466
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
467
+ failed = @failed[type]
468
+ label = PHASE_SUMMARY_LABELS[type] || (PHASE_LABELS[type] || type.to_s)
469
+ failure_suffix = failed.positive? ? " (#{failed} failed)" : ""
470
+ @phase_elapsed[type] ||= elapsed
471
+ line = "#{DIM}#{label}#{RESET} #{done}/#{total} in #{DIM}#{format_phase_elapsed(elapsed)}#{RESET}#{failure_suffix}"
472
+
473
+ clear_live_block_locked if @interactive
474
+ @output.print "\r"
475
+ @output.puts line
476
+ @phase_finished[type] = true
477
+ end
478
+
479
+ def mark_phase_elapsed_locked(type)
480
+ return unless stream_type?(type)
481
+ return if @phase_elapsed.key?(type)
482
+
483
+ total = @total[type]
484
+ return if total.zero?
485
+
486
+ done = @completed[type] + @failed[type]
487
+ return if done < total
488
+
489
+ started_at = @phase_started_at[type] || Process.clock_gettime(Process::CLOCK_MONOTONIC)
490
+ @phase_elapsed[type] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
491
+ end
492
+
493
+ def mark_completed_phase_elapsed_locked
494
+ PANEL_PHASE_ORDER.each do |type|
495
+ total = @total[type]
496
+ next if total.zero?
497
+ next unless active_stream_jobs_for(type).empty?
498
+
499
+ done = @completed[type] + @failed[type]
500
+ mark_phase_elapsed_locked(type) if done >= total
501
+ end
502
+ end
503
+
504
+ def render_done_locked?
505
+ any_stream_activity? && !stream_active_or_pending?
506
+ end
507
+
508
+ def format_phase_elapsed(seconds)
509
+ ms = (seconds * 1000.0).round
510
+ return "#{ms}ms" if ms < 1000
511
+
512
+ format("%.2fs", seconds)
513
+ end
514
+
515
+ def fit_line(text, max_width)
516
+ line = text.to_s
517
+ return line if visible_width(line) <= max_width
518
+
519
+ target = [max_width - 1, 1].max
520
+ visible = 0
521
+ out = +""
522
+
523
+ line.scan(/\e\[[0-9;?]*[ -\/]*[@-~]|[^\e]+/).each do |token|
524
+ if token.start_with?("\e[")
525
+ out << token
526
+ next
527
+ end
528
+
529
+ token.each_char do |ch|
530
+ break if visible >= target
531
+ out << ch
532
+ visible += 1
533
+ end
534
+ break if visible >= target
535
+ end
536
+
537
+ "#{out}…"
538
+ end
539
+
540
+ def visible_width(text)
541
+ text.to_s.gsub(/\e\[[0-9;?]*[ -\/]*[@-~]/, "").length
542
+ end
543
+
544
+ def detect_terminal_width(io)
545
+ from_env = ENV["COLUMNS"].to_i
546
+ width = from_env if from_env.positive?
547
+
548
+ if width.nil? && io.respond_to?(:winsize)
549
+ begin
550
+ winsize = io.winsize
551
+ width = winsize[1] if winsize && winsize[1].to_i.positive?
552
+ rescue StandardError
553
+ width = nil
554
+ end
555
+ end
556
+
557
+ if width.nil?
558
+ begin
559
+ require "io/console"
560
+ console = IO.console
561
+ winsize = console&.winsize
562
+ width = winsize[1] if winsize && winsize[1].to_i.positive?
563
+ rescue StandardError
564
+ width = nil
565
+ end
566
+ end
567
+
568
+ width ||= MAX_LINE_LEN
569
+ width = [width, MAX_LINE_LEN].min
570
+ [width, MIN_RENDER_WIDTH].max
571
+ end
572
+
573
+ def tty_output?(io)
574
+ io.respond_to?(:tty?) && io.tty?
575
+ rescue StandardError
576
+ false
577
+ end
578
+ end
579
+ end