diff --git a/util/log/ConsoleLogger.scala b/util/log/ConsoleLogger.scala index d8ce50dd0..bc48d7ad2 100644 --- a/util/log/ConsoleLogger.scala +++ b/util/log/ConsoleLogger.scala @@ -27,6 +27,53 @@ object ConsoleLogger def println() = { out.newLine(); out.flush() } } + /** Escape character, used to introduce an escape sequence. */ + final val ESC = '\u001B' + + /** An escape terminator is a character in the range `@` (decimal value 64) to `~` (decimal value 126). + * It is the final character in an escape sequence. */ + def isEscapeTerminator(c: Char): Boolean = + c >= '@' && c <= '~' + + /** Returns true if the string contains the ESC character. */ + def hasEscapeSequence(s: String): Boolean = + s.indexOf(ESC) >= 0 + + /** Returns the string `s` with escape sequences removed. + * An escape sequence starts with the ESC character (decimal value 27) and ends with an escape terminator. + * @see isEscapeTerminator + */ + def removeEscapeSequences(s: String): String = + if(s.isEmpty || !hasEscapeSequence(s)) + s + else + { + val sb = new java.lang.StringBuilder + nextESC(s, 0, sb) + sb.toString + } + private[this] def nextESC(s: String, start: Int, sb: java.lang.StringBuilder) + { + val escIndex = s.indexOf(ESC, start) + if(escIndex < 0) + sb.append(s, start, s.length) + else { + sb.append(s, start, escIndex) + val next = skipESC(s, escIndex+1) + nextESC(s, next, sb) + } + } + + + /** 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 = + if(i >= s.length) + i + else if( isEscapeTerminator(s.charAt(i)) ) + i+1 + else + skipESC(s, i+1) + val formatEnabled = { import java.lang.Boolean.{getBoolean, parseBoolean} diff --git a/util/log/MainLogging.scala b/util/log/MainLogging.scala index 25f42a6af..d7b45e043 100644 --- a/util/log/MainLogging.scala +++ b/util/log/MainLogging.scala @@ -31,7 +31,7 @@ object MainLogging def defaultScreen(suppressedMessage: SuppressedTraceContext => Option[String]): AbstractLogger = ConsoleLogger(suppressedMessage = suppressedMessage) def defaultBacked(useColor: Boolean = ConsoleLogger.formatEnabled): PrintWriter => ConsoleLogger = - to => ConsoleLogger(ConsoleLogger.printWriterOut(to), useColor = useColor) // TODO: should probably filter ANSI codes when useColor=false + to => ConsoleLogger(ConsoleLogger.printWriterOut(to), useColor = useColor) } final case class MultiLoggerConfig(console: AbstractLogger, backed: AbstractLogger, extra: List[AbstractLogger], diff --git a/util/log/MultiLogger.scala b/util/log/MultiLogger.scala index 9cdb65386..cd73bf2c3 100644 --- a/util/log/MultiLogger.scala +++ b/util/log/MultiLogger.scala @@ -8,7 +8,10 @@ package sbt // on the behavior of the delegates. class MultiLogger(delegates: List[AbstractLogger]) extends BasicLogger { - override lazy val ansiCodesSupported = delegates.forall(_.ansiCodesSupported) + override lazy val ansiCodesSupported = delegates exists supported + private[this] lazy val allSupportCodes = delegates forall supported + private[this] def supported = (_: AbstractLogger).ansiCodesSupported + override def setLevel(newLevel: Level.Value) { super.setLevel(newLevel) @@ -29,5 +32,24 @@ class MultiLogger(delegates: List[AbstractLogger]) extends BasicLogger def success(message: => String) { dispatch(new Success(message)) } def logAll(events: Seq[LogEvent]) { delegates.foreach(_.logAll(events)) } def control(event: ControlEvent.Value, message: => String) { delegates.foreach(_.control(event, message)) } - private def dispatch(event: LogEvent) { delegates.foreach(_.log(event)) } + private[this] def dispatch(event: LogEvent) + { + val plainEvent = if(allSupportCodes) event else removeEscapes(event) + for( d <- delegates) + if(d.ansiCodesSupported) + d.log(event) + else + d.log(plainEvent) + } + + private[this] def removeEscapes(event: LogEvent): LogEvent = + { + import ConsoleLogger.{removeEscapeSequences => rm} + event match { + case s: Success => new Success(rm(s.msg)) + case l: Log => new Log(l.level, rm(l.msg)) + case ce: ControlEvent => new ControlEvent(ce.event, rm(ce.msg)) + case _: Trace | _: SetLevel | _: SetTrace | _: SetSuccess => event + } + } } \ No newline at end of file diff --git a/util/log/src/test/scala/Escapes.scala b/util/log/src/test/scala/Escapes.scala new file mode 100644 index 000000000..f90499574 --- /dev/null +++ b/util/log/src/test/scala/Escapes.scala @@ -0,0 +1,91 @@ +package sbt + +import org.scalacheck._ +import Prop._ +import Gen.{listOf, oneOf} + +import ConsoleLogger.{ESC, hasEscapeSequence, isEscapeTerminator, removeEscapeSequences} + +object Escapes extends Properties("Escapes") +{ + property("genTerminator only generates terminators") = + forAllNoShrink(genTerminator) { (c: Char) => isEscapeTerminator(c) } + + property("genWithoutTerminator only generates terminators") = + forAllNoShrink(genWithoutTerminator) { (s: String) => + s.forall { c => !isEscapeTerminator(c) } + } + + property("hasEscapeSequence is false when no escape character is present") = forAllNoShrink(genWithoutEscape) { (s: String) => + !hasEscapeSequence(s) + } + + property("hasEscapeSequence is true when escape character is present") = forAllNoShrink(genWithRandomEscapes) { (s: String) => + hasEscapeSequence(s) + } + + property("removeEscapeSequences is the identity when no escape character is present") = forAllNoShrink(genWithoutEscape) { (s: String) => + val removed: String = removeEscapeSequences(s) + ("Escape sequence removed: '" + removed + "'") |: + (removed == s) + } + + property("No escape characters remain after removeEscapeSequences") = forAll { (s: String) => + val removed: String = removeEscapeSequences(s) + ("Escape sequence removed: '" + removed + "'") |: + !hasEscapeSequence(removed) + } + + property("removeEscapeSequences returns string without escape sequences") = + forAllNoShrink( genWithoutEscape, genEscapePairs ) { (start: String, escapes: List[EscapeAndNot]) => + val withEscapes: String = start + escapes.map { ean => ean.escape.makeString + ean.notEscape } + val removed: String = removeEscapeSequences(withEscapes) + val original = start + escapes.map(_.notEscape) + ("Input string with escapes: '" + withEscapes + "'") |: + ("Escapes removed '" + removed + "'") |: + (original == removed) + } + + final case class EscapeAndNot(escape: EscapeSequence, notEscape: String) + final case class EscapeSequence(content: String, terminator: Char) + { + assert( content.forall(c => !isEscapeTerminator(c) ), "Escape sequence content contains an escape terminator: '" + content + "'" ) + assert( isEscapeTerminator(terminator) ) + def makeString: String = ESC + content + terminator + } + private[this] def noEscape(s: String): String = s.replace(ESC, ' ') + + lazy val genEscapeSequence: Gen[EscapeSequence] = oneOf(genKnownSequence, genArbitraryEscapeSequence) + lazy val genEscapePair: Gen[EscapeAndNot] = for(esc <- genEscapeSequence; not <- genWithoutEscape) yield EscapeAndNot(esc, not) + lazy val genEscapePairs: Gen[List[EscapeAndNot]] = listOf(genEscapePair) + + lazy val genArbitraryEscapeSequence: Gen[EscapeSequence] = + for(content <- genWithoutTerminator; term <- genTerminator) yield + new EscapeSequence(content, term) + + lazy val genKnownSequence: Gen[EscapeSequence] = + oneOf((misc ++ setGraphicsMode ++ setMode ++ resetMode).map(toEscapeSequence)) + + def toEscapeSequence(s: String): EscapeSequence = EscapeSequence(s.init, s.last) + + lazy val misc = Seq("14;23H", "5;3f", "2A", "94B", "19C", "85D", "s", "u", "2J", "K") + + lazy val setGraphicsMode: Seq[String] = + for(txt <- 0 to 8; fg <- 30 to 37; bg <- 40 to 47) yield + txt.toString + ";" + fg.toString + ";" + bg.toString + "m" + + lazy val resetMode = setModeLike('I') + lazy val setMode = setModeLike('h') + def setModeLike(term: Char): Seq[String] = (0 to 19).map(i => "=" + i.toString + term) + + lazy val genWithoutTerminator = genRawString.map( _.filter { c => !isEscapeTerminator(c) } ) + + lazy val genTerminator: Gen[Char] = Gen.choose('@', '~') + lazy val genWithoutEscape: Gen[String] = genRawString.map(noEscape) + + def genWithRandomEscapes: Gen[String] = + for(ls <- listOf(genRawString); end <- genRawString) yield + ls.mkString("", ESC.toString, ESC.toString + end) + + private def genRawString = Arbitrary.arbString.arbitrary +}