diff --git a/internal/util-logging/src/main/scala/sbt/internal/util/EscHelpers.scala b/internal/util-logging/src/main/scala/sbt/internal/util/EscHelpers.scala index 253bc76cf..44d00af8c 100644 --- a/internal/util-logging/src/main/scala/sbt/internal/util/EscHelpers.scala +++ b/internal/util-logging/src/main/scala/sbt/internal/util/EscHelpers.scala @@ -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 = { diff --git a/internal/util-logging/src/test/scala/sbt/internal/util/CleanStringSpec.scala b/internal/util-logging/src/test/scala/sbt/internal/util/CleanStringSpec.scala new file mode 100644 index 000000000..f7ad9185c --- /dev/null +++ b/internal/util-logging/src/test/scala/sbt/internal/util/CleanStringSpec.scala @@ -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) + } +}