Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 67 additions & 13 deletions lib/cli/ui/truncater.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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|
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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).
Expand All @@ -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
Expand Down
35 changes: 35 additions & 0 deletions test/cli/ui/truncater_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading