Add additional ansi-escape removal functions

This commit adds a number of functions for stripping ansi escape
characters and/or finding the position of the cursor in a line that may
contain colors and moves. The motivation for EscHelpers.cursorPosition
is that when printing progress lines, we need to know the visual
dimensions of the last line printed to the prompt. The
EscHelpers.stripColorsAndMoves can be used to remove all ansi escape
sequences. Finally EscHelpers.stripMoves leaves colors but strips out
all other escape sequences. This is so we can reprint the terminal
prompt during supershell. If we didn't strip out the escape sequences,
we could inadvertently blow away everything below the cursor in cases
where we actually want the lines below the cursor to persist.
This commit is contained in:
Ethan Atkins 2020-06-24 13:05:19 -07:00
parent 13138457cf
commit 034b9690c1
2 changed files with 173 additions and 0 deletions

View File

@ -7,6 +7,9 @@
package sbt.internal.util
import scala.collection.mutable.ArrayBuffer
import scala.util.Try
object EscHelpers {
/** Escape character, used to introduce an escape sequence. */
@ -84,6 +87,110 @@ object EscHelpers {
nextESC(s, next, sb)
}
}
private[this] val esc = 1
private[this] val csi = 2
def cursorPosition(s: String): Int = {
val bytes = s.getBytes
var i = 0
var index = 0
var state = 0
val digit = new ArrayBuffer[Byte]
var leftDigit = -1
while (i < bytes.length) {
bytes(i) match {
case 27 => state = esc
case b if (state == esc || state == csi) && b >= 48 && b < 58 =>
state = csi
digit += b
case '[' if state == esc => state = csi
case 8 =>
state = 0
index = index - 1
case b if state == csi =>
leftDigit = Try(new String(digit.toArray).toInt).getOrElse(0)
state = 0
b.toChar match {
case 'D' => index = math.max(index - leftDigit, 0)
case 'C' => index += leftDigit
case 'K' =>
case 'J' => if (leftDigit == 2) index = 0
case 'm' =>
case ';' => state = csi
case _ =>
}
digit.clear()
case _ =>
index += 1
}
i += 1
}
index
}
def stripMoves(s: String): String = {
val bytes = s.getBytes
val res = Array.fill[Byte](bytes.length)(0)
var index = 0
var lastEscapeIndex = -1
var state = 0
def set(b: Byte) = {
res(index) = b
index += 1
}
bytes.foreach { b =>
set(b)
b match {
case 27 =>
state = esc
lastEscapeIndex = math.max(0, index)
case b if b == '[' && state == esc => state = csi
case 'm' => state = 0
case b if state == csi && (b < 48 || b >= 58) && b != ';' =>
state = 0
index = math.max(0, lastEscapeIndex - 1)
case b =>
}
}
new String(res, 0, index)
}
def stripColorsAndMoves(s: String): String = {
val bytes = s.getBytes
val res = Array.fill[Byte](bytes.length)(0)
var i = 0
var index = 0
var state = 0
var limit = 0
val digit = new ArrayBuffer[Byte]
var leftDigit = -1
bytes.foreach {
case 27 => state = esc
case b if (state == esc || state == csi) && b >= 48 && b < 58 =>
state = csi
digit += b
case '[' if state == esc => state = csi
case 8 =>
state = 0
index = math.max(index - 1, 0)
case b if state == csi =>
leftDigit = Try(new String(digit.toArray).toInt).getOrElse(0)
state = 0
b.toChar match {
case 'D' => index = math.max(index - leftDigit, 0)
case 'C' => index = math.min(limit, math.min(index + leftDigit, res.length - 1))
case 'K' | 'J' =>
if (leftDigit > 0) (0 until index).foreach(res(_) = 32)
else res(index) = 32
case 'm' =>
case ';' => state = csi
case _ =>
}
digit.clear()
case b =>
res(index) = b
index += 1
limit = math.max(limit, index)
}
new String(res, 0, limit)
}
/** Skips the escape sequence starting at `i-1`. `i` should be positioned at the character after the ESC that starts the sequence. */
private[this] def skipESC(s: String, i: Int): Int = {

View File

@ -0,0 +1,66 @@
/*
* sbt
* Copyright 2011 - 2018, Lightbend, Inc.
* Copyright 2008 - 2010, Mark Harrah
* Licensed under Apache License 2.0 (see LICENSE)
*/
package sbt.internal.util
import org.scalatest.FlatSpec
class CleanStringSpec extends FlatSpec {
"EscHelpers" should "not modify normal strings" in {
val cleanString = s"1234"
assert(EscHelpers.stripColorsAndMoves(cleanString) == cleanString)
}
it should "remove delete lines" in {
val clean = "1234"
val string = s"${ConsoleAppender.DeleteLine}$clean"
assert(EscHelpers.stripColorsAndMoves(string) == clean)
}
it should "remove cursor left" in {
val clean = "1234"
val backspaced = s"1235${ConsoleAppender.cursorLeft(1)}${ConsoleAppender.clearLine(0)}4"
assert(EscHelpers.stripColorsAndMoves(backspaced) == clean)
}
it should "remove colors" in {
val clean = "1234"
val colored = s"${scala.Console.RED}$clean${scala.Console.RESET}"
assert(EscHelpers.stripColorsAndMoves(colored) == clean)
}
it should "remove backspaces" in {
// Taken from an actual failure case. In the scala client, type 'clean', then type backspace
// five times to clear 'clean' and then retype 'clean'.
val bytes = Array[Byte](27, 91, 50, 75, 27, 91, 48, 74, 27, 91, 50, 75, 27, 91, 49, 48, 48, 48,
68, 115, 98, 116, 58, 115, 99, 97, 108, 97, 45, 99, 111, 109, 112, 105, 108, 101, 27, 91, 51,
54, 109, 62, 32, 27, 91, 48, 109, 99, 108, 101, 97, 110, 8, 27, 91, 75, 110)
val str = new String(bytes)
assert(EscHelpers.stripColorsAndMoves(str) == "sbt:scala-compile> clean")
}
it should "handle cursor left overwrite" in {
val clean = "1234"
val backspaced = s"1235${8.toChar}4${8.toChar}"
assert(EscHelpers.stripColorsAndMoves(backspaced) == clean)
}
it should "remove moves in string with only moves" in {
val original =
new String(Array[Byte](27, 91, 50, 75, 27, 91, 51, 65, 27, 91, 49, 48, 48, 48, 68))
assert(EscHelpers.stripMoves(original) == "")
}
it should "remove moves in string with moves and letters" in {
val original = new String(
Array[Byte](27, 91, 50, 75, 27, 91, 51, 65) ++ "foo".getBytes ++ Array[Byte](27, 91, 49, 48,
48, 48, 68)
)
assert(EscHelpers.stripMoves(original) == "foo")
}
it should "preserve colors" in {
val original = new String(
Array[Byte](27, 91, 49, 48, 48, 48, 68, 27, 91, 48, 74, 102, 111, 111, 27, 91, 51, 54, 109,
62, 32, 27, 91, 48, 109)
) // this is taken from an sbt prompt that looks like "foo> " with the > rendered blue
val colorArrow = new String(Array[Byte](27, 91, 51, 54, 109, 62))
assert(EscHelpers.stripMoves(original) == "foo" + colorArrow + " " + scala.Console.RESET)
}
}