From cd7b2ce3af489615b5438630d072afed2a37adbb Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 11 Feb 2025 15:30:12 -0300 Subject: [PATCH] fix(ansi): cut/truncate/truncateleft improperly handling new lines (#367) --- ansi/truncate.go | 41 +++++++++++++++++++++--------- ansi/truncate_test.go | 59 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 84 insertions(+), 16 deletions(-) diff --git a/ansi/truncate.go b/ansi/truncate.go index 1fa3efef..3f541fa5 100644 --- a/ansi/truncate.go +++ b/ansi/truncate.go @@ -10,8 +10,7 @@ import ( // Cut the string, without adding any prefix or tail strings. This function is // aware of ANSI escape codes and will not break them, and accounts for -// wide-characters (such as East-Asian characters and emojis). Note that the -// [left] parameter is inclusive, while [right] isn't. +// wide-characters (such as East-Asian characters and emojis). // This treats the text as a sequence of graphemes. func Cut(s string, left, right int) string { return cut(GraphemeWidth, s, left, right) @@ -19,8 +18,10 @@ func Cut(s string, left, right int) string { // CutWc the string, without adding any prefix or tail strings. This function is // aware of ANSI escape codes and will not break them, and accounts for -// wide-characters (such as East-Asian characters and emojis). Note that the -// [left] parameter is inclusive, while [right] isn't. +// wide-characters (such as East-Asian characters and emojis). +// Note that the [left] parameter is inclusive, while [right] isn't, +// which is to say it'll return `[left, right)`. +// // This treats the text as a sequence of wide characters and runes. func CutWc(s string, left, right int) string { return cut(WcWidth, s, left, right) @@ -41,7 +42,7 @@ func cut(m Method, s string, left, right int) string { if left == 0 { return truncate(s, right, "") } - return truncateLeft(Truncate(s, right, ""), left, "") + return truncateLeft(truncate(s, right, ""), left, "") } // Truncate truncates a string to a given length, adding a tail to the end if @@ -99,6 +100,7 @@ func truncate(m Method, s string, length int, tail string) string { // increment the index by the length of the cluster i += len(cluster) + curWidth += width // Are we ignoring? Skip to the next byte if ignoring { @@ -107,16 +109,15 @@ func truncate(m Method, s string, length int, tail string) string { // Is this gonna be too wide? // If so write the tail and stop collecting. - if curWidth+width > length && !ignoring { + if curWidth > length && !ignoring { ignoring = true buf.WriteString(tail) } - if curWidth+width > length { + if curWidth > length { continue } - curWidth += width buf.Write(cluster) // Done collecting, now we're back in the ground state. @@ -142,6 +143,14 @@ func truncate(m Method, s string, length int, tail string) string { // collects printable ASCII curWidth++ fallthrough + case parser.ExecuteAction: + // execute action will be things like \n, which, if outside the cut, + // should be ignored. + if ignoring { + i++ + continue + } + fallthrough default: buf.WriteByte(b[i]) i++ @@ -214,14 +223,14 @@ func truncateLeft(m Method, s string, n int, prefix string) string { buf.WriteString(prefix) } - if ignoring { - continue - } - if curWidth > n { buf.Write(cluster) } + if ignoring { + continue + } + pstate = parser.GroundState continue } @@ -240,6 +249,14 @@ func truncateLeft(m Method, s string, n int, prefix string) string { continue } + fallthrough + case parser.ExecuteAction: + // execute action will be things like \n, which, if outside the cut, + // should be ignored. + if ignoring { + i++ + continue + } fallthrough default: buf.WriteByte(b[i]) diff --git a/ansi/truncate_test.go b/ansi/truncate_test.go index 0f346dfe..decf11a6 100644 --- a/ansi/truncate_test.go +++ b/ansi/truncate_test.go @@ -45,6 +45,14 @@ var tcases = []struct { "on.", ".👋", }, + { + "simple multiple words", + "a couple of words", + "", + 6, + "a coup", + "le of words", + }, { "equalcontrolemoji", "one\x1b[0m", @@ -275,7 +283,45 @@ var tcases = []struct { "…", 9, "สวัสดีสวัสดี\x1b]8;;https://example.com\x1b\\\n…\x1b]8;;\x1b\\", - "\x1b]8;;https://example.com\x1b\\\n…วัสดีสวัสดี\x1b]8;;\x1b\\", + "\x1b]8;;https://example.com\x1b\\…วัสดีสวัสดี\x1b]8;;\x1b\\", + }, + { + "simple japanese text prefix/suffix", + "耐許ヱヨカハ調出あゆ監", + "…", + 13, + "耐許ヱヨカハ…", + "…調出あゆ監", + }, + { + "simple japanese text", + "耐許ヱヨカハ調出あゆ監", + "", + 14, + "耐許ヱヨカハ調", + "出あゆ監", + }, + { + "new line inside and outside range", + "\n\nsomething\nin\nthe\nway\n\n", + "-", + 10, + "\n\nsomething\n-", + "-n\nthe\nway\n\n", + }, + { + "multi-width graphemes with newlines - japanese text", + `耐許ヱヨカハ調出あゆ監件び理別よン國給災レホチ権輝モエフ会割もフ響3現エツ文時しだびほ経機ムイメフ敗文ヨク現義なさド請情ゆじょて憶主管州けでふく。排ゃわつげ美刊ヱミ出見ツ南者オ抜豆ハトロネ論索モネニイ任償スヲ話破リヤヨ秒止口イセソス止央のさ食周健でてつだ官送ト読聴遊容ひるべ。際ぐドらづ市居ネムヤ研校35岩6繹ごわク報拐イ革深52球ゃレスご究東スラ衝3間ラ録占たス。 + +禁にンご忘康ざほぎル騰般ねど事超スんいう真表何カモ自浩ヲシミ図客線るふ静王ぱーま写村月掛焼詐面ぞゃ。昇強ごントほ価保キ族85岡モテ恋困ひりこな刊並せご出来ぼぎむう点目ヲウ止環公ニレ事応タス必書タメムノ当84無信升ちひょ。価ーぐ中客テサ告覧ヨトハ極整 +ラ得95稿はかラせ江利ス宏丸霊ミ考整ス静将ず業巨職ノラホ収嗅ざな。`, + "", + 14, + "耐許ヱヨカハ調", + `出あゆ監件び理別よン國給災レホチ権輝モエフ会割もフ響3現エツ文時しだびほ経機ムイメフ敗文ヨク現義なさド請情ゆじょて憶主管州けでふく。排ゃわつげ美刊ヱミ出見ツ南者オ抜豆ハトロネ論索モネニイ任償スヲ話破リヤヨ秒止口イセソス止央のさ食周健でてつだ官送ト読聴遊容ひるべ。際ぐドらづ市居ネムヤ研校35岩6繹ごわク報拐イ革深52球ゃレスご究東スラ衝3間ラ録占たス。 + +禁にンご忘康ざほぎル騰般ねど事超スんいう真表何カモ自浩ヲシミ図客線るふ静王ぱーま写村月掛焼詐面ぞゃ。昇強ごントほ価保キ族85岡モテ恋困ひりこな刊並せご出来ぼぎむう点目ヲウ止環公ニレ事応タス必書タメムノ当84無信升ちひょ。価ーぐ中客テサ告覧ヨトハ極整 +ラ得95稿はかラせ江利ス宏丸霊ミ考整ス静将ず業巨職ノラホ収嗅ざな。`, }, } @@ -283,7 +329,7 @@ func TestTruncate(t *testing.T) { for i, c := range tcases { t.Run(c.name, func(t *testing.T) { if result := Truncate(c.input, c.width, c.extra); result != c.expectRight { - t.Errorf("test case %d failed: expected %q, got %q", i+1, c.expectRight, result) + t.Errorf("test case %d failed:\nexpected: %q\n got: %q", i+1, c.expectRight, result) } }) } @@ -303,7 +349,7 @@ func TestTruncateLeft(t *testing.T) { for i, c := range tcases { t.Run(c.name, func(t *testing.T) { if result := TruncateLeft(c.input, c.width, c.extra); result != c.expectLeft { - t.Errorf("test case %d failed: expected %q, got %q", i+1, c.expectLeft, result) + t.Errorf("test case %d failed:\nexpected: %q\n got: %q", i+1, c.expectLeft, result) } }) } @@ -352,8 +398,13 @@ func TestCut(t *testing.T) { "\x1b[38;5;212;48;5;63mHello, Artichoke!\x1b[m", 7, 16, "\x1b[38;5;212;48;5;63mArtichoke\x1b[m", }, + { + "multiline", + "\n\x1b[38;2;98;98;98m\nif [ -f RE\nADME.md ]; then\x1b[m\n\x1b[38;2;98;98;98m echo oi\x1b[m\n\x1b[38;2;98;98;98mfi\x1b[m\n", 8, 13, + "\x1b[38;2;98;98;98mRE\nADM\x1b[m\x1b[38;2;98;98;98m\x1b[m\x1b[38;2;98;98;98m\x1b[m", + }, } { - t.Run(c.input, func(t *testing.T) { + t.Run(c.desc, func(t *testing.T) { got := Cut(c.input, c.left, c.right) if got != c.expect { t.Errorf("%s (#%d):\nexpected: %q\ngot: %q", c.desc, i+1, c.expect, got)