# typed: true # frozen_string_literal: true require('cli/ui') require('strscan') module CLI module UI class Formatter extend T::Sig # Available mappings of formattings # To use any of them, you can use {{:}} # There are presentational (colours and formatters) # and semantic (error, info, command) formatters available # SGR_MAP = { # presentational 'red' => '31', 'green' => '32', 'yellow' => '33', # default blue is low-contrast against black in some default terminal color scheme 'blue' => '94', # 9x = high-intensity fg color x 'magenta' => '35', 'cyan' => '36', 'gray' => '38;5;244', 'white' => '97', 'bold' => '1', 'italic' => '3', 'underline' => '4', 'reset' => '0', # semantic 'error' => '31', # red 'success' => '32', # success 'warning' => '33', # yellow 'info' => '94', # bright blue 'command' => '36', # cyan }.freeze BEGIN_EXPR = '{{' END_EXPR = '}}' SCAN_WIDGET = %r[@widget/(?\w+):(?.*?)}}] SCAN_FUNCNAME = /\w+:/ SCAN_GLYPH = /.}}/ SCAN_BODY = %r{ .*? ( #{BEGIN_EXPR} | #{END_EXPR} | \z ) }mx DISCARD_BRACES = 0..-3 LITERAL_BRACES = Class.new Stack = T.type_alias { T::Array[T.any(String, LITERAL_BRACES)] } class FormatError < StandardError extend T::Sig sig { returns(String) } attr_accessor :input sig { returns(Integer) } attr_accessor :index sig { params(message: String, input: String, index: Integer).void } def initialize(message, input, index) super(message) @input = input @index = index end end # Initialize a formatter with text. # # ===== Attributes # # * +text+ - the text to format # sig { params(text: String).void } def initialize(text) @text = text @nodes = T.let([], T::Array[[String, Stack]]) end # Format the text using a map. # # ===== Attributes # # * +sgr_map+ - the mapping of the formattings. Defaults to +SGR_MAP+ # # ===== Options # # * +:enable_color+ - enable color output? Default is true unless output is redirected # sig { params(sgr_map: T::Hash[String, String], enable_color: T::Boolean).returns(String) } def format(sgr_map = SGR_MAP, enable_color: CLI::UI.enable_color?) @nodes.replace([]) stack = parse_body(StringScanner.new(@text)) prev_fmt = T.let(nil, T.nilable(Stack)) content = @nodes.each_with_object(+'') do |(text, fmt), str| if prev_fmt != fmt && enable_color text = apply_format(text, fmt, sgr_map) end str << text prev_fmt = fmt end stack.reject! { |e| e.is_a?(LITERAL_BRACES) } return content unless enable_color return content if stack == prev_fmt unless stack.empty? && (@nodes.size.zero? || T.must(@nodes.last)[1].empty?) content << apply_format('', stack, sgr_map) end content end private sig { params(text: String, fmt: Stack, sgr_map: T::Hash[String, String]).returns(String) } def apply_format(text, fmt, sgr_map) sgr = fmt.each_with_object(+'0') do |name, str| next if name.is_a?(LITERAL_BRACES) begin str << ';' << sgr_map.fetch(name) rescue KeyError raise FormatError.new( "invalid format specifier: #{name}", @text, -1 ) end end CLI::UI::ANSI.sgr(sgr) + text end sig { params(sc: StringScanner, stack: Stack).returns(Stack) } def parse_expr(sc, stack) if (match = sc.scan(SCAN_GLYPH)) glyph_handle = T.must(match[0]) begin glyph = Glyph.lookup(glyph_handle) emit(glyph.char, [glyph.color.name.to_s]) rescue Glyph::InvalidGlyphHandle index = sc.pos - 2 # rewind past '}}' raise FormatError.new( "invalid glyph handle at index #{index}: '#{glyph_handle}'", @text, index ) end elsif (match = sc.scan(SCAN_WIDGET)) match_data = T.must(SCAN_WIDGET.match(match)) # Regexp.last_match doesn't work here widget_handle = T.must(match_data['handle']) begin widget = Widgets.lookup(widget_handle) emit(widget.call(T.must(match_data['args'])), stack) rescue Widgets::InvalidWidgetHandle index = sc.pos - 2 # rewind past '}}' raise(FormatError.new( "invalid widget handle at index #{index}: '#{widget_handle}'", @text, index, )) end elsif (match = sc.scan(SCAN_FUNCNAME)) funcname = match.chop stack.push(funcname) else # We read a {{ but it's not apparently Formatter syntax. # We could error, but it's nicer to just pass through as text. # We do kind of assume that the text will probably have balanced # pairs of {{ }} at least. emit('{{', stack) stack.push(LITERAL_BRACES.new) end parse_body(sc, stack) stack end sig { params(sc: StringScanner, stack: Stack).returns(Stack) } def parse_body(sc, stack = []) match = sc.scan(SCAN_BODY) if match&.end_with?(BEGIN_EXPR) emit(T.must(match[DISCARD_BRACES]), stack) parse_expr(sc, stack) elsif match&.end_with?(END_EXPR) emit(T.must(match[DISCARD_BRACES]), stack) if stack.pop.is_a?(LITERAL_BRACES) emit('}}', stack) end parse_body(sc, stack) elsif match emit(match, stack) else emit(sc.rest, stack) end stack end sig { params(text: String, stack: Stack).void } def emit(text, stack) return if text.empty? @nodes << [text, stack.reject { |n| n.is_a?(LITERAL_BRACES) }] end end end end