class String
Chronify methods for strings
Template coloring
String
to symbol conversion
Tag and search highlighting
Handling of search and regex strings
Handling of @tags in strings
String
helpers
String
truncation
URL linking and formatting
Public Instance Methods
Add @ prefix to string if needed, maintains +/- prefix
@return [String] @string
# File lib/doing/string/tags.rb, line 11 def add_at strip.sub(/^([+-]*)@?/, '\1@') end
Capitalize on the first character on string
@return Capitalized string
# File lib/doing/string/transform.rb, line 102 def cap_first sub(/^\w/) do |m| m.upcase end end
Converts input string into a Time
object when input takes on the following formats: - interval format e.g. '1d2h30m', '45m' etc. - a semantic phrase e.g. 'yesterday 5:30pm' - a strftime e.g. '2016-03-15 15:32:04 PDT'
@param options Additional options
@option options :future [Boolean] assume future date (default: false)
@option options :guess [Symbol] :begin or :end to assume beginning or end of arbitrary time range
@return [DateTime] result
# File lib/doing/chronify/string.rb, line 27 def chronify(**options) now = Time.now raise Errors::InvalidTimeExpression, "Invalid time expression #{inspect}" if to_s.strip == '' secs_ago = if match(/^(\d+)$/) # plain number, assume minutes Regexp.last_match(1).to_i * 60 elsif (m = match(/^(?:(?<day>\d+)d)? *(?:(?<hour>\d+)h)? *(?:(?<min>\d+)m)?$/i)) # day/hour/minute format e.g. 1d2h30m [[m['day'], 24 * 3600], [m['hour'], 3600], [m['min'], 60]].map { |qty, secs| qty ? (qty.to_i * secs) : 0 }.reduce(0, :+) end if secs_ago res = now - secs_ago Doing.logger.debug('Parser:', %(date/time string "#{self}" interpreted as #{res} (#{secs_ago} seconds ago))) else date_string = dup date_string = 'today' if date_string.match(Types::REGEX_DAY) && now.strftime('%a') =~ /^#{Regexp.last_match(1)}/i date_string = "#{options[:context].to_s} #{date_string}" if date_string =~ Types::REGEX_TIME && options[:context] res = Chronic.parse(date_string, { guess: options.fetch(:guess, :begin), context: options.fetch(:future, false) ? :future : :past, ambiguous_time_range: 8 }) Doing.logger.debug('Parser:', %(date/time string "#{self}" interpreted as #{res})) end res end
Converts simple strings into seconds that can be added to a Time
object
Input string can be HH:MM or XX[[XXhm]] (1d2h30m, 45m, 1.5d, 1h20m, etc.)
@return [Integer] seconds
# File lib/doing/chronify/string.rb, line 70 def chronify_qty minutes = 0 case self.strip when /^(\d+):(\d\d)$/ minutes += Regexp.last_match(1).to_i * 60 minutes += Regexp.last_match(2).to_i when /^(\d+(?:\.\d+)?)([hmd])?/ scan(/(\d+(?:\.\d+)?)([hmd])?/).each do |m| amt = m[0] type = m[1].nil? ? 'm' : m[1] minutes += case type.downcase when 'm' amt.to_i when 'h' (amt.to_f * 60).round when 'd' (amt.to_f * 60 * 24).round else 0 end end end minutes * 60 end
Clean up unlinked <urls>
# File lib/doing/string/url.rb, line 71 def clean_unlinked_urls gsub(/<(\w+:.*?)>/) do |match| m = Regexp.last_match if m[1] =~ /<a href/ match else %(<a href="#{m[1]}" title="Link to #{m[1]}">[link]</a>) end end end
Compress multiple spaces to single space
# File lib/doing/string/transform.rb, line 9 def compress gsub(/ +/, ' ').strip end
# File lib/doing/string/transform.rb, line 13 def compress! replace compress end
Tests if object is nil or empty
@return [Boolean] true if object is defined and has content
# File lib/doing/good.rb, line 24 def good? !strip.empty? end
# File lib/helpers/threaded_tests_string.rb, line 5 def highlight_errors cols = `tput cols`.strip.to_i string = dup errs = string.scan(/(?<==\n)(?:Failure|Error):.*?(?=\n=+)/m) errs.map! do |error| err = error.dup err.gsub!(%r{^(/.*?/)([^/:]+):(\d+):in (.*?)$}) do m = Regexp.last_match "#{m[1].white}#{m[2].bold.white}:#{m[3].yellow}:in #{m[4].cyan}" end err.gsub!(/(Failure|Error): (.*?)\((.*?)\):\n (.*?)(?=\n)/m) do m = Regexp.last_match [ m[1].bold.boldbgred.white, m[3].bold.boldbgcyan.white, m[2].bold.boldbgyellow.black, " #{m[4]} ".bold.boldbgwhite.black.reset ].join(':'.boldblack.boldbgblack.reset) end err.gsub!(/(<.*?>) (was expected to) (.*?)\n( *<.*?>)./m) do m = Regexp.last_match "#{m[1].bold.green} #{m[2].white} #{m[3].boldwhite.boldbgred.reset}\n#{m[4].bold.white}" end err.gsub!(/(Finished in) ([\d.]+) (seconds)/) do m = Regexp.last_match "#{m[1].green} #{m[2].bold.white} #{m[3].green}" end err.gsub!(/(\d+) (failures)/) do m = Regexp.last_match "#{m[1].bold.red} #{m[2].red}" end err.gsub!(/100% passed/) do |m| m.bold.green end err end errs.join("\n#{('=' * cols).blue}\n") end
# File lib/doing/string/highlight.rb, line 36 def highlight_search(search, distance: nil, negate: false, case_type: nil) out = dup matching = Doing.setting('search.matching', 'pattern').normalize_matching distance ||= Doing.setting('search.distance', 3).to_i case_type ||= Doing.setting('search.case', 'smart').normalize_case if search.rx? || matching == :fuzzy rx = search.to_rx(distance: distance, case_type: case_type) out.gsub!(rx) { |m| m.bgyellow.black } else query = search.strip.to_phrase_query if query[:must].nil? && query[:must_not].nil? query[:must] = query[:should] query[:should] = [] end qs = [] qs.concat(query[:must]) if query[:must] qs.concat(query[:should]) if query[:should] qs.each do |s| rx = Regexp.new(s.wildcard_to_rx, ignore_case(s, case_type)) out.gsub!(rx) { |m| m.bgyellow.black } end end out end
# File lib/doing/string/highlight.rb, line 32 def highlight_search!(search, distance: nil, negate: false, case_type: nil) replace highlight_search(search, distance: distance, negate: negate, case_type: case_type) end
Test if line should be ignored
@return [Boolean] line is empty or comment
# File lib/doing/string/query.rb, line 24 def ignore? line = self line =~ /^#/ || line =~ /^\s*$/ end
Determine whether case should be ignored for string
@param search The search string @param case_type The case type, :smart, :sensitive, :ignore
@return [Boolean] true if case should be ignored
# File lib/doing/string/query.rb, line 15 def ignore_case(search, case_type) (case_type == :smart && search !~ /[A-Z]/) || case_type == :ignore end
# File lib/doing/chronify/string.rb, line 164 def is_range? self =~ / (to|through|thru|(un)?til|-+) / end
Returns the last escape sequence from a string.
Actually returns all escape codes, with the assumption that the result of inserting them will generate the same color as was set at the end of the string. Because you can send modifiers like dark and bold separate from color codes, only using the last code may not render the same style.
@return [String] All escape codes in string
# File lib/doing/string/highlight.rb, line 74 def last_color scan(/\e\[[\d;]+m/).join('') end
Turn raw urls into HTML links
@param opt [Hash] Additional Options
@option opt [Symbol] :format can be :markdown or :html (default)
# File lib/doing/string/url.rb, line 16 def link_urls(**opt) fmt = opt.fetch(:format, :html) return self unless fmt str = dup str = str.remove_self_links if fmt == :markdown str.replace_qualified_urls(format: fmt).clean_unlinked_urls end
@see link_urls
# File lib/doing/string/url.rb, line 28 def link_urls!(**opt) fmt = opt.fetch(:format, :html) replace link_urls(format: fmt) end
# File lib/doing/completion/string.rb, line 6 def ltrunc(max) if length > max sub(/^.*?(.{#{max - 3}})$/, '...\1') else self end end
# File lib/doing/completion/string.rb, line 14 def ltrunc!(max) replace ltrunc(max) end
Convert an age string to a qualified type
@return [Symbol] :oldest or :newest
# File lib/doing/normalize.rb, line 34 def normalize_age(default = :newest) case self when /^o/i :oldest when /^n/i :newest else default end end
@see normalize_age
# File lib/doing/normalize.rb, line 46 def normalize_age!(default = :newest) replace normalize_age(default) end
Convert a boolean string to a symbol
@return Symbol
:and, :or, or :not
# File lib/doing/normalize.rb, line 98 def normalize_bool(default = :and) case self when /(and|all)/i :and when /(any|or)/i :or when /(not|none)/i :not when /^p/i :pattern else default.is_a?(Symbol) ? default : default.normalize_bool end end
@see normalize_bool
# File lib/doing/normalize.rb, line 114 def normalize_bool!(default = :and) replace normalize_bool(default) end
Convert a case sensitivity string to a symbol
@return Symbol
:smart, :sensitive, :ignore
# File lib/doing/normalize.rb, line 75 def normalize_case(default = :smart) case self when /^(c|sens)/i :sensitive when /^i/i :ignore when /^s/i :smart else default.is_a?(Symbol) ? default : default.normalize_case end end
@see normalize_case
# File lib/doing/normalize.rb, line 89 def normalize_case!(default = :smart) replace normalize_case(default) end
Normalize a color name, removing underscores, replacing “bright” with “bold”, and converting bgbold to boldbg
@return [String] Normalized color name
# File lib/doing/colors.rb, line 116 def normalize_color gsub(/_/, '').sub(/bright/i, 'bold').sub(/bgbold/, 'boldbg') end
Convert a matching configuration string to a symbol
@param default [Symbol] the default matching type to return if the string doesn't match a known symbol @return Symbol
:fuzzy, :pattern, :exact
# File lib/doing/normalize.rb, line 126 def normalize_matching(default = :pattern) case self when /^f/i :fuzzy when /^p/i :pattern when /^e/i :exact else default.is_a?(Symbol) ? default : default.normalize_matching end end
@see normalize_matching
# File lib/doing/normalize.rb, line 140 def normalize_matching!(default = :pattern) replace normalize_bool(default) end
# File lib/doing/normalize.rb, line 59 def normalize_order(default = :asc) case self when /^a/i :asc when /^d/i :desc else default end end
Convert a sort order string to a qualified type
@return [Symbol] :asc or :desc
# File lib/doing/normalize.rb, line 55 def normalize_order!(default = :asc) replace normalize_order(default) end
Convert tag sort string to a qualified type
@return [Symbol] :name or :time
# File lib/doing/normalize.rb, line 13 def normalize_tag_sort(default = :name) case self when /^n/i :name when /^t/i :time else default end end
@see normalize_tag_sort
# File lib/doing/normalize.rb, line 25 def normalize_tag_sort!(default = :name) replace normalize_tag_sort(default) end
Adds ?: to any parentheticals in a regular expression to avoid match groups
@return [String] modified regular expression
# File lib/doing/normalize.rb, line 150 def normalize_trigger gsub(/\((?!\?:)/, '(?:').downcase end
@see normalize_trigger
# File lib/doing/normalize.rb, line 155 def normalize_trigger! replace normalize_trigger end
Removes @ prefix if needed, maintains +/- prefix
@return [String] string without @ prefix
# File lib/doing/string/tags.rb, line 20 def remove_at strip.sub(/^([+-]*)@?/, '\1') end
Remove <self-linked> formatting
# File lib/doing/string/url.rb, line 34 def remove_self_links gsub(/<(.*?)>/) do |match| m = Regexp.last_match if m[1] =~ /^https?:/ m[1] else match end end end
Replace qualified urls
# File lib/doing/string/url.rb, line 46 def replace_qualified_urls(**options) fmt = options.fetch(:format, :html) gsub(%r{(?mi)(?x: (?<!["'\[(\\]) (?<protocol>(?:http|https)://) (?<domain>[\w\-]+(?:\.[\w\-]+)+) (?<path>[\w\-.,@?^=%&;:/~+#]*[\w\-@^=%&;/~+#])? )}) do |_match| m = Regexp.last_match url = "#{m['domain']}#{m['path']}" proto = m['protocol'].nil? ? 'http://' : m['protocol'] case fmt when :terminal TTY::Link.link_to("#{proto}#{url}", "#{proto}#{url}") when :html %(<a href="#{proto}#{url}" title="Link to #{m['domain']}">[#{url}]</a>) when :markdown "[#{url}](#{proto}#{url})" else m[0] end end end
Determines if receiver is surrounded by slashes or starts with single quote
@return [Boolean] True if regex, False otherwise.
# File lib/doing/string/query.rb, line 34 def rx? self =~ %r{(^/.*?/$|^')} end
# File lib/doing/completion/zsh_completion.rb, line 6 def sanitize gsub(/'/, '\\\'').gsub(/\[/, '(').gsub(/\]/, ')') end
Convert a string value to an appropriate type. If kind is not specified, '[one, two]' becomes an Array
, '1' becomes Integer, '1.5' becomes Float, 'true' or 'yes' becomes TrueClass
, 'false' or 'no' becomes FalseClass
.
@param kind [String] specify string, array, integer, float, symbol, or boolean (falls back to string if value is not recognized) @return Converted object type
# File lib/doing/string/transform.rb, line 130 def set_type(kind = nil) if kind case kind.to_s when /^a/i gsub(/^\[ *| *\]$/, '').split(/ *, */) when /^i/i to_i when /^(fa|tr)/i to_bool when /^f/i to_f when /^sy/i sub(/^:/, '').to_sym when /^b/i self =~ /^(true|yes)$/ ? true : false else to_s end else case self when /(^\[.*?\]$| *, *)/ gsub(/^\[ *| *\]$/, '').split(/ *, */) when /^[0-9]+$/ to_i when /^[0-9]+\.[0-9]+$/ to_f when /^:\w+/ sub(/^:/, '').to_sym when /^(true|yes)$/i true when /^(false|no)$/i false else to_s end end end
# File lib/doing/completion/string.rb, line 2 def short_desc split(/[,.]/)[0].sub(/ \(.*?\)?$/, '').strip end
# File lib/doing/string/transform.rb, line 17 def simple_wrap(width) str = gsub(/@\S+\(.*?\)/) { |tag| tag.gsub(/\s/, '%%%%') } words = str.split(/ /).map { |word| word.gsub(/%%%%/, ' ') } out = [] line = [] words.each do |word| if word.uncolor.length >= width chars = word.uncolor.split('') out << chars.slice!(0, width - 1).join('') while chars.count >= width line << chars.join('') next elsif line.join(' ').uncolor.length + word.uncolor.length + 1 > width out.push(line.join(' ')) line.clear end line << word.uncolor end out.push(line.join(' ')) out.join("\n") end
Splits a range string and returns an array of DateTime objects as [start, end]. If only one date is given, end time is nil.
@return [Array<DateTime>] Start and end dates as array @example Process a natural language date range “mon 3pm to mon 5pm”.split_date_range
# File lib/doing/chronify/string.rb, line 178 def split_date_range time_rx = /^(\d{1,2}(:\d{1,2})?( *(am|pm))?|midnight|noon)$/ range_rx = / (to|through|thru|(?:un)?til|-+) / date_string = dup if date_string.is_range? # Do we want to differentiate between "to" and "through"? # inclusive = date_string =~ / (through|thru|-+) / ? true : false inclusive = true dates = date_string.split(range_rx) if dates[0].strip =~ time_rx && dates[-1].strip =~ time_rx start = dates[0].strip finish = dates[-1].strip else start = dates[0].chronify(guess: :begin, future: false) finish = dates[-1].chronify(guess: inclusive ? :end : :begin, future: true) end raise Errors::InvalidTimeExpression, "Unrecognized date string (#{dates[0]})" if start.nil? raise Errors::InvalidTimeExpression, "Unrecognized date string (#{dates[-1]})" if finish.nil? else if date_string.strip =~ time_rx start = date_string.strip finish = '11:59pm' else start = date_string.strip.chronify(guess: :begin, future: false) finish = date_string.strip.chronify(guess: :end) end raise Errors::InvalidTimeExpression, 'Unrecognized date string' unless start end if start.is_a? String Doing.logger.debug('Parser:', "--from string interpreted as time span, from #{start || '12am'} to #{finish || '11:59pm'}") else Doing.logger.debug('Parser:', "date range interpreted as #{start.strftime('%F %R')} -- #{finish ? finish.strftime('%F %R') : 'now'}") end [start, finish] end
Add, rename, or remove a tag
@param tag The tag @param value [String] Value for tag (@tag(value)) @param remove [Boolean] Remove the tag instead of adding @param rename_to [String] Replace tag with this tag @param regex [Boolean] Tag is regular expression @param single [Boolean] Operating on a single item (for logging) @param force [Boolean] With rename_to, add tag if it doesn't exist
@return [String] The string with modified tags
# File lib/doing/string/tags.rb, line 82 def tag(tag, value: nil, remove: false, rename_to: nil, regex: false, single: false, force: false) log_level = single ? :info : :debug title = dup title.chomp! tag = tag.sub(/^@?/, '') case_sensitive = tag !~ /[A-Z]/ rx_tag = if regex tag.gsub(/\./, '\S') else tag.gsub(/\?/, '.').gsub(/\*/, '\S*?') end if remove || rename_to rx = Regexp.new("(?<=^| )@#{rx_tag}(?<parens>\\((?<value>[^)]*)\\))?(?= |$)", case_sensitive) m = title.match(rx) if m.nil? && rename_to && force title.tag!(rename_to, value: value, single: single) elsif m title.gsub!(rx) do rename_to ? "@#{rename_to}#{value.nil? ? m['parens'] : "(#{value})"}" : '' end title.dedup_tags! title.chomp! if rename_to f = "@#{tag}".cyan t = "@#{rename_to}".cyan Doing.logger.write(log_level, 'Tag:', %(renamed #{f} to #{t} in "#{title}")) else f = "@#{tag}".cyan Doing.logger.write(log_level, 'Tag:', %(removed #{f} from "#{title}")) end else Doing.logger.debug('Skipped:', "not tagged #{"@#{tag}".cyan}") end elsif title =~ /@#{tag}(?=[ (]|$)/ && !value.good? Doing.logger.debug('Skipped:', "already tagged #{"@#{tag}".cyan}") return title else add = tag add += "(#{value})" unless value.nil? title.chomp! if value && title =~ /@#{tag}(?=[ (]|$)/ title.sub!(/@#{tag}(\(.*?\))?/, "@#{add}") else title += " @#{add}" end title.dedup_tags! title.chomp! Doing.logger.write(log_level, 'Tag:', %(added #{('@' + tag).cyan} to "#{title}")) end title.gsub(/ +/, ' ') end
Add, rename, or remove a tag in place
@see tag
# File lib/doing/string/tags.rb, line 65 def tag!(tag, **options) replace tag(tag, **options) end
Convert DD:HH:MM to a natural language string
@param format [Symbol] The format to output (:dhm, :hm, :m, :clock, :natural)
# File lib/doing/chronify/string.rb, line 117 def time_string(format: :dhm) to_seconds.time_string(format: format) end
Returns a bool representation of the string.
@return [Boolean] Bool representation of the object.
# File lib/doing/string/query.rb, line 120 def to_bool case self when /^[yt1]/i true else false end end
Pluralize a string based on quantity
@param number [Integer] the quantity of the object the string represents
# File lib/doing/string/transform.rb, line 114 def to_p(number) number == 1 ? self : "#{self}s" end
# File lib/doing/string/query.rb, line 88 def to_phrase_query parser = PhraseParser::QueryParser.new transformer = PhraseParser::QueryTransformer.new parse_tree = parser.parse(self) transformer.apply(parse_tree).to_elasticsearch end
# File lib/doing/string/query.rb, line 95 def to_query parser = BooleanTermParser::QueryParser.new transformer = BooleanTermParser::QueryTransformer.new parse_tree = parser.parse(self) transformer.apply(parse_tree).to_elasticsearch end
Convert string to fuzzy regex. Characters in words can be separated by up to distance characters in haystack, spaces indicate unlimited distance.
@example “this word”.to_rx(3) # => /t.{0,3}h.{0,3}i.{0,3}s.{0,3}.*?w.{0,3}o.{0,3}r.{0,3}d/
@param distance [Integer] Allowed distance between characters @param case_type The case type
@return [Regexp] Regex pattern
# File lib/doing/string/query.rb, line 63 def to_rx(distance: nil, case_type: nil) distance ||= Doing.config.fetch('search', 'distance', 3).to_i case_type ||= Doing.config.fetch('search', 'case', 'smart')&.normalize_case case_sensitive = case case_type when :smart self =~ /[A-Z]/ ? true : false when :sensitive true else false end pattern = case dup.strip when %r{^/.*?/$} sub(%r{/(.*?)/}, '\1') when /^'/ sub(/^'(.*?)'?$/, '\1') else split(/ +/).map do |w| w.split('').join(".{0,#{distance}}").gsub(/\+/, '\+').wildcard_to_rx end.join('.*?') end Regexp.new(pattern, !case_sensitive) end
Convert DD:HH:MM to seconds
@return [Integer] rounded number of seconds
# File lib/doing/chronify/string.rb, line 101 def to_seconds mtch = match(/(\d+):(\d+):(\d+)/) raise Errors::DoingRuntimeError, "Invalid time string: #{self}" unless mtch h = mtch[1] m = mtch[2] s = mtch[3] (h.to_i * 60 * 60) + (m.to_i * 60) + s.to_i end
Truncate to nearest word
@param len The length
# File lib/doing/string/truncate.rb, line 13 def trunc(len, ellipsis: '...') return self if length <= len total = 0 res = [] split(/ /).each do |word| break if total + 1 + word.length > len total += 1 + word.length res.push(word) end res.join(' ') + ellipsis end
# File lib/doing/string/truncate.rb, line 28 def trunc!(len, ellipsis: '...') replace trunc(len, ellipsis: ellipsis) end
Truncate from middle to end at nearest word
@param len The length
# File lib/doing/string/truncate.rb, line 37 def truncend(len, ellipsis: '...') return self if length <= len total = 0 res = [] split(/ /).reverse.each do |word| break if total + 1 + word.length > len total += 1 + word.length res.unshift(word) end ellipsis + res.join(' ') end
# File lib/doing/string/truncate.rb, line 52 def truncend!(len, ellipsis: '...') replace truncend(len, ellipsis: ellipsis) end
Truncate string in the middle, separating at nearest word
@param len The length @param ellipsis The ellipsis
# File lib/doing/string/truncate.rb, line 62 def truncmiddle(len, ellipsis: '...') return self if length <= len len -= (ellipsis.length / 2).to_i half = (len / 2).to_i start = trunc(half, ellipsis: ellipsis) finish = truncend(half, ellipsis: '') start + finish end
# File lib/doing/string/truncate.rb, line 71 def truncmiddle!(len, ellipsis: '...') replace truncmiddle(len, ellipsis: ellipsis) end
Test string for truthiness (0, “f”, “false”, “n”, “no” all return false, case insensitive, otherwise true)
@return [Boolean] String
is truthy
# File lib/doing/string/query.rb, line 107 def truthy? if self =~ /^(0|f(alse)?|n(o)?)$/i false else true end end
Remove color escape codes
@return clean string
# File lib/doing/string/highlight.rb, line 83 def uncolor gsub(/\e\[[\d;]+m/, '') end
@see uncolor
# File lib/doing/string/highlight.rb, line 90 def uncolor! replace uncolor end
# File lib/doing/string/string.rb, line 6 def utf8 if String.method_defined? :force_encoding dup.force_encoding('utf-8') else self end end
Extract the longest valid %color name from a string.
Allows %colors to bleed into other text and still be recognized, e.g. %greensomething still finds %green.
@return [String] a valid color name
# File lib/doing/colors.rb, line 98 def validate_color valid_color = nil compiled = '' normalize_color.split('').each do |char| compiled += char valid_color = compiled if Color.attributes.include?(compiled.to_sym) end valid_color end
Convert ? and * wildcards to regular expressions. Uses S (non-whitespace) instead of . (any character)
@return [String] Regular expression string
# File lib/doing/string/query.rb, line 44 def wildcard_to_rx gsub(/\?/, '\S').gsub(/\*/, '\S*?').gsub(/\]\]/, '--') end
Wrap string at word breaks, respecting tags
@param len [Integer] The length @param offset [Integer] (Optional) The width to pad each subsequent line @param prefix [String] (Optional) A prefix to add to each line
# File lib/doing/string/transform.rb, line 47 def wrap(len, pad: 0, indent: ' ', offset: 0, prefix: '', color: '', after: '', reset: '', pad_first: false) last_color = color.empty? ? '' : after.last_color note_rx = /(?mi)(?<!\\)%(?<width>-?\d+)?(?:\^(?<mchar>.))?(?:(?<ichar>[ _t]|[^a-z0-9])(?<icount>\d+))?(?<prefix>.[ _t]?)?note/ note = '' after = after.dup if after.frozen? after.sub!(note_rx) do note = Regexp.last_match(0) '' end left_pad = ' ' * offset left_pad += indent # return "#{left_pad}#{prefix}#{color}#{self}#{last_color} #{note}" unless len.positive? # Don't break inside of tag values str = gsub(/@\S+\(.*?\)/) { |tag| tag.gsub(/\s/, '%%%%') }.gsub(/\n/, ' ') words = str.split(/ /).map { |word| word.gsub(/%%%%/, ' ') } out = [] line = [] words.each do |word| if word.uncolor.length >= len chars = word.uncolor.split('') out << chars.slice!(0, len - 1).join('') while chars.count >= len line << chars.join('') next elsif line.join(' ').uncolor.length + word.uncolor.length + 1 > len out.push(line.join(' ')) line.clear end line << word.uncolor end out.push(line.join(' ')) last_color = '' out[0] = format("%-#{pad}s%s%s", out[0], last_color, after) out.map.with_index { |l, idx| if !pad_first && idx == 0 "#{color}#{prefix}#{l}#{last_color}" else "#{left_pad}#{color}#{prefix}#{l}#{last_color}" end }.join("\n") + " #{note}".chomp # res.join("\n").strip + last_color + " #{note}".chomp end