diff --git a/lib/cli/ui/truncater.rb b/lib/cli/ui/truncater.rb index f5c2332e..7c799c6f 100644 --- a/lib/cli/ui/truncater.rb +++ b/lib/cli/ui/truncater.rb @@ -5,15 +5,20 @@ module CLI module UI # Truncater truncates a string to a provided printable width. module Truncater - PARSE_ROOT = :root - PARSE_ANSI = :ansi - PARSE_ESC = :esc - PARSE_ZWJ = :zwj + PARSE_ROOT = :root + PARSE_ANSI = :ansi + PARSE_ESC = :esc + PARSE_ZWJ = :zwj + PARSE_OSC = :osc + PARSE_OSC_END = :osc_end - ESC = 0x1b - LEFT_SQUARE_BRACKET = 0x5b - ZWJ = 0x200d # emojipedia.org/emoji-zwj-sequences - SEMICOLON = 0x3b + ESC = 0x1b + LEFT_SQUARE_BRACKET = 0x5b + RIGHT_SQUARE_BRACKET = 0x5d # ] + BACKSLASH = 0x5c # \ + BEL = 0x07 + ZWJ = 0x200d # emojipedia.org/emoji-zwj-sequences + SEMICOLON = 0x3b # EMOJI_RANGE in particular is super inaccurate. This is best-effort. # If you need this to be more accurate, we'll almost certainly accept a @@ -24,15 +29,21 @@ module Truncater UC_ALPHA_RANGE = 0x60..0x71 TRUNCATED = "\x1b[0m…" + # OSC 8 close (empty URI). Both BEL and ST terminate OSC; we emit ST here + # to match CLI::UI.link / ANSI hyperlink endings. + HYPERLINK_END = "\x1b]8;;\x1b\\" class << self #: (String text, Integer printing_width) -> String def call(text, printing_width) return text if text.size <= printing_width - width = 0 - mode = PARSE_ROOT - truncation_index = nil #: Integer? + width = 0 + mode = PARSE_ROOT + truncation_index = nil #: Integer? + open_hyperlink = false + open_hyperlink_at_cut = false + osc_payload_start = nil #: Integer? codepoints = text.codepoints codepoints.each.with_index do |cp, index| @@ -46,7 +57,10 @@ def call(text, printing_width) else width += width(cp) if width >= printing_width - truncation_index ||= index + unless truncation_index + truncation_index = index + open_hyperlink_at_cut = open_hyperlink + end # it looks like we could break here but we still want the # width calculation for the rest of the characters. end @@ -55,6 +69,9 @@ def call(text, printing_width) mode = case cp when LEFT_SQUARE_BRACKET PARSE_ANSI + when RIGHT_SQUARE_BRACKET + osc_payload_start = index + 1 + PARSE_OSC else PARSE_ROOT end @@ -69,6 +86,28 @@ def call(text, printing_width) # unexpected. let's just go back to the root state I guess? mode = PARSE_ROOT end + when PARSE_OSC + # BEL and ST (ESC \) both terminate OSC; see ANSI::OSC_SEQUENCE. + case cp + when BEL + state = osc8_link_state(codepoints, osc_payload_start, index) + open_hyperlink = state unless state.nil? + osc_payload_start = nil + mode = PARSE_ROOT + when ESC + mode = PARSE_OSC_END + end + when PARSE_OSC_END + if cp == BACKSLASH + # ST is ESC \; payload ends before the ESC. + state = osc8_link_state(codepoints, osc_payload_start, index - 1) + open_hyperlink = state unless state.nil? + osc_payload_start = nil + mode = PARSE_ROOT + else + # Not a String Terminator — keep consuming as OSC payload. + mode = PARSE_OSC + end when PARSE_ZWJ # consume any character and consider it as having no width # width(x+ZWJ+y) = width(x). @@ -84,11 +123,26 @@ def call(text, printing_width) return text if !truncation_index || width <= printing_width slice = codepoints[0...truncation_index] #: as !nil - slice.pack('U*') + TRUNCATED + truncated = slice.pack('U*') + truncated += HYPERLINK_END if open_hyperlink_at_cut + truncated + TRUNCATED end private + #: (Array[Integer] codepoints, Integer? start, Integer end_exclusive) -> bool? + def osc8_link_state(codepoints, start, end_exclusive) + return nil if start.nil? || end_exclusive <= start + + payload = codepoints[start...end_exclusive].pack('U*') + return nil unless payload.start_with?('8;') + + # OSC 8: 8;params;URI — nonempty URI opens a link; empty closes it. + # Non-OSC8 sequences return nil so Truncater does not clear open-link state. + _params, uri = payload.delete_prefix('8;').split(';', 2) + !uri.to_s.empty? + end + #: (Integer printable_codepoint) -> Integer def width(printable_codepoint) case printable_codepoint diff --git a/test/cli/ui/truncater_test.rb b/test/cli/ui/truncater_test.rb index b399face..75c0b438 100644 --- a/test/cli/ui/truncater_test.rb +++ b/test/cli/ui/truncater_test.rb @@ -22,6 +22,41 @@ def test_truncate assert_example(3, 'AB' + MAN_COOKING, 'AB' + Truncater::TRUNCATED) end + def test_truncate_formatted_hyperlink_issue_614 + url = 'https://github.com/Shopify/shopify/pull/12345' + link = CLI::UI.link(url, '#12345') + + assert_example(20, link, link) + end + + def test_truncate_closes_an_open_osc8_hyperlink + link = CLI::UI.link('https://example.com/very/long/url', 'hello world', format: false) + opening = "\x1b]8;;https://example.com/very/long/url\x1b\\" + + assert_example(6, link, "#{opening}hello#{Truncater::HYPERLINK_END}#{Truncater::TRUNCATED}") + end + + def test_truncate_preserves_bel_terminated_osc + progress = "\x1b]9;4;1\x07" + text = "#{progress}hello world" + + assert_example(20, text, text) + assert_example(6, text, "#{progress}hello#{Truncater::TRUNCATED}") + end + + def test_truncate_keeps_osc8_open_across_unrelated_osc + opening = "\x1b]8;;https://example.com/very/long/url\x1b\\" + progress = "\x1b]9;4;1\x07" + closing = "\x1b]8;;\x1b\\" + text = "#{opening}hello#{progress} world#{closing}" + + assert_example( + 6, + text, + "#{opening}hello#{progress}#{Truncater::HYPERLINK_END}#{Truncater::TRUNCATED}", + ) + end + private def assert_example(width, from, to)