reviewer 0.1.5 → 1.0.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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/.github/FUNDING.yml +3 -0
  3. data/.github/workflows/main.yml +79 -11
  4. data/.github/workflows/release.yml +98 -0
  5. data/.gitignore +1 -1
  6. data/.inch.yml +3 -1
  7. data/.reek.yml +175 -0
  8. data/.reviewer.example.yml +7 -2
  9. data/.reviewer.yml +166 -40
  10. data/.rubocop.yml +34 -2
  11. data/CHANGELOG.md +42 -2
  12. data/Gemfile +39 -1
  13. data/Gemfile.lock +291 -70
  14. data/LICENSE.txt +20 -4
  15. data/README.md +310 -21
  16. data/RELEASING.md +190 -0
  17. data/Rakefile +117 -0
  18. data/dependency_decisions.yml +61 -0
  19. data/exe/fmt +1 -1
  20. data/exe/rvw +1 -1
  21. data/lib/reviewer/arguments/files.rb +47 -20
  22. data/lib/reviewer/arguments/keywords.rb +34 -41
  23. data/lib/reviewer/arguments/tags.rb +11 -11
  24. data/lib/reviewer/arguments.rb +100 -29
  25. data/lib/reviewer/batch/formatter.rb +87 -0
  26. data/lib/reviewer/batch.rb +32 -48
  27. data/lib/reviewer/capabilities.rb +81 -0
  28. data/lib/reviewer/command/string/env.rb +12 -6
  29. data/lib/reviewer/command/string/flags.rb +2 -4
  30. data/lib/reviewer/command/string.rb +47 -12
  31. data/lib/reviewer/command.rb +65 -10
  32. data/lib/reviewer/configuration/loader.rb +70 -0
  33. data/lib/reviewer/configuration.rb +6 -3
  34. data/lib/reviewer/context.rb +15 -0
  35. data/lib/reviewer/doctor/config_check.rb +46 -0
  36. data/lib/reviewer/doctor/environment_check.rb +58 -0
  37. data/lib/reviewer/doctor/formatter.rb +75 -0
  38. data/lib/reviewer/doctor/keyword_check.rb +85 -0
  39. data/lib/reviewer/doctor/opportunity_check.rb +88 -0
  40. data/lib/reviewer/doctor/report.rb +63 -0
  41. data/lib/reviewer/doctor/tool_inventory.rb +41 -0
  42. data/lib/reviewer/doctor.rb +28 -0
  43. data/lib/reviewer/history.rb +10 -17
  44. data/lib/reviewer/output/formatting.rb +40 -0
  45. data/lib/reviewer/output/printer.rb +70 -9
  46. data/lib/reviewer/output.rb +37 -78
  47. data/lib/reviewer/prompt.rb +38 -0
  48. data/lib/reviewer/report/formatter.rb +124 -0
  49. data/lib/reviewer/report.rb +100 -0
  50. data/lib/reviewer/runner/failed_files.rb +66 -0
  51. data/lib/reviewer/runner/formatter.rb +103 -0
  52. data/lib/reviewer/runner/guidance.rb +79 -0
  53. data/lib/reviewer/runner/result.rb +150 -0
  54. data/lib/reviewer/runner/strategies/captured.rb +98 -23
  55. data/lib/reviewer/runner/strategies/passthrough.rb +2 -11
  56. data/lib/reviewer/runner.rb +126 -40
  57. data/lib/reviewer/session/formatter.rb +87 -0
  58. data/lib/reviewer/session.rb +208 -0
  59. data/lib/reviewer/setup/catalog.rb +233 -0
  60. data/lib/reviewer/setup/detector.rb +61 -0
  61. data/lib/reviewer/setup/formatter.rb +94 -0
  62. data/lib/reviewer/setup/gemfile_lock.rb +55 -0
  63. data/lib/reviewer/setup/generator.rb +54 -0
  64. data/lib/reviewer/setup/tool_block.rb +112 -0
  65. data/lib/reviewer/setup.rb +41 -0
  66. data/lib/reviewer/shell/result.rb +14 -15
  67. data/lib/reviewer/shell/timer.rb +40 -35
  68. data/lib/reviewer/shell.rb +41 -12
  69. data/lib/reviewer/tool/conversions.rb +20 -0
  70. data/lib/reviewer/tool/file_resolver.rb +54 -0
  71. data/lib/reviewer/tool/settings.rb +88 -44
  72. data/lib/reviewer/tool/test_file_mapper.rb +73 -0
  73. data/lib/reviewer/tool/timing.rb +78 -0
  74. data/lib/reviewer/tool.rb +88 -69
  75. data/lib/reviewer/tools.rb +47 -33
  76. data/lib/reviewer/version.rb +1 -1
  77. data/lib/reviewer.rb +109 -50
  78. data/reviewer.gemspec +16 -19
  79. metadata +101 -142
  80. data/lib/reviewer/conversions.rb +0 -16
  81. data/lib/reviewer/guidance.rb +0 -77
  82. data/lib/reviewer/keywords/git/staged.rb +0 -64
  83. data/lib/reviewer/keywords/git.rb +0 -14
  84. data/lib/reviewer/keywords.rb +0 -9
  85. data/lib/reviewer/loader.rb +0 -59
  86. data/lib/reviewer/output/scrubber.rb +0 -48
  87. data/lib/reviewer/output/token.rb +0 -85
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module Reviewer
6
+ module Doctor
7
+ # Checks environment prerequisites (git, Ruby version)
8
+ class EnvironmentCheck
9
+ attr_reader :report
10
+
11
+ # Creates an environment checker for Ruby and git availability
12
+ # @param report [Doctor::Report] the report to add findings to
13
+ #
14
+ # @return [EnvironmentCheck]
15
+ def initialize(report)
16
+ @report = report
17
+ end
18
+
19
+ # Checks Ruby version and git availability
20
+ def check
21
+ check_ruby_version
22
+ check_git
23
+ end
24
+
25
+ private
26
+
27
+ def check_ruby_version
28
+ report.add(:environment, status: :ok, message: "Ruby #{RUBY_VERSION}")
29
+ end
30
+
31
+ def check_git
32
+ stdout, _stderr, status = Open3.capture3('git --version')
33
+
34
+ unless status.success?
35
+ report.add(:environment, status: :warning,
36
+ message: 'Git not available',
37
+ detail: 'Git keywords (staged, modified, etc.) require git')
38
+ return
39
+ end
40
+
41
+ report.add(:environment, status: :ok, message: stdout.strip)
42
+ check_git_repo
43
+ end
44
+
45
+ def check_git_repo
46
+ _stdout, _stderr, status = Open3.capture3('git rev-parse --git-dir')
47
+
48
+ if status.success?
49
+ report.add(:environment, status: :ok, message: 'Inside a git repository')
50
+ else
51
+ report.add(:environment, status: :warning,
52
+ message: 'Not inside a git repository',
53
+ detail: 'Git keywords (staged, modified, etc.) will not work')
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../output/formatting'
4
+
5
+ module Reviewer
6
+ module Doctor
7
+ # Display logic for diagnostic reports
8
+ class Formatter
9
+ include Output::Formatting
10
+
11
+ attr_reader :output, :printer
12
+ private :output, :printer
13
+
14
+ SYMBOLS = { ok: "\u2713", warning: '!', error: "\u2717", info: "\u00b7", muted: "\u00b7" }.freeze
15
+ STYLES = { ok: :success, warning: :warning, error: :failure, info: :muted, muted: :muted }.freeze
16
+
17
+ SECTION_LABELS = {
18
+ configuration: 'Configuration',
19
+ tools: 'Tools',
20
+ opportunities: 'Opportunities',
21
+ environment: 'Environment'
22
+ }.freeze
23
+
24
+ # Creates a formatter for diagnostic report display
25
+ # @param output [Output] the console output handler
26
+ #
27
+ # @return [Formatter]
28
+ def initialize(output)
29
+ @output = output
30
+ @printer = output.printer
31
+ end
32
+
33
+ # Renders a full diagnostic report
34
+ # @param report [Doctor::Report] the report to display
35
+ def print(report)
36
+ output.newline
37
+ Doctor::Report::SECTIONS.each do |section|
38
+ findings = report.section(section)
39
+ print_section(section, findings) if findings.any?
40
+ end
41
+ print_summary(report)
42
+ end
43
+
44
+ private
45
+
46
+ def print_section(section, findings)
47
+ printer.puts(:bold, SECTION_LABELS.fetch(section) { section.to_s.capitalize })
48
+ findings.each { |finding| print_finding(finding) }
49
+ output.newline
50
+ end
51
+
52
+ def print_finding(finding)
53
+ status = finding.status
54
+ message = finding.message
55
+ detail = finding.detail
56
+
57
+ symbol = SYMBOLS.fetch(status) { ' ' }
58
+ style = STYLES.fetch(status) { :default }
59
+
60
+ printer.print(style, " #{symbol} ")
61
+ printer.puts(:default, message)
62
+ printer.puts(:muted, " #{detail}") if detail
63
+ end
64
+
65
+ def print_summary(report)
66
+ if report.ok?
67
+ printer.puts(:success, 'No issues found')
68
+ else
69
+ printer.puts(:failure, pluralize(report.errors.size, 'issue found', 'issues found'))
70
+ end
71
+ output.newline
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewer
4
+ module Doctor
5
+ # Detects conflicts between configured tool names, tags, and reserved keywords
6
+ class KeywordCheck
7
+ attr_reader :report
8
+
9
+ RESERVED = Arguments::Keywords::RESERVED
10
+
11
+ # Creates a keyword check that scans for keyword conflicts in configuration
12
+ # @param report [Doctor::Report] the report to add findings to
13
+ # @param configuration [Configuration] the configuration to check
14
+ # @param tools [Tools] the tools collection to analyze
15
+ #
16
+ # @return [KeywordCheck]
17
+ def initialize(report, configuration:, tools:)
18
+ @report = report
19
+ @configuration = configuration
20
+ @tools = tools
21
+ end
22
+
23
+ # Checks for keyword conflicts between tool names, tags, and reserved keywords
24
+ def check
25
+ return unless @configuration.file.exist?
26
+
27
+ check_tool_names_vs_reserved
28
+ check_tags_vs_reserved
29
+ check_tool_names_vs_tags
30
+ rescue Configuration::Loader::MissingConfigurationError
31
+ # Tools may reference a stale config path — nothing to check
32
+ end
33
+
34
+ private
35
+
36
+ def all_tools = @all_tools ||= @tools.all
37
+
38
+ def tool_names = all_tools.map { |tool| tool.key.to_s }
39
+
40
+ def all_tags = all_tools.flat_map(&:tags).uniq
41
+
42
+ # Names of tools that use a given tag
43
+ def tools_with_tag(tag)
44
+ all_tools.select { |tool| tool.tags.include?(tag) }.map(&:name)
45
+ end
46
+
47
+ # Tags that belong to tools OTHER than the named tool
48
+ def tags_from_other_tools(tool_name)
49
+ all_tools.reject { |tool| tool.key.to_s == tool_name }.flat_map(&:tags).uniq
50
+ end
51
+
52
+ def check_tool_names_vs_reserved
53
+ (tool_names & RESERVED).each do |name|
54
+ report.add(:configuration,
55
+ status: :warning,
56
+ message: "Tool name '#{name}' shadows reserved keyword '#{name}'",
57
+ detail: "Reserved keywords (#{RESERVED.join(', ')}) trigger special behavior. " \
58
+ 'Rename this tool to avoid unexpected results.')
59
+ end
60
+ end
61
+
62
+ def check_tags_vs_reserved
63
+ (all_tags & RESERVED).each do |tag|
64
+ report.add(:configuration,
65
+ status: :warning,
66
+ message: "Tag '#{tag}' shadows reserved keyword '#{tag}' (used by #{tools_with_tag(tag).join(', ')})",
67
+ detail: "Reserved keywords (#{RESERVED.join(', ')}) trigger special behavior. " \
68
+ 'Rename this tag or use -t to pass tags explicitly.')
69
+ end
70
+ end
71
+
72
+ def check_tool_names_vs_tags
73
+ tool_names.each do |name|
74
+ next unless tags_from_other_tools(name).include?(name)
75
+
76
+ report.add(:configuration,
77
+ status: :warning,
78
+ message: "Tool name '#{name}' is also a tag on: #{tools_with_tag(name).join(', ')}",
79
+ detail: "'rvw #{name}' will run both the '#{name}' tool and tagged tools. " \
80
+ 'Use -t to target tags explicitly if this is unintended.')
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewer
4
+ module Doctor
5
+ # Suggests improvements based on current configuration and project state
6
+ class OpportunityCheck
7
+ attr_reader :report, :project_dir
8
+
9
+ # Creates an opportunity checker that scans for unconfigured tools and missing features
10
+ # @param report [Doctor::Report] the report to add findings to
11
+ # @param project_dir [Pathname] the project root for tool detection
12
+ # @param configuration [Configuration] the configuration to check
13
+ # @param tools [Tools] the tools collection to analyze
14
+ #
15
+ # @return [OpportunityCheck]
16
+ def initialize(report, project_dir, configuration:, tools:)
17
+ @report = report
18
+ @project_dir = project_dir
19
+ @configuration = configuration
20
+ @tools = tools
21
+ end
22
+
23
+ # Checks for unconfigured tools, missing file targeting, and missing format commands
24
+ def check
25
+ return unless @configuration.file.exist?
26
+
27
+ check_unconfigured_tools
28
+ check_missing_files_config
29
+ check_missing_format_command
30
+ end
31
+
32
+ private
33
+
34
+ def check_unconfigured_tools
35
+ detected = Setup::Detector.new(project_dir).detect
36
+ configured_keys = @tools.all.map(&:key)
37
+
38
+ detected.each do |result|
39
+ next if configured_keys.include?(result.key)
40
+
41
+ report.add(:opportunities, status: :info,
42
+ message: "#{result.name} detected but not configured",
43
+ detail: result.reasons.join(', '))
44
+ end
45
+ end
46
+
47
+ def check_missing_files_config
48
+ @tools.all.each do |tool|
49
+ next if tool.skip_in_batch?
50
+ next if tool.supports_files?
51
+ next unless catalog_supports?(tool.key, :files)
52
+
53
+ report.add(:opportunities, status: :info,
54
+ message: "#{tool.name} has no file targeting configured",
55
+ detail: 'Add a `files` section to enable staged/modified file scoping')
56
+ end
57
+ end
58
+
59
+ def check_missing_format_command
60
+ @tools.all.each do |tool|
61
+ next if tool.skip_in_batch?
62
+ next if tool.formattable?
63
+ next unless catalog_supports?(tool.key, :format)
64
+
65
+ report.add(:opportunities, status: :info,
66
+ message: "#{tool.name} has no format command",
67
+ detail: 'Add a `format` command to enable `fmt` support')
68
+ end
69
+ end
70
+
71
+ # Returns true only if the catalog knows this tool AND the catalog entry
72
+ # includes the given capability (:files or :format command)
73
+ def catalog_supports?(key, capability)
74
+ entry = Setup::Catalog::TOOLS[key]
75
+ return false unless entry
76
+
77
+ case capability
78
+ when :files
79
+ entry.key?(:files)
80
+ when :format
81
+ entry.dig(:commands, :format) ? true : false
82
+ else
83
+ false
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewer
4
+ module Doctor
5
+ # Structured container for diagnostic findings organized by section
6
+ class Report
7
+ # A single diagnostic finding with status, message, and optional detail.
8
+ # @!attribute status [rw]
9
+ # @return [Symbol] the severity (:ok, :warning, :error, or :info)
10
+ # @!attribute message [rw]
11
+ # @return [String] the finding summary
12
+ # @!attribute detail [rw]
13
+ # @return [String, nil] optional detail or guidance text
14
+ Finding = Struct.new(:status, :message, :detail, keyword_init: true)
15
+
16
+ # Ordered list of report sections
17
+ SECTIONS = %i[configuration tools opportunities environment].freeze
18
+
19
+ attr_reader :findings
20
+
21
+ def initialize
22
+ @findings = Hash.new { |hash, key| hash[key] = [] }
23
+ end
24
+
25
+ # Adds a finding to a section
26
+ # @param section [Symbol] one of SECTIONS
27
+ # @param status [Symbol] :ok, :warning, :error, or :info
28
+ # @param message [String] the finding summary
29
+ # @param detail [String, nil] optional detail text
30
+ def add(section, status:, message:, detail: nil)
31
+ findings[section] << Finding.new(status: status, message: message, detail: detail)
32
+ end
33
+
34
+ # Whether all findings are free of errors
35
+ def ok?
36
+ all_findings.none? { |finding| finding.status == :error }
37
+ end
38
+
39
+ # All error findings across sections
40
+ def errors
41
+ all_findings.select { |finding| finding.status == :error }
42
+ end
43
+
44
+ # All warning findings across sections
45
+ def warnings
46
+ all_findings.select { |finding| finding.status == :warning }
47
+ end
48
+
49
+ # Findings for a specific section
50
+ # @param name [Symbol] the section name
51
+ # @return [Array<Finding>] findings for that section
52
+ def section(name)
53
+ findings[name]
54
+ end
55
+
56
+ private
57
+
58
+ def all_findings
59
+ findings.values.flatten
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewer
4
+ module Doctor
5
+ # Reports the status of each configured tool
6
+ class ToolInventory
7
+ attr_reader :report
8
+
9
+ # Creates a tool inventory check that reports batch/skip status for each tool
10
+ # @param report [Doctor::Report] the report to add findings to
11
+ # @param configuration [Configuration] the configuration to check
12
+ # @param tools [Tools] the tools collection to report on
13
+ #
14
+ # @return [ToolInventory]
15
+ def initialize(report, configuration:, tools:)
16
+ @report = report
17
+ @configuration = configuration
18
+ @tools = tools
19
+ end
20
+
21
+ # Reports batch/skip status and available commands for each configured tool
22
+ def check
23
+ return unless @configuration.file.exist?
24
+
25
+ @tools.all.each do |tool|
26
+ skipped = tool.skip_in_batch?
27
+
28
+ report.add(:tools,
29
+ status: skipped ? :muted : :ok,
30
+ message: "#{tool.name} (#{tool.key}) — #{command_summary(tool)}")
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def command_summary(tool)
37
+ %i[review format install prepare].select { |cmd| tool.command?(cmd) }.join(', ')
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'doctor/formatter'
4
+ require_relative 'doctor/report'
5
+ require_relative 'doctor/config_check'
6
+ require_relative 'doctor/keyword_check'
7
+ require_relative 'doctor/tool_inventory'
8
+ require_relative 'doctor/opportunity_check'
9
+ require_relative 'doctor/environment_check'
10
+
11
+ module Reviewer
12
+ # Diagnostic module for checking configuration, tools, and environment health
13
+ module Doctor
14
+ # Runs all diagnostic checks and returns a structured report
15
+ # @param project_dir [Pathname] the project root to scan
16
+ #
17
+ # @return [Doctor::Report] the complete diagnostic report
18
+ def self.run(configuration:, tools:, project_dir: Pathname.pwd)
19
+ report = Report.new
20
+ ConfigCheck.new(report, configuration: configuration).check
21
+ KeywordCheck.new(report, configuration: configuration, tools: tools).check
22
+ ToolInventory.new(report, configuration: configuration, tools: tools).check
23
+ OpportunityCheck.new(report, project_dir, configuration: configuration, tools: tools).check
24
+ EnvironmentCheck.new(report).check
25
+ report
26
+ end
27
+ end
28
+ end
@@ -17,11 +17,11 @@ module Reviewer
17
17
  class History
18
18
  attr_reader :file, :store
19
19
 
20
- # Creates an instance of a YAML::Store-backed history file.
21
- # @param file = Reviewer.configuration.history_file [Pathname] the history file to store data
20
+ # Creates an instance of a YAML::Store-backed history file
21
+ # @param file [Pathname] the history file to store data
22
22
  #
23
- # @return [History] an instance of history
24
- def initialize(file = Reviewer.configuration.history_file)
23
+ # @return [History]
24
+ def initialize(file:)
25
25
  @file = file
26
26
  @store = YAML::Store.new(file)
27
27
  end
@@ -33,9 +33,9 @@ module Reviewer
33
33
  #
34
34
  # @return [Primitive] the value being stored
35
35
  def set(group, attribute, value)
36
- store.transaction do |s|
37
- s[group] = {} if s[group].nil?
38
- s[group][attribute] = value
36
+ store.transaction do
37
+ store[group] ||= {}
38
+ store[group][attribute] = value
39
39
  end
40
40
  end
41
41
 
@@ -45,25 +45,18 @@ module Reviewer
45
45
  #
46
46
  # @return [Primitive] the value being stored
47
47
  def get(group, attribute)
48
- store.transaction do |s|
49
- s[group].nil? ? nil : s[group][attribute]
48
+ store.transaction(true) do
49
+ store[group]&.[](attribute)
50
50
  end
51
51
  end
52
52
 
53
53
  # Removes the existing history file.
54
54
  #
55
55
  # @return [void]
56
- def reset!
56
+ def clear
57
57
  return unless File.exist?(file)
58
58
 
59
59
  FileUtils.rm(file)
60
60
  end
61
-
62
- # Convenience class method for removing the history file.
63
- #
64
- # @return [void]
65
- def self.reset!
66
- new.reset!
67
- end
68
61
  end
69
62
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewer
4
+ class Output
5
+ # Shared display vocabulary included by all domain formatters.
6
+ # Provides common constants and helper methods for formatting output.
7
+ module Formatting
8
+ CHECKMARK = "\u2713"
9
+ XMARK = "\u2717"
10
+
11
+ private
12
+
13
+ # Formats a duration in seconds for display
14
+ # @param seconds [Float, nil] the duration to format
15
+ # @return [String] formatted duration (e.g., "1.23s")
16
+ def format_duration(seconds)
17
+ "#{seconds.to_f.round(2)}s"
18
+ end
19
+
20
+ # Returns the appropriate status mark
21
+ # @param success [Boolean] whether the operation succeeded
22
+ # @return [String] checkmark or x mark
23
+ def status_mark(success) = success ? CHECKMARK : XMARK
24
+
25
+ # Returns the appropriate style key for a status
26
+ # @param success [Boolean] whether the operation succeeded
27
+ # @return [Symbol] :success or :failure
28
+ def status_style(success) = success ? :success : :failure
29
+
30
+ # Pluralizes a word based on count
31
+ # @param count [Integer] the count
32
+ # @param singular [String] the singular form
33
+ # @param plural [String] the plural form (defaults to singular + "s")
34
+ # @return [String] formatted count with word (e.g., "1 issue" or "3 issues")
35
+ def pluralize(count, singular, plural = "#{singular}s")
36
+ count == 1 ? "1 #{singular}" : "#{count} #{plural}"
37
+ end
38
+ end
39
+ end
40
+ end
@@ -4,37 +4,98 @@ require 'io/console' # For determining console width/height
4
4
 
5
5
  module Reviewer
6
6
  class Output
7
- # Wrapper to encapsulate some lower-level details of printing to $stdout
7
+ # ANSI terminal escape sequences for styled console output.
8
+ # Extracted from Printer so style definitions are separated from printing mechanics.
9
+ module AnsiStyles
10
+ ESC = "\e["
11
+ RESET = "#{ESC}0m".freeze
12
+
13
+ # Weight codes
14
+ WEIGHTS = { default: 0, bold: 1, light: 2, italic: 3 }.freeze
15
+
16
+ # Color codes
17
+ COLORS = {
18
+ black: 30, red: 31, green: 32, yellow: 33,
19
+ blue: 34, magenta: 35, cyan: 36, gray: 37, default: 39
20
+ }.freeze
21
+
22
+ # Style definitions: [weight, color]
23
+ STYLE_DEFS = {
24
+ success_bold: %i[bold green],
25
+ success: %i[default green],
26
+ success_light: %i[light green],
27
+ error: %i[bold red],
28
+ failure: %i[default red],
29
+ warning: %i[bold yellow],
30
+ warning_light: %i[light yellow],
31
+ source: %i[italic default],
32
+ bold: %i[default default],
33
+ default: %i[default default],
34
+ muted: %i[light gray]
35
+ }.freeze
36
+
37
+ # Pre-computed ANSI escape strings for each style
38
+ STYLES = STYLE_DEFS.transform_values do |weight_key, color_key|
39
+ "#{ESC}#{WEIGHTS.fetch(weight_key)};#{COLORS.fetch(color_key)}m"
40
+ end.freeze
41
+ end
42
+
43
+ # Wrapper to encapsulate some lower-level details of printing to $stdout.
44
+ # Handles ANSI styling via the pre-computed AnsiStyles::STYLES constant.
8
45
  class Printer
46
+ include AnsiStyles
47
+
9
48
  attr_reader :stream
10
49
 
11
- # Creates an instance of Output to print Reviewer activity and results to the console
50
+ # Creates a printer for styled console output
51
+ # @param stream [IO] the output stream to write to
52
+ #
53
+ # @return [Printer]
12
54
  def initialize(stream = $stdout)
13
- @stream = stream.tap do |str|
14
- # If the IO channel supports flushing the output immediately, then ensure it's enabled
15
- str.sync = str.respond_to?(:sync=)
16
- end
55
+ @stream = stream
56
+ @stream.sync = true if @stream.respond_to?(:sync=)
17
57
  end
18
58
 
59
+ # Prints styled content without a newline
60
+ # @param style [Symbol] the style key for color and weight
61
+ # @param content [String] the text to print
62
+ #
63
+ # @return [void]
19
64
  def print(style, content)
20
65
  text(style, content)
21
66
  end
22
67
 
68
+ # Prints styled content followed by a newline
69
+ # @param style [Symbol] the style key for color and weight
70
+ # @param content [String] the text to print
71
+ #
72
+ # @return [void]
23
73
  def puts(style, content)
24
74
  text(style, content)
25
75
  stream.puts
26
76
  end
27
77
 
28
- def tty?
29
- stream.tty?
78
+ # Writes content directly to the stream without styling.
79
+ # Skips if content is nil or blank.
80
+ #
81
+ # @param content [String, nil] the raw text to write
82
+ # @return [void]
83
+ def write_raw(content)
84
+ return if content.to_s.strip.empty?
85
+
86
+ stream << content
30
87
  end
88
+
89
+ # Whether the output stream is a TTY (interactive terminal)
90
+ # @return [Boolean] true if the stream supports ANSI styling
91
+ def tty? = stream.tty?
31
92
  alias style_enabled? tty?
32
93
 
33
94
  private
34
95
 
35
96
  def text(style, content)
36
97
  if style_enabled?
37
- stream.print Token.new(style, content).to_s
98
+ stream.print "#{STYLES.fetch(style)}#{content}#{RESET}"
38
99
  else
39
100
  stream.print content
40
101
  end