loading
Generated 2025-10-09T00:03:41+00:00

All Files ( 100.0% covered at 12.05 hits/line )

7 files in total.
119 relevant lines, 119 lines covered and 0 lines missed. ( 100.0% )
15 total branches, 15 branches covered and 0 branches missed. ( 100.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
lib/tone.rb 100.00 % 22 12 12 0 1.25 100.00 % 0 0 0
lib/tone/aliaser.rb 100.00 % 60 31 31 0 19.84 100.00 % 6 6 0
lib/tone/client.rb 100.00 % 43 22 22 0 4.00 100.00 % 0 0 0
lib/tone/configuration/loader.rb 100.00 % 21 10 10 0 1.20 100.00 % 0 0 0
lib/tone/decoder.rb 100.00 % 51 24 24 0 12.13 100.00 % 5 5 0
lib/tone/encoder.rb 100.00 % 37 18 18 0 22.83 100.00 % 4 4 0
lib/tone/error.rb 100.00 % 7 2 2 0 1.00 100.00 % 0 0 0

lib/tone.rb

100.0% lines covered

100.0% branches covered

12 relevant lines. 12 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "zeitwerk"
  3. 1 Zeitwerk::Loader.new.then do |loader|
  4. 1 loader.ignore "#{__dir__}/tone/rspec"
  5. 1 loader.tag = File.basename __FILE__, ".rb"
  6. 1 loader.push_dir __dir__
  7. 1 loader.setup
  8. end
  9. # Main namespace.
  10. 1 module Tone
  11. 1 DEFAULTS = Configuration::Loader.new.call
  12. 1 CONTAINER = {defaults: DEFAULTS, aliaser: Aliaser, encoder: Encoder, decoder: Decoder}.freeze
  13. 1 def self.loader registry = Zeitwerk::Registry
  14. 4 @loader ||= registry.loaders.each.find { |loader| loader.tag == File.basename(__FILE__, ".rb") }
  15. end
  16. 1 def self.new(...) = Client.new(...)
  17. end

lib/tone/aliaser.rb

100.0% lines covered

100.0% branches covered

31 relevant lines. 31 lines covered and 0 lines missed.
6 total branches, 6 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "refinements/array"
  3. 1 module Tone
  4. # Allows storage of custom custom which can be referenced when colorizing text.
  5. 1 class Aliaser
  6. 1 using Refinements::Array
  7. 1 attr_reader :defaults
  8. 1 def initialize defaults: DEFAULTS
  9. 90 @defaults = defaults
  10. 90 @custom = {}
  11. end
  12. 1 def get key
  13. 60 symbol = String(key).to_sym
  14. 60 custom.fetch symbol do
  15. 55 then: 51 else: 4 return key if defaults.key? symbol
  16. 4 usage = defaults.keys.append(*custom.keys).to_usage "and/or"
  17. 4 fail Error, "Invalid alias or default: #{key.inspect}. Use: #{usage}."
  18. end
  19. end
  20. 1 def add(key, *styles)
  21. 25 then: 2 else: 23 fail Error, "Alias must have styles: #{key.inspect}." if styles.tap(&:compact!).empty?
  22. 23 custom[key.to_sym] = validate key, styles.map(&:to_sym)
  23. 21 self
  24. end
  25. 1 def to_h = custom.dup
  26. 1 private
  27. 1 attr_reader :custom
  28. 1 def validate key, styles
  29. 23 check_duplicate key
  30. 61 styles.each { |style| check_style key, style }
  31. 21 styles
  32. end
  33. 1 def check_duplicate key
  34. 23 then: 1 else: 22 fail Error, "Alias mustn't duplicate (override) default: #{key.inspect}." if defaults.key? key
  35. end
  36. 1 def check_style key, style
  37. 39 defaults.fetch style do
  38. 1 usage = defaults.keys.to_usage "and/or"
  39. 1 fail Error, "Invalid style (#{style.inspect}) for key (#{key.inspect}). Use: #{usage}."
  40. end
  41. end
  42. end
  43. end

lib/tone/client.rb

100.0% lines covered

100.0% branches covered

22 relevant lines. 22 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Tone
  3. # The primary interface for working with colorized text.
  4. 1 class Client
  5. 1 def initialize enabled: $stdout.tty?, container: Tone::CONTAINER
  6. 21 @aliaser = container.fetch(:aliaser).new defaults: container.fetch(:defaults)
  7. 21 @encoder = container.fetch(:encoder).new(aliaser:, enabled:)
  8. 21 @decoder = container.fetch(:decoder).new defaults: aliaser.defaults
  9. end
  10. 1 def defaults = aliaser.defaults
  11. 1 def aliases = aliaser.to_h
  12. 1 def add_alias(...)
  13. 4 aliaser.add(...)
  14. 4 self
  15. end
  16. 1 def get_alias(...) = aliaser.get(...)
  17. 1 def encode(...) = encoder.call(...)
  18. 1 alias [] encode
  19. 1 def decode(...) = decoder.call(...)
  20. 1 def find_code(key) = defaults[key]
  21. 1 def find_codes(*keys) = defaults.values_at(*keys)
  22. 1 def find_symbol(code) = defaults.invert[code]
  23. 1 def find_symbols(*codes) = defaults.invert.values_at(*codes)
  24. 1 def inspect = %(#<#{self.class} @encoder=#{encoder} @decoder=#{decoder} @aliaser=#{aliaser})
  25. 1 private
  26. 1 attr_reader :aliaser, :encoder, :decoder
  27. end
  28. end

lib/tone/configuration/loader.rb

100.0% lines covered

100.0% branches covered

10 relevant lines. 10 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "pathname"
  3. 1 require "yaml"
  4. 1 module Tone
  5. 1 module Configuration
  6. # Loads the default configuration into memory as a frozen hash.
  7. 1 class Loader
  8. 1 def initialize path = Pathname("#{__dir__}/defaults.yml")
  9. 3 @path = path
  10. end
  11. 1 def call = YAML.safe_load_file path, symbolize_names: true, freeze: true
  12. 1 private
  13. 1 attr_reader :path
  14. end
  15. end
  16. end

lib/tone/decoder.rb

100.0% lines covered

100.0% branches covered

24 relevant lines. 24 lines covered and 0 lines missed.
5 total branches, 5 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "strscan"
  3. 1 module Tone
  4. # Decodes color encoded text into metadata (hash).
  5. 1 class Decoder
  6. 1 PATTERN = /
  7. \e\[ # Start.
  8. (?<codes>[\d;]+) # Style codes.
  9. m # Start suffix.
  10. (?<text>.+?) # Lazy text.
  11. \e\[0m # Stop.
  12. /mx
  13. 1 def initialize pattern: PATTERN, defaults: DEFAULTS, client: StringScanner
  14. 33 @pattern = pattern
  15. 33 @defaults = defaults
  16. 33 @client = client
  17. 33 @prefix_pattern = /.*#{pattern}/m
  18. end
  19. 1 def call(text) = scan client.new(text.to_s)
  20. 1 private
  21. 1 attr_reader :pattern, :defaults, :client, :prefix_pattern
  22. 1 def scan scanner, collection: []
  23. 13 body: 20 until scanner.eos?
  24. 20 match = scanner.scan_until pattern
  25. 20 else: 17 then: 3 return collection.append [scanner.string[scanner.rest]] unless match
  26. 17 normal = scanner.pre_match.sub prefix_pattern, ""
  27. 17 else: 8 then: 9 collection << [normal] unless normal.empty?
  28. 17 collection << extract_captures(scanner)
  29. end
  30. 10 collection
  31. end
  32. 1 def extract_captures scanner
  33. 17 codes, text = scanner.captures
  34. 17 [text, *symbolize(codes)]
  35. end
  36. 1 def symbolize(codes) = defaults.invert.values_at(*String(codes).split(";").map(&:to_i))
  37. end
  38. end

lib/tone/encoder.rb

100.0% lines covered

100.0% branches covered

18 relevant lines. 18 lines covered and 0 lines missed.
4 total branches, 4 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "refinements/array"
  3. 1 module Tone
  4. # Encodes plain text as colorized text.
  5. 1 class Encoder
  6. 1 using Refinements::Array
  7. 1 def initialize aliaser: Aliaser.new, enabled: $stdout.tty?
  8. 71 @aliaser = aliaser
  9. 71 @enabled = enabled
  10. end
  11. 1 def call(text, *styles)
  12. 53 then: 2 else: 51 return "" if String(text).empty?
  13. 51 then: 3 else: 48 !enabled || styles.tap(&:compact!).empty? ? text : "#{start(*styles)}#{text}#{stop}"
  14. end
  15. 1 private
  16. 1 attr_reader :aliaser, :enabled
  17. 1 def start(*styles) = %(\e[#{escape(*styles)}m)
  18. 1 def stop = "\e[#{defaults.fetch :clear}m"
  19. 1 def escape(*styles)
  20. 100 styles.reduce([]) { |expansion, key| expansion.append(*aliaser.get(key)) }
  21. 53 .map { |key| defaults[key.to_sym] }
  22. .join ";"
  23. end
  24. 1 def defaults = aliaser.defaults
  25. end
  26. end

lib/tone/error.rb

100.0% lines covered

100.0% branches covered

2 relevant lines. 2 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Tone
  3. # Root error class for gem.
  4. 1 class Error < StandardError
  5. end
  6. end