diff --git a/internal/util-complete/src/main/scala/sbt/internal/util/LineReader.scala b/internal/util-complete/src/main/scala/sbt/internal/util/LineReader.scala index dfce336b3..bac94c1de 100644 --- a/internal/util-complete/src/main/scala/sbt/internal/util/LineReader.scala +++ b/internal/util-complete/src/main/scala/sbt/internal/util/LineReader.scala @@ -18,7 +18,6 @@ import scala.concurrent.duration._ trait LineReader { def readLine(prompt: String, mask: Option[Char] = None): Option[String] - def redraw(): Unit = () } object LineReader { @@ -26,8 +25,12 @@ object LineReader { !java.lang.Boolean.getBoolean("sbt.disable.cont") && Signals.supported(Signals.CONT) val MaxHistorySize = 500 - def createReader(historyPath: Option[File], in: InputStream): ConsoleReader = { - val cr = Terminal.createReader(in) + def createReader( + historyPath: Option[File], + terminal: Terminal, + prompt: Prompt = Prompt.Running, + ): ConsoleReader = { + val cr = Terminal.createReader(terminal, prompt) cr.setExpandEvents(false) // https://issues.scala-lang.org/browse/SI-7650 cr.setBellEnabled(false) val h = historyPath match { @@ -36,9 +39,11 @@ object LineReader { } h.setMaxSize(MaxHistorySize) cr.setHistory(h) + cr.setHistoryEnabled(true) cr } + def simple(terminal: Terminal): LineReader = new SimpleReader(None, HandleCONT, terminal) def simple( historyPath: Option[File], handleCONT: Boolean = HandleCONT, @@ -56,18 +61,13 @@ abstract class JLine extends LineReader { override def readLine(prompt: String, mask: Option[Char] = None): Option[String] = try { - Terminal.withRawSystemIn(unsynchronizedReadLine(prompt, mask)) + unsynchronizedReadLine(prompt, mask) } catch { case _: InterruptedException => // println("readLine: InterruptedException") Option("") } - override def redraw(): Unit = { - reader.drawLine() - reader.flush() - } - private[this] def unsynchronizedReadLine(prompt: String, mask: Option[Char]): Option[String] = readLineWithHistory(prompt, mask) map { x => x.trim @@ -144,16 +144,19 @@ private[sbt] object JLine { * For accessing the JLine Terminal object. * This ensures synchronized access as well as re-enabling echo after getting the Terminal. */ - @deprecated("Don't use jline.Terminal directly. Use Terminal.withCanonicalIn instead.", "1.4.0") + @deprecated( + "Don't use jline.Terminal directly. Use Terminal.get.withCanonicalIn instead.", + "1.4.0" + ) def usingTerminal[T](f: jline.Terminal => T): T = - Terminal.withCanonicalIn(f(Terminal.deprecatedTeminal)) + Terminal.get.withCanonicalIn(f(Terminal.get.toJLine)) @deprecated("unused", "1.4.0") def createReader(): ConsoleReader = createReader(None, Terminal.wrappedSystemIn) @deprecated("Use LineReader.createReader", "1.4.0") def createReader(historyPath: Option[File], in: InputStream): ConsoleReader = { - val cr = Terminal.createReader(in) + val cr = Terminal.createReader(Terminal.console, Prompt.Running) cr.setExpandEvents(false) // https://issues.scala-lang.org/browse/SI-7650 cr.setBellEnabled(false) val h = historyPath match { @@ -165,8 +168,8 @@ private[sbt] object JLine { cr } - @deprecated("Avoid referencing JLine directly. Use Terminal.withRawSystemIn instead.", "1.4.0") - def withJLine[T](action: => T): T = Terminal.withRawSystemIn(action) + @deprecated("Avoid referencing JLine directly.", "1.4.0") + def withJLine[T](action: => T): T = Terminal.get.withRawSystemIn(action) @deprecated("Use LineReader.simple instead", "1.4.0") def simple( @@ -211,7 +214,7 @@ final class FullReader( historyPath: Option[File], complete: Parser[_], val handleCONT: Boolean, - inputStream: InputStream, + terminal: Terminal ) extends JLine { @deprecated("Use the constructor with no injectThreadSleep parameter", "1.4.0") def this( @@ -219,9 +222,15 @@ final class FullReader( complete: Parser[_], handleCONT: Boolean = LineReader.HandleCONT, injectThreadSleep: Boolean = false - ) = this(historyPath, complete, handleCONT, JLine.makeInputStream(injectThreadSleep)) + ) = + this( + historyPath, + complete, + handleCONT, + Terminal.console + ) protected[this] val reader: ConsoleReader = { - val cr = LineReader.createReader(historyPath, inputStream) + val cr = LineReader.createReader(historyPath, terminal) sbt.internal.util.complete.JLineCompletion.installCustomCompletor(cr, complete) cr } @@ -230,12 +239,15 @@ final class FullReader( class SimpleReader private[sbt] ( historyPath: Option[File], val handleCONT: Boolean, - inputStream: InputStream + terminal: Terminal ) extends JLine { def this(historyPath: Option[File], handleCONT: Boolean, injectThreadSleep: Boolean) = - this(historyPath, handleCONT, Terminal.wrappedSystemIn) + this(historyPath, handleCONT, Terminal.console) protected[this] val reader: ConsoleReader = - LineReader.createReader(historyPath, inputStream) + LineReader.createReader(historyPath, terminal) } -object SimpleReader extends SimpleReader(None, LineReader.HandleCONT, false) +object SimpleReader extends SimpleReader(None, LineReader.HandleCONT, false) { + def apply(terminal: Terminal): SimpleReader = + new SimpleReader(None, LineReader.HandleCONT, terminal) +} diff --git a/internal/util-logging/src/main/scala/sbt/internal/util/ConsoleAppender.scala b/internal/util-logging/src/main/scala/sbt/internal/util/ConsoleAppender.scala index e403c9213..7b533ef61 100644 --- a/internal/util-logging/src/main/scala/sbt/internal/util/ConsoleAppender.scala +++ b/internal/util-logging/src/main/scala/sbt/internal/util/ConsoleAppender.scala @@ -100,13 +100,11 @@ class ConsoleLogger private[ConsoleLogger] ( override def trace(t: => Throwable): Unit = appender.trace(t, getTrace) - override def logAll(events: Seq[LogEvent]) = - out.lockObject.synchronized { events.foreach(log) } + override def logAll(events: Seq[LogEvent]) = events.foreach(log) } object ConsoleAppender { private[sbt] def cursorLeft(n: Int): String = s"\u001B[${n}D" - private[sbt] def cursorRight(n: Int): String = s"\u001B[${n}C" private[sbt] def cursorUp(n: Int): String = s"\u001B[${n}A" private[sbt] def cursorDown(n: Int): String = s"\u001B[${n}B" private[sbt] def scrollUp(n: Int): String = s"\u001B[${n}S" @@ -116,9 +114,27 @@ object ConsoleAppender { private[sbt] final val ClearScreenAfterCursor = clearScreen(0) private[sbt] final val CursorLeft1000 = cursorLeft(1000) private[sbt] final val CursorDown1 = cursorDown(1) + private[sbt] final val ClearPromptLine = CursorLeft1000 + ClearScreenAfterCursor private[this] val showProgressHolder: AtomicBoolean = new AtomicBoolean(false) def setShowProgress(b: Boolean): Unit = showProgressHolder.set(b) def showProgress: Boolean = showProgressHolder.get + private[ConsoleAppender] trait Properties { + def isAnsiSupported: Boolean + def isColorEnabled: Boolean + def out: ConsoleOut + } + object Properties { + def from(terminal: Terminal): Properties = new Properties { + override def isAnsiSupported: Boolean = terminal.isAnsiSupported + override def isColorEnabled: Boolean = terminal.isColorEnabled + override def out = ConsoleOut.terminalOut(terminal) + } + def from(o: ConsoleOut, ansi: Boolean, color: Boolean): Properties = new Properties { + override def isAnsiSupported: Boolean = ansi + override def isColorEnabled: Boolean = color + override def out = o + } + } /** Hide stack trace altogether. */ val noSuppressedMessage = (_: SuppressedTraceContext) => None @@ -130,7 +146,7 @@ object ConsoleAppender { * 3. -Dsbt.colour=always/auto/never/true/false * 4. -Dsbt.log.format=always/auto/never/true/false */ - val formatEnabledInEnv: Boolean = { + lazy val formatEnabledInEnv: Boolean = { def useColorDefault: Boolean = { // This approximates that both stdin and stdio are connected, // so by default color will be turned off for pipes and redirects. @@ -239,7 +255,38 @@ object ConsoleAppender { * @return A new `ConsoleAppender` that writes to `out`. */ def apply(name: String, out: ConsoleOut, useFormat: Boolean): ConsoleAppender = - apply(name, out, formatEnabledInEnv, useFormat, noSuppressedMessage) + apply(name, out, useFormat || formatEnabledInEnv, useFormat, noSuppressedMessage) + + /** + * A new `ConsoleAppender` identified by `name`, and that writes to `out`. + * + * @param name An identifier for the `ConsoleAppender`. + * @param terminal The terminal to which this appender corresponds + * @return A new `ConsoleAppender` that writes to `out`. + */ + def apply(name: String, terminal: Terminal): ConsoleAppender = { + val appender = new ConsoleAppender(name, Properties.from(terminal), noSuppressedMessage) + appender.start() + appender + } + + /** + * A new `ConsoleAppender` identified by `name`, and that writes to `out`. + * + * @param name An identifier for the `ConsoleAppender`. + * @param terminal The terminal to which this appender corresponds + * @param suppressedMessage How to handle stack traces. + * @return A new `ConsoleAppender` that writes to `out`. + */ + def apply( + name: String, + terminal: Terminal, + suppressedMessage: SuppressedTraceContext => Option[String] + ): ConsoleAppender = { + val appender = new ConsoleAppender(name, Properties.from(terminal), suppressedMessage) + appender.start() + appender + } /** * A new `ConsoleAppender` identified by `name`, and that writes to `out`. @@ -296,7 +343,7 @@ object ConsoleAppender { private[sbt] def generateName(): String = "out-" + generateId.incrementAndGet - private[this] def ansiSupported: Boolean = Terminal.isAnsiSupported + private[this] def ansiSupported: Boolean = Terminal.console.isAnsiSupported } // See http://stackoverflow.com/questions/24205093/how-to-create-a-custom-appender-in-log4j2 @@ -312,14 +359,23 @@ object ConsoleAppender { */ class ConsoleAppender private[ConsoleAppender] ( name: String, - out: ConsoleOut, - ansiCodesSupported: Boolean, - useFormat: Boolean, + properties: Properties, suppressedMessage: SuppressedTraceContext => Option[String] ) extends AbstractAppender(name, null, LogExchange.dummyLayout, true, Array.empty) { + def this( + name: String, + out: ConsoleOut, + ansiCodesSupported: Boolean, + useFormat: Boolean, + suppressedMessage: SuppressedTraceContext => Option[String] + ) = this(name, Properties.from(out, ansiCodesSupported, useFormat), suppressedMessage) import scala.Console.{ BLUE, GREEN, RED, YELLOW } - private val reset: String = { + private[util] def out: ConsoleOut = properties.out + private[util] def ansiCodesSupported: Boolean = properties.isAnsiSupported + private[util] def useFormat: Boolean = properties.isColorEnabled + + private def reset: String = { if (ansiCodesSupported && useFormat) scala.Console.RESET else "" } @@ -352,16 +408,15 @@ class ConsoleAppender private[ConsoleAppender] ( * @param t The `Throwable` whose stack trace to log. * @param traceLevel How to shorten the stack trace. */ - def trace(t: => Throwable, traceLevel: Int): Unit = - out.lockObject.synchronized { - if (traceLevel >= 0) - write(StackTrace.trimmed(t, traceLevel)) - if (traceLevel <= 2) { - val ctx = new SuppressedTraceContext(traceLevel, ansiCodesSupported && useFormat) - for (msg <- suppressedMessage(ctx)) - appendLog(NO_COLOR, "trace", NO_COLOR, msg) - } + def trace(t: => Throwable, traceLevel: Int): Unit = { + if (traceLevel >= 0) + write(StackTrace.trimmed(t, traceLevel)) + if (traceLevel <= 2) { + val ctx = new SuppressedTraceContext(traceLevel, ansiCodesSupported && useFormat) + for (msg <- suppressedMessage(ctx)) + appendLog(NO_COLOR, "trace", NO_COLOR, msg) } + } /** * Logs a `ControlEvent` to the log. @@ -382,18 +437,6 @@ class ConsoleAppender private[ConsoleAppender] ( appendLog(labelColor(level), level.toString, NO_COLOR, message) } - /** - * Formats `msg` with `format, wrapped between `RESET`s - * - * @param format The format to use - * @param msg The message to format - * @return The formatted message. - */ - private def formatted(format: String, msg: String): String = { - val builder = new java.lang.StringBuilder(reset.length * 2 + format.length + msg.length) - builder.append(reset).append(format).append(msg).append(reset).toString - } - /** * Select the right color for the label given `level`. * @@ -424,22 +467,24 @@ class ConsoleAppender private[ConsoleAppender] ( messageColor: String, message: String ): Unit = - out.lockObject.synchronized { - val builder: StringBuilder = - new StringBuilder(labelColor.length + label.length + messageColor.length + reset.length * 3) + try { + val len = + labelColor.length + label.length + messageColor.length + reset.length * 3 + ClearScreenAfterCursor.length + val builder: StringBuilder = new StringBuilder(len) message.linesIterator.foreach { line => - builder.ensureCapacity( - labelColor.length + label.length + messageColor.length + line.length + reset.length * 3 + 3 - ) + builder.ensureCapacity(len + line.length + 4) builder.setLength(0) + def fmted(a: String, b: String) = builder.append(reset).append(a).append(b).append(reset) + builder.append(reset).append('[') fmted(labelColor, label) builder.append("] ") fmted(messageColor, line) + builder.append(ClearScreenAfterCursor) write(builder.toString) } - } + } catch { case _: InterruptedException => } // success is called by ConsoleLogger. private[sbt] def success(message: => String): Unit = { @@ -449,7 +494,11 @@ class ConsoleAppender private[ConsoleAppender] ( private def write(msg: String): Unit = { val toWrite = if (!useFormat || !ansiCodesSupported) EscHelpers.removeEscapeSequences(msg) else msg - out.println(toWrite) + /* + * Use print + flush rather than println to prevent log lines from getting interleaved. + */ + out.print(toWrite + "\n") + out.flush() } private def appendMessage(level: Level.Value, msg: Message): Unit = @@ -519,45 +568,70 @@ private[sbt] final class ProgressState( new AtomicReference(Nil), new AtomicInteger(0), blankZone, - new AtomicReference(new ArrayBuffer[Byte]) + new AtomicReference(new ArrayBuffer[Byte]), ) def reset(): Unit = { progressLines.set(Nil) padding.set(0) + currentLineBytes.set(new ArrayBuffer[Byte]) + } + private[util] def clearBytes(): Unit = { + val pad = padding.get + if (currentLineBytes.get.isEmpty && pad > 0) padding.decrementAndGet() + currentLineBytes.set(new ArrayBuffer[Byte]) + } + + private[util] def addBytes(terminal: Terminal, bytes: ArrayBuffer[Byte]): Unit = { + val previous = currentLineBytes.get + val padding = this.padding.get + val prevLineCount = if (padding > 0) terminal.lineCount(new String(previous.toArray)) else 0 + previous ++= bytes + if (padding > 0) { + val newLineCount = terminal.lineCount(new String(previous.toArray)) + val diff = newLineCount - prevLineCount + this.padding.set(math.max(padding - diff, 0)) + } + } + + private[util] def printPrompt(terminal: Terminal, printStream: PrintStream): Unit = + if (terminal.prompt != Prompt.Running && terminal.prompt != Prompt.Batch) { + val prefix = if (terminal.isAnsiSupported) s"$DeleteLine$CursorLeft1000" else "" + val pmpt = prefix.getBytes ++ terminal.prompt.render().getBytes + pmpt.foreach(b => printStream.write(b & 0xFF)) + } + private[util] def reprint(terminal: Terminal, printStream: PrintStream): Unit = { + printPrompt(terminal, printStream) + if (progressLines.get.nonEmpty) { + val lines = printProgress(terminal, terminal.getLastLine.getOrElse("")) + printStream.print(ClearScreenAfterCursor + lines) + } + } + + private[util] def printProgress( + terminal: Terminal, + lastLine: String + ): String = { + val previousLines = progressLines.get + if (previousLines.nonEmpty) { + val currentLength = previousLines.foldLeft(0)(_ + terminal.lineCount(_)) + val (height, width) = terminal.getLineHeightAndWidth(lastLine) + val left = cursorLeft(1000) // resets the position to the left + val offset = width > 0 + val pad = math.max(padding.get - height, 0) + val start = (if (offset) "\n" else "") + val totalSize = currentLength + blankZone + pad + val blank = left + s"\n$DeleteLine" * (totalSize - currentLength) + val lines = previousLines.mkString(DeleteLine, s"\n$DeleteLine", s"\n$DeleteLine") + val resetCursorUp = cursorUp(totalSize + (if (offset) 1 else 0)) + val resetCursor = resetCursorUp + left + lastLine + start + blank + lines + resetCursor + } else { + ClearScreenAfterCursor + } } } + private[sbt] object ProgressState { - private val progressState: AtomicReference[ProgressState] = new AtomicReference(null) - private[util] def clearBytes(): Unit = progressState.get match { - case null => - case state => - val pad = state.padding.get - if (state.currentLineBytes.get.isEmpty && pad > 0) state.padding.decrementAndGet() - state.currentLineBytes.set(new ArrayBuffer[Byte]) - } - - private[util] def addBytes(bytes: ArrayBuffer[Byte]): Unit = progressState.get match { - case null => - case state => - val previous = state.currentLineBytes.get - val padding = state.padding.get - val prevLineCount = if (padding > 0) Terminal.lineCount(new String(previous.toArray)) else 0 - previous ++= bytes - if (padding > 0) { - val newLineCount = Terminal.lineCount(new String(previous.toArray)) - val diff = newLineCount - prevLineCount - state.padding.set(math.max(padding - diff, 0)) - } - } - - private[util] def reprint(printStream: PrintStream): Unit = progressState.get match { - case null => printStream.write('\n') - case state => - if (state.progressLines.get.nonEmpty) { - val lines = printProgress(0, 0) - printStream.print(ClearScreenAfterCursor + "\n" + lines) - } else printStream.write('\n') - } /** * Receives a new task report and replaces the old one. In the event that the new @@ -566,51 +640,53 @@ private[sbt] object ProgressState { * at the info or greater level, we can decrement the padding because the console * line will have filled in the blank line. */ - private[sbt] def updateProgressState(pe: ProgressEvent): Unit = Terminal.withPrintStream { ps => - progressState.get match { - case null => - case state => - val info = pe.items.map { item => - val elapsed = item.elapsedMicros / 1000000L - s" | => ${item.name} ${elapsed}s" + private[sbt] def updateProgressState( + pe: ProgressEvent, + terminal: Terminal + ): Unit = { + val state = terminal.progressState + val isRunning = terminal.prompt == Prompt.Running + val isBatch = terminal.prompt == Prompt.Batch + val isWatch = terminal.prompt == Prompt.Watch + if (terminal.isSupershellEnabled) { + if (!pe.skipIfActive.getOrElse(false) || (!isRunning && !isBatch)) { + terminal.withPrintStream { ps => + val info = if (isRunning || isBatch && pe.channelName.fold(true)(_ == terminal.name)) { + pe.items.map { item => + val elapsed = item.elapsedMicros / 1000000L + s" | => ${item.name} ${elapsed}s" + } + } else { + pe.command.toSeq.flatMap { cmd => + s"sbt server is running '$cmd'" :: tail + } + } + + val currentLength = info.foldLeft(0)(_ + terminal.lineCount(_)) + val previousLines = state.progressLines.getAndSet(info) + if (previousLines != info) { + val prevLength = previousLines.foldLeft(0)(_ + terminal.lineCount(_)) + val lastLine = terminal.prompt match { + case Prompt.Running | Prompt.Batch => terminal.getLastLine.getOrElse("") + case a => a.render() + } + val prevSize = prevLength + state.padding.get + + val newPadding = math.max(0, prevSize - currentLength) + state.padding.set(newPadding) + state.printPrompt(terminal, ps) + ps.print(state.printProgress(terminal, lastLine)) + ps.flush() + } } - - val currentLength = info.foldLeft(0)(_ + Terminal.lineCount(_)) - val previousLines = state.progressLines.getAndSet(info) - val prevLength = previousLines.foldLeft(0)(_ + Terminal.lineCount(_)) - - val (height, width) = Terminal.getLineHeightAndWidth - val prevSize = prevLength + state.padding.get - - val newPadding = math.max(0, prevSize - currentLength) - state.padding.set(newPadding) - ps.print(printProgress(height, width)) - ps.flush() + } else if (state.progressLines.get.nonEmpty) { + state.progressLines.set(Nil) + terminal.withPrintStream { ps => + val lastLine = terminal.getLastLine.getOrElse("") + ps.print(lastLine + ClearScreenAfterCursor) + ps.flush() + } + } } } - - private[sbt] def set(state: ProgressState): Unit = progressState.set(state) - - private[util] def printProgress(height: Int, width: Int): String = progressState.get match { - case null => "" - case state => - val previousLines = state.progressLines.get - if (previousLines.nonEmpty) { - val currentLength = previousLines.foldLeft(0)(_ + Terminal.lineCount(_)) - val left = cursorLeft(1000) // resets the position to the left - val offset = width > 0 - val pad = math.max(state.padding.get - height, 0) - val start = ClearScreenAfterCursor + (if (offset) "\n" else "") - val totalSize = currentLength + state.blankZone + pad - val blank = left + s"\n$DeleteLine" * (totalSize - currentLength) - val lines = previousLines.mkString(DeleteLine, s"\n$DeleteLine", s"\n$DeleteLine") - val resetCursorUp = cursorUp(totalSize + (if (offset) 1 else 0)) - val resetCursorRight = left + (if (offset) cursorRight(width) else "") - val resetCursor = resetCursorUp + resetCursorRight - start + blank + lines + resetCursor - } else { - ClearScreenAfterCursor - } - } - } diff --git a/internal/util-logging/src/main/scala/sbt/internal/util/ConsoleOut.scala b/internal/util-logging/src/main/scala/sbt/internal/util/ConsoleOut.scala index f0c0a1d12..2ea84f182 100644 --- a/internal/util-logging/src/main/scala/sbt/internal/util/ConsoleOut.scala +++ b/internal/util-logging/src/main/scala/sbt/internal/util/ConsoleOut.scala @@ -8,6 +8,8 @@ package sbt.internal.util import java.io.{ BufferedWriter, PrintStream, PrintWriter } +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicReference sealed trait ConsoleOut { val lockObject: AnyRef @@ -18,7 +20,20 @@ sealed trait ConsoleOut { } object ConsoleOut { - def systemOut: ConsoleOut = printStreamOut(System.out) + def systemOut: ConsoleOut = terminalOut + private[sbt] def globalProxy: ConsoleOut = Proxy + private[sbt] def setGlobalProxy(out: ConsoleOut): Unit = Proxy.set(out) + private[sbt] def getGlobalProxy: ConsoleOut = Proxy.proxy.get + private object Proxy extends ConsoleOut { + private[ConsoleOut] val proxy = new AtomicReference[ConsoleOut](systemOut) + private[this] def get: ConsoleOut = proxy.get + def set(proxy: ConsoleOut): Unit = this.proxy.set(proxy) + override val lockObject: AnyRef = proxy + override def print(s: String): Unit = get.print(s) + override def println(s: String): Unit = get.println(s) + override def println(): Unit = get.println() + override def flush(): Unit = get.flush() + } def overwriteContaining(s: String): (String, String) => Boolean = (cur, prev) => cur.contains(s) && prev.contains(s) @@ -57,6 +72,28 @@ object ConsoleOut { } } + def terminalOut: ConsoleOut = new ConsoleOut { + override val lockObject: AnyRef = System.out + override def print(s: String): Unit = Terminal.get.printStream.print(s) + override def println(s: String): Unit = Terminal.get.printStream.println(s) + override def println(): Unit = Terminal.get.printStream.println() + override def flush(): Unit = Terminal.get.printStream.flush() + } + + private[this] val consoleOutPerTerminal = new ConcurrentHashMap[Terminal, ConsoleOut] + def terminalOut(terminal: Terminal): ConsoleOut = consoleOutPerTerminal.get(terminal) match { + case null => + val res = new ConsoleOut { + override val lockObject: AnyRef = terminal + override def print(s: String): Unit = terminal.printStream.print(s) + override def println(s: String): Unit = terminal.printStream.println(s) + override def println(): Unit = terminal.printStream.println() + override def flush(): Unit = terminal.printStream.flush() + } + consoleOutPerTerminal.put(terminal, res) + res + case c => c + } def printStreamOut(out: PrintStream): ConsoleOut = new ConsoleOut { val lockObject = out def print(s: String) = out.print(s) diff --git a/internal/util-logging/src/main/scala/sbt/internal/util/MainLogging.scala b/internal/util-logging/src/main/scala/sbt/internal/util/MainLogging.scala index 6a0f2d929..b58fd97d2 100644 --- a/internal/util-logging/src/main/scala/sbt/internal/util/MainLogging.scala +++ b/internal/util-logging/src/main/scala/sbt/internal/util/MainLogging.scala @@ -75,8 +75,13 @@ object MainAppender { def defaultScreen( console: ConsoleOut, suppressedMessage: SuppressedTraceContext => Option[String] - ): Appender = - ConsoleAppender(ConsoleAppender.generateName, console, suppressedMessage = suppressedMessage) + ): Appender = { + ConsoleAppender( + ConsoleAppender.generateName, + Terminal.get, + suppressedMessage = suppressedMessage + ) + } def defaultScreen( name: String, diff --git a/internal/util-logging/src/main/scala/sbt/internal/util/Prompt.scala b/internal/util-logging/src/main/scala/sbt/internal/util/Prompt.scala new file mode 100644 index 000000000..c87f4dca4 --- /dev/null +++ b/internal/util-logging/src/main/scala/sbt/internal/util/Prompt.scala @@ -0,0 +1,47 @@ +/* + * sbt + * Copyright 2011 - 2018, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt.internal.util + +import java.io.OutputStream +import java.util.concurrent.LinkedBlockingQueue + +import scala.collection.JavaConverters._ + +private[sbt] sealed trait Prompt { + def mkPrompt: () => String + def render(): String + def wrappedOutputStream(terminal: Terminal): OutputStream +} + +private[sbt] object Prompt { + private[sbt] case class AskUser(override val mkPrompt: () => String) extends Prompt { + private[this] val bytes = new LinkedBlockingQueue[Int] + override def wrappedOutputStream(terminal: Terminal): OutputStream = new OutputStream { + override def write(b: Int): Unit = { + if (b == 10) bytes.clear() + else bytes.put(b) + terminal.withPrintStream { p => + p.write(b) + p.flush() + } + } + override def flush(): Unit = terminal.withPrintStream(_.flush()) + } + + override def render(): String = + EscHelpers.stripMoves(new String(bytes.asScala.toArray.map(_.toByte))) + } + private[sbt] trait NoPrompt extends Prompt { + override val mkPrompt: () => String = () => "" + override def render(): String = "" + override def wrappedOutputStream(terminal: Terminal): OutputStream = terminal.outputStream + } + private[sbt] case object Running extends NoPrompt + private[sbt] case object Batch extends NoPrompt + private[sbt] case object Watch extends NoPrompt +} diff --git a/internal/util-logging/src/main/scala/sbt/internal/util/Terminal.scala b/internal/util-logging/src/main/scala/sbt/internal/util/Terminal.scala index 680ad1956..1796e75b9 100644 --- a/internal/util-logging/src/main/scala/sbt/internal/util/Terminal.scala +++ b/internal/util-logging/src/main/scala/sbt/internal/util/Terminal.scala @@ -10,17 +10,17 @@ package sbt.internal.util import java.io.{ InputStream, OutputStream, PrintStream } import java.nio.channels.ClosedChannelException import java.util.Locale -import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.atomic.{ AtomicBoolean, AtomicReference } -import java.util.concurrent.locks.ReentrantLock +import java.util.concurrent.{ ArrayBlockingQueue, CountDownLatch, Executors, LinkedBlockingQueue } +import jline.DefaultTerminal2 import jline.console.ConsoleReader +import sbt.internal.util.ConsoleAppender.{ ClearScreenAfterCursor, CursorLeft1000 } import scala.annotation.tailrec import scala.collection.mutable.ArrayBuffer -import scala.util.control.NonFatal -object Terminal { +trait Terminal extends AutoCloseable { /** * Gets the current width of the terminal. The implementation reads a property from the jline @@ -29,7 +29,7 @@ object Terminal { * * @return the terminal width. */ - def getWidth: Int = terminal.getWidth + def getWidth: Int /** * Gets the current height of the terminal. The implementation reads a property from the jline @@ -38,7 +38,7 @@ object Terminal { * * @return the terminal height. */ - def getHeight: Int = terminal.getHeight + def getHeight: Int /** * Returns the height and width of the current line that is displayed on the terminal. If the @@ -46,14 +46,83 @@ object Terminal { * * @return the (height, width) pair */ - def getLineHeightAndWidth: (Int, Int) = currentLine.get.toArray match { - case bytes if bytes.isEmpty => (0, 0) - case bytes => - val width = getWidth - val line = EscHelpers.removeEscapeSequences(new String(bytes)) - val count = lineCount(line) - (count, line.length - ((count - 1) * width)) - } + def getLineHeightAndWidth(line: String): (Int, Int) + + /** + * + */ + /** + * Gets the input stream for this Terminal. This could be a wrapper around System.in for the + * process or it could be a remote input stream for a network channel. + * @return the input stream. + */ + def inputStream: InputStream + + /** + * Gets the input stream for this Terminal. This could be a wrapper around System.in for the + * process or it could be a remote input stream for a network channel. + * @return the input stream. + */ + def outputStream: OutputStream + + /** + * Returns true if the terminal supports ansi characters. + * + * @return true if the terminal supports ansi escape codes. + */ + def isAnsiSupported: Boolean + + /** + * Returns true if color is enabled for this terminal. + * + * @return true if color is enabled for this terminal. + */ + def isColorEnabled: Boolean + + /** + * Returns true if the terminal has echo enabled. + * + * @return true if the terminal has echo enabled. + */ + def isEchoEnabled: Boolean + + /** + * Returns true if the terminal has success enabled, which it may not if it is for batch + * commands because the client will print the success results when received from the + * server. + * + * @return true if the terminal has success enabled + */ + def isSuccessEnabled: Boolean + + /** + * Returns true if the terminal has supershell enabled. + * + * @return true if the terminal has supershell enabled. + */ + def isSupershellEnabled: Boolean + + /** + * Returns the last line written to the terminal's output stream. + * @return the last line + */ + def getLastLine: Option[String] + + def getBooleanCapability(capability: String): Boolean + def getNumericCapability(capability: String): Int + def getStringCapability(capability: String): String + + private[sbt] def name: String + private[sbt] def withRawSystemIn[T](f: => T): T = f + private[sbt] def withCanonicalIn[T](f: => T): T = f + private[sbt] def write(bytes: Int*): Unit + private[sbt] def printStream: PrintStream + private[sbt] def withPrintStream[T](f: PrintStream => T): T + private[sbt] def restore(): Unit = {} + private[sbt] val progressState = new ProgressState(1) + private[this] val promptHolder: AtomicReference[Prompt] = new AtomicReference(Prompt.Running) + private[sbt] final def prompt: Prompt = promptHolder.get + private[sbt] final def setPrompt(newPrompt: Prompt): Unit = promptHolder.set(newPrompt) /** * Returns the number of lines that the input string will cover given the current width of the @@ -62,9 +131,9 @@ object Terminal { * @param line the input line * @return the number of lines that the line will cover on the terminal */ - def lineCount(line: String): Int = { + private[sbt] def lineCount(line: String): Int = { + val lines = EscHelpers.stripColorsAndMoves(line).split('\n') val width = getWidth - val lines = EscHelpers.removeEscapeSequences(line).split('\n') def count(l: String): Int = { val len = l.length if (width > 0 && len > 0) (len - 1 + width) / width else 0 @@ -72,14 +141,58 @@ object Terminal { lines.tail.foldLeft(lines.headOption.fold(0)(count))(_ + count(_)) } - /** - * Returns true if the current terminal supports ansi characters. - * - * @return true if the current terminal supports ansi escape codes. - */ - def isAnsiSupported: Boolean = - try terminal.isAnsiSupported - catch { case NonFatal(_) => !isWindows } +} + +object Terminal { + def consoleLog(string: String): Unit = { + Terminal.console.printStream.println(s"[info] $string") + } + private[sbt] def set(terminal: Terminal) = { + currentTerminal.set(terminal) + jline.TerminalFactory.set(terminal.toJLine) + } + implicit class TerminalOps(private val term: Terminal) extends AnyVal { + def ansi(richString: => String, string: => String): String = + if (term.isAnsiSupported) richString else string + private[sbt] def toJLine: jline.Terminal with jline.Terminal2 = term match { + case t: ConsoleTerminal => t.term + case _ => + new jline.Terminal with jline.Terminal2 { + override def init(): Unit = {} + override def restore(): Unit = {} + override def reset(): Unit = {} + override def isSupported: Boolean = true + override def getWidth: Int = term.getWidth + override def getHeight: Int = term.getHeight + override def isAnsiSupported: Boolean = term.isAnsiSupported + override def wrapOutIfNeeded(out: OutputStream): OutputStream = out + override def wrapInIfNeeded(in: InputStream): InputStream = in + override def hasWeirdWrap: Boolean = false + override def isEchoEnabled: Boolean = term.isEchoEnabled + override def setEchoEnabled(enabled: Boolean): Unit = {} + override def disableInterruptCharacter(): Unit = {} + override def enableInterruptCharacter(): Unit = {} + override def getOutputEncoding: String = null + override def getBooleanCapability(capability: String): Boolean = { + term.getBooleanCapability(capability) + } + override def getNumericCapability(capability: String): Integer = { + term.getNumericCapability(capability) + } + override def getStringCapability(capability: String): String = { + term.getStringCapability(capability) + } + } + } + } + + def close(): Unit = { + if (System.console == null) { + originalOut.close() + originalIn.close() + System.err.close() + } + } /** * Returns true if System.in is attached. When sbt is run as a subprocess, like in scripted or @@ -90,15 +203,21 @@ object Terminal { */ def systemInIsAttached: Boolean = attached.get + def read: Int = inputStream.get match { + case null => -1 + case is => is.read + } + /** * Returns an InputStream that will throw a [[ClosedChannelException]] if read returns -1. * @return the wrapped InputStream. */ - private[sbt] def throwOnClosedSystemIn: InputStream = new InputStream { - override def available(): Int = WrappedSystemIn.available() - override def read(): Int = WrappedSystemIn.read() match { - case -1 => throw new ClosedChannelException - case r => r + private[sbt] def throwOnClosedSystemIn(in: InputStream): InputStream = new InputStream { + override def available(): Int = in.available() + override def read(): Int = in.read() match { + case -1 => throw new ClosedChannelException + case r if r >= 0 => r + case _ => -1 } } @@ -114,27 +233,7 @@ object Terminal { /** * Restore the terminal to its initial state. */ - private[sbt] def restore(): Unit = terminal.restore() - - /** - * Runs a thunk ensuring that the terminal has echo enabled. Most of the time sbt should have - * echo mode on except when it is explicitly set to raw mode via [[withRawSystemIn]]. - * - * @param f the thunk to run - * @tparam T the result type of the thunk - * @return the result of the thunk - */ - private[sbt] def withEcho[T](toggle: Boolean)(f: => T): T = { - val previous = terminal.isEchoEnabled - terminalLock.lockInterruptibly() - try { - terminal.setEchoEnabled(toggle) - f - } finally { - terminal.setEchoEnabled(previous) - terminalLock.unlock() - } - } + private[sbt] def restore(): Unit = console.toJLine.restore() /** * @@ -147,142 +246,193 @@ object Terminal { withOut(withIn(f)) } else f - /** - * Runs a thunk ensuring that the terminal is in canonical mode: - * [[https://www.gnu.org/software/libc/manual/html_node/Canonical-or-Not.html Canonical or Not]]. - * Most of the time sbt should be in canonical mode except when it is explicitly set to raw mode - * via [[withRawSystemIn]]. - * - * @param f the thunk to run - * @tparam T the result type of the thunk - * @return the result of the thunk - */ - private[sbt] def withCanonicalIn[T](f: => T): T = withTerminal { t => - t.restore() - f + private[this] object ProxyTerminal extends Terminal { + private def t: Terminal = currentTerminal.get + override def getWidth: Int = t.getWidth + override def getHeight: Int = t.getHeight + override def getLineHeightAndWidth(line: String): (Int, Int) = t.getLineHeightAndWidth(line) + override def lineCount(line: String): Int = t.lineCount(line) + override def inputStream: InputStream = t.inputStream + override def outputStream: OutputStream = t.outputStream + override def isAnsiSupported: Boolean = t.isAnsiSupported + override def isColorEnabled: Boolean = t.isColorEnabled + override def isEchoEnabled: Boolean = t.isEchoEnabled + override def isSuccessEnabled: Boolean = t.isSuccessEnabled + override def isSupershellEnabled: Boolean = t.isSupershellEnabled + override def getBooleanCapability(capability: String): Boolean = + t.getBooleanCapability(capability) + override def getNumericCapability(capability: String): Int = t.getNumericCapability(capability) + override def getStringCapability(capability: String): String = t.getStringCapability(capability) + override def withRawSystemIn[T](f: => T): T = t.withRawSystemIn(f) + override def withCanonicalIn[T](f: => T): T = t.withCanonicalIn(f) + override def printStream: PrintStream = t.printStream + override def withPrintStream[T](f: PrintStream => T): T = t.withPrintStream(f) + override def restore(): Unit = t.restore() + override def close(): Unit = {} + override private[sbt] def write(bytes: Int*): Unit = t.write(bytes: _*) + override def getLastLine: Option[String] = t.getLastLine + override private[sbt] def name: String = t.name + } + private[sbt] def get: Terminal = ProxyTerminal + + private[sbt] def withIn[T](in: InputStream)(f: => T): T = { + val original = inputStream.get + try { + inputStream.set(in) + System.setIn(in) + scala.Console.withIn(in)(f) + } finally { + inputStream.set(original) + System.setIn(original) + } } - /** - * Runs a thunk ensuring that the terminal is in in non-canonical mode: - * [[https://www.gnu.org/software/libc/manual/html_node/Canonical-or-Not.html Canonical or Not]]. - * This should be used when sbt is reading user input, e.g. in `shell` or a continuous build. - * @param f the thunk to run - * @tparam T the result type of the thunk - * @return the result of the thunk - */ - private[sbt] def withRawSystemIn[T](f: => T): T = withTerminal { t => - t.init() - f - } - - private[this] def withTerminal[T](f: jline.Terminal => T): T = { - val t = terminal - terminalLock.lockInterruptibly() - try f(t) - finally { - t.restore() - terminalLock.unlock() + private[sbt] def withOut[T](out: PrintStream)(f: => T): T = { + val originalOut = System.out + val originalProxyOut = ConsoleOut.getGlobalProxy + try { + ConsoleOut.setGlobalProxy(ConsoleOut.printStreamOut(out)) + System.setOut(out) + scala.Console.withOut(out)(f) + } finally { + ConsoleOut.setGlobalProxy(originalProxyOut) + System.setOut(originalOut) } } private[this] val originalOut = System.out private[this] val originalIn = System.in - private[this] val currentLine = new AtomicReference(new ArrayBuffer[Byte]) - private[this] val lineBuffer = new LinkedBlockingQueue[Byte] - private[this] val flushQueue = new LinkedBlockingQueue[Unit] - private[this] val writeLock = new AnyRef - private[this] final class WriteThread extends Thread("sbt-stdout-write-thread") { - setDaemon(true) - start() - private[this] val isStopped = new AtomicBoolean(false) - def close(): Unit = { - isStopped.set(true) - flushQueue.put(()) + private[this] class WriteableInputStream(in: InputStream, name: String) + extends InputStream + with AutoCloseable { + final def write(bytes: Int*): Unit = bytes.foreach(buffer.put) + private[this] val executor = + Executors.newSingleThreadExecutor(r => new Thread(r, s"sbt-$name-input-reader")) + private[this] val buffer = new LinkedBlockingQueue[Int] + private[this] val latch = new CountDownLatch(1) + private[this] val closed = new AtomicBoolean(false) + private[this] def takeOne: Int = if (closed.get) -1 else buffer.take + private[this] val runnable: Runnable = () => { + @tailrec def impl(): Unit = { + val b = in.read + buffer.put(b) + if (b != -1) impl() + else closed.set(true) + } + try { + latch.await() + impl() + } catch { case _: InterruptedException => } + } + executor.submit(runnable) + override def read(): Int = { + latch.countDown() + takeOne match { + case -1 => throw new ClosedChannelException + case b => b + } + } + + override def available(): Int = { + latch.countDown() + buffer.size + } + override def close(): Unit = { + executor.shutdownNow() () } - @tailrec override def run(): Unit = { - try { - flushQueue.take() - val bytes = new java.util.ArrayList[Byte] - writeLock.synchronized { - lineBuffer.drainTo(bytes) - import scala.collection.JavaConverters._ - val remaining = bytes.asScala.foldLeft(new ArrayBuffer[Byte]) { (buf, i) => - if (i == 10) { - ProgressState.addBytes(buf) - ProgressState.clearBytes() - buf.foreach(b => originalOut.write(b & 0xFF)) - ProgressState.reprint(originalOut) - currentLine.set(new ArrayBuffer[Byte]) - new ArrayBuffer[Byte] - } else buf += i - } - if (remaining.nonEmpty) { - currentLine.get ++= remaining - originalOut.write(remaining.toArray) - } - originalOut.flush() - } - } catch { case _: InterruptedException => isStopped.set(true) } - if (!isStopped.get) run() - } } + private[this] val nonBlockingIn: WriteableInputStream = + new WriteableInputStream(jline.TerminalFactory.get.wrapInIfNeeded(originalIn), "console") + private[this] val inputStream = new AtomicReference[InputStream](System.in) private[this] def withOut[T](f: => T): T = { - val thread = new WriteThread try { - System.setOut(SystemPrintStream) - scala.Console.withOut(SystemPrintStream)(f) + System.setOut(proxyPrintStream) + scala.Console.withOut(proxyOutputStream)(f) } finally { - thread.close() System.setOut(originalOut) } } private[this] def withIn[T](f: => T): T = try { - System.setIn(Terminal.wrappedSystemIn) - scala.Console.withIn(Terminal.wrappedSystemIn)(f) + inputStream.set(Terminal.wrappedSystemIn) + System.setIn(wrappedSystemIn) + scala.Console.withIn(proxyInputStream)(f) } finally System.setIn(originalIn) - private[sbt] def withPrintStream[T](f: PrintStream => T): T = writeLock.synchronized { - f(originalOut) + private[sbt] def withPrintStream[T](f: PrintStream => T): T = console.withPrintStream(f) + private[this] val attached = new AtomicBoolean(true) + private[this] val terminalHolder = new AtomicReference(wrap(jline.TerminalFactory.get)) + private[this] val currentTerminal = new AtomicReference[Terminal](terminalHolder.get) + jline.TerminalFactory.set(terminalHolder.get.toJLine) + private[this] object proxyInputStream extends InputStream { + def read(): Int = currentTerminal.get().inputStream.read() } - private object SystemOutputStream extends OutputStream { - override def write(b: Int): Unit = writeLock.synchronized(lineBuffer.put(b.toByte)) - override def write(b: Array[Byte]): Unit = writeLock.synchronized(b.foreach(lineBuffer.put)) - override def write(b: Array[Byte], off: Int, len: Int): Unit = writeLock.synchronized { - val lo = math.max(0, off) - val hi = math.min(math.max(off + len, 0), b.length) - (lo until hi).foreach(i => lineBuffer.put(b(i))) + private[this] object proxyOutputStream extends OutputStream { + private[this] def os = currentTerminal.get().outputStream + def write(byte: Int): Unit = { + os.write(byte) + os.flush() + if (byte == 10) os.flush() } - def write(s: String): Unit = s.getBytes.foreach(lineBuffer.put) - override def flush(): Unit = writeLock.synchronized(flushQueue.put(())) + override def write(bytes: Array[Byte]): Unit = write(bytes, 0, bytes.length) + override def write(bytes: Array[Byte], offset: Int, len: Int): Unit = { + os.write(bytes, offset, len) + os.flush() + } + override def flush(): Unit = os.flush() } - private object SystemPrintStream extends PrintStream(SystemOutputStream, true) + private[this] val proxyPrintStream = new PrintStream(proxyOutputStream, true) { + override def toString: String = s"proxyPrintStream($proxyOutputStream)" + override def println(s: String): Unit = { + proxyOutputStream.write(s"$s\n".getBytes("UTF-8")) + proxyOutputStream.flush() + } + } + private[this] lazy val isWindows = + System.getProperty("os.name", "").toLowerCase(Locale.ENGLISH).indexOf("windows") >= 0 private[this] object WrappedSystemIn extends InputStream { - private[this] val in = terminal.wrapInIfNeeded(System.in) - override def available(): Int = if (attached.get) in.available else 0 + private[this] val in = proxyInputStream + override def available(): Int = if (attached.get) in.available() else 0 override def read(): Int = synchronized { if (attached.get) { - val res = in.read + val res = in.read() if (res == -1) attached.set(false) res } else -1 } } - private[this] val terminalLock = new ReentrantLock() - private[this] val attached = new AtomicBoolean(true) - private[this] val terminalHolder = new AtomicReference(wrap(jline.TerminalFactory.get)) - private[this] lazy val isWindows = - System.getProperty("os.name", "").toLowerCase(Locale.ENGLISH).indexOf("windows") >= 0 - - private[this] def wrap(terminal: jline.Terminal): jline.Terminal = { - val term: jline.Terminal = new jline.Terminal { + private[this] def wrap(terminal: jline.Terminal): Terminal = { + val term: jline.Terminal with jline.Terminal2 = new jline.Terminal with jline.Terminal2 { + /* + * JLine spams the log with stacktraces if we directly interrupt the thread that is shelling + * out to run an stty command. To avoid this, run certain commands on a background thread. + */ + private[this] def doInBackground[T](f: => T): Unit = { + val result = new ArrayBlockingQueue[Either[Throwable, Any]](1) + new Thread("sbt-terminal-background-work-thread") { + setDaemon(true) + start() + override def run(): Unit = { + try result.put(Right(f)) + catch { case t: Throwable => result.put(Left(t)) } + } + } + result.take match { + case Left(e) => throw e + case _ => + } + } private[this] val hasConsole = System.console != null private[this] def alive = hasConsole && attached.get - override def init(): Unit = if (alive) terminal.init() - override def restore(): Unit = if (alive) terminal.restore() - override def reset(): Unit = if (alive) terminal.reset() + private[this] val term2: jline.Terminal2 = terminal match { + case t: jline.Terminal2 => t + case _ => new DefaultTerminal2(terminal) + } + override def init(): Unit = if (alive) doInBackground(terminal.init()) + override def restore(): Unit = if (alive) doInBackground(terminal.restore()) + override def reset(): Unit = if (alive) doInBackground(terminal.reset()) override def isSupported: Boolean = terminal.isSupported override def getWidth: Int = terminal.getWidth override def getHeight: Int = terminal.getHeight @@ -291,22 +441,33 @@ object Terminal { override def wrapInIfNeeded(in: InputStream): InputStream = terminal.wrapInIfNeeded(in) override def hasWeirdWrap: Boolean = terminal.hasWeirdWrap override def isEchoEnabled: Boolean = terminal.isEchoEnabled - override def setEchoEnabled(enabled: Boolean): Unit = if (alive) { - terminal.setEchoEnabled(enabled) - } + + /* + * Do this on a background thread so that jline doesn't spam the logs if interrupted + */ + override def setEchoEnabled(enabled: Boolean): Unit = + if (alive) doInBackground(terminal.setEchoEnabled(enabled)) override def disableInterruptCharacter(): Unit = if (alive) terminal.disableInterruptCharacter() override def enableInterruptCharacter(): Unit = if (alive) terminal.enableInterruptCharacter() override def getOutputEncoding: String = terminal.getOutputEncoding + override def getBooleanCapability(capability: String): Boolean = + term2.getBooleanCapability(capability) + override def getNumericCapability(capability: String): Integer = + term2.getNumericCapability(capability) + override def getStringCapability(capability: String): String = { + term2.getStringCapability(capability) + } } term.restore() term.setEchoEnabled(true) - term + new ConsoleTerminal(term, nonBlockingIn, originalOut) } - private[util] def reset(): Unit = { + private[sbt] def reset(): Unit = { jline.TerminalFactory.reset() + console.close() terminalHolder.set(wrap(jline.TerminalFactory.get)) } @@ -329,14 +490,178 @@ object Terminal { } fixTerminalProperty() - private[sbt] def createReader(in: InputStream): ConsoleReader = - new ConsoleReader(in, System.out, terminal) + private[sbt] def createReader(term: Terminal, prompt: Prompt): ConsoleReader = { + new ConsoleReader(term.inputStream, prompt.wrappedOutputStream(term), term.toJLine) { + override def readLine(prompt: String, mask: Character): String = + term.withRawSystemIn(super.readLine(prompt, mask)) + override def readLine(prompt: String): String = term.withRawSystemIn(super.readLine(prompt)) + } + } - private[this] def terminal: jline.Terminal = terminalHolder.get match { + private[sbt] def console: Terminal = terminalHolder.get match { case null => throw new IllegalStateException("Uninitialized terminal.") case term => term } @deprecated("For compatibility only", "1.4.0") - private[sbt] def deprecatedTeminal: jline.Terminal = terminal + private[sbt] def deprecatedTeminal: jline.Terminal = console.toJLine + private class ConsoleTerminal( + val term: jline.Terminal with jline.Terminal2, + in: InputStream, + out: OutputStream + ) extends TerminalImpl(in, out, "console0") { + private[this] def isCI = sys.env.contains("BUILD_NUMBER") || sys.env.contains("CI") + override def getWidth: Int = term.getWidth + override def getHeight: Int = term.getHeight + override def isAnsiSupported: Boolean = term.isAnsiSupported && !isCI + override def isEchoEnabled: Boolean = term.isEchoEnabled + override def isSuccessEnabled: Boolean = true + override def getBooleanCapability(capability: String): Boolean = + term.getBooleanCapability(capability) + override def getNumericCapability(capability: String): Int = + term.getNumericCapability(capability) + override def getStringCapability(capability: String): String = + term.getStringCapability(capability) + override private[sbt] def restore(): Unit = term.restore() + + override def withRawSystemIn[T](f: => T): T = term.synchronized { + try { + term.init() + term.setEchoEnabled(false) + f + } finally { + term.restore() + term.setEchoEnabled(true) + } + } + override def isColorEnabled: Boolean = ConsoleAppender.formatEnabledInEnv + + override def isSupershellEnabled: Boolean = System.getProperty("sbt.supershell") match { + case null => !(sys.env.contains("BUILD_NUMBER") || sys.env.contains("CI")) && isColorEnabled + case "true" => true + case _ => false + } + } + private[sbt] abstract class TerminalImpl private[sbt] ( + val in: InputStream, + val out: OutputStream, + override private[sbt] val name: String + ) extends Terminal { + private[this] val directWrite = new AtomicBoolean(false) + private[this] val currentLine = new AtomicReference(new ArrayBuffer[Byte]) + private[this] val lineBuffer = new LinkedBlockingQueue[Byte] + private[this] val flushQueue = new LinkedBlockingQueue[Seq[Byte]] + private[this] val writeLock = new AnyRef + private[this] val writeableInputStream = in match { + case w: WriteableInputStream => w + case _ => new WriteableInputStream(in, name) + } + def throwIfClosed[R](f: => R): R = if (isStopped.get) throw new ClosedChannelException else f + + override val outputStream = new OutputStream { + override def write(b: Int): Unit = throwIfClosed { + writeLock.synchronized { + if (b == Int.MinValue) currentLine.set(new ArrayBuffer[Byte]) + else doWrite(Vector((b & 0xFF).toByte)) + if (b == 10) out.flush() + } + } + override def write(b: Array[Byte]): Unit = throwIfClosed(write(b, 0, b.length)) + override def write(b: Array[Byte], off: Int, len: Int): Unit = { + throwIfClosed { + writeLock.synchronized { + val lo = math.max(0, off) + val hi = math.min(math.max(off + len, 0), b.length) + doWrite(b.slice(off, off + len).toSeq) + } + } + } + private[this] val clear = s"$CursorLeft1000$ClearScreenAfterCursor" + private def doWrite(bytes: Seq[Byte]): Unit = { + def doWrite(b: Byte): Unit = out.write(b & 0xFF) + val remaining = bytes.foldLeft(new ArrayBuffer[Byte]) { (buf, i) => + if (i == 10) { + progressState.addBytes(TerminalImpl.this, buf) + progressState.clearBytes() + val cl = currentLine.get + if (buf.nonEmpty && isAnsiSupported && cl.isEmpty) clear.getBytes.foreach(doWrite) + out.write(buf.toArray) + out.write(10) + currentLine.get match { + case s if s.nonEmpty => currentLine.set(new ArrayBuffer[Byte]) + case _ => + } + progressState.reprint(TerminalImpl.this, rawPrintStream) + new ArrayBuffer[Byte] + } else buf += i + } + if (remaining.nonEmpty) { + val cl = currentLine.get + if (isAnsiSupported && cl.isEmpty) { + clear.getBytes.foreach(doWrite) + } + cl ++= remaining + out.write(remaining.toArray) + } + out.flush() + } + } + override private[sbt] val printStream: PrintStream = new PrintStream(outputStream, true) + override def inputStream: InputStream = writeableInputStream + + private[sbt] def write(bytes: Int*): Unit = writeableInputStream.write(bytes: _*) + private[this] val isStopped = new AtomicBoolean(false) + + override def getLineHeightAndWidth(line: String): (Int, Int) = getWidth match { + case width if width > 0 => + val position = EscHelpers.cursorPosition(line) + val count = (position + width - 1) / width + (count, position - (math.max((count - 1), 0) * width)) + case _ => (0, 0) + } + + override def getLastLine: Option[String] = currentLine.get match { + case bytes if bytes.isEmpty => None + case bytes => + // TODO there are ghost characters when the user deletes prompt characters + // when they are given the cancellation option + Some(new String(bytes.toArray).replaceAllLiterally(ClearScreenAfterCursor, "")) + } + + private[this] val rawPrintStream: PrintStream = new PrintStream(out, true) { + override def close(): Unit = {} + } + override def withPrintStream[T](f: PrintStream => T): T = + writeLock.synchronized(f(rawPrintStream)) + + override def close(): Unit = if (isStopped.compareAndSet(false, true)) { + writeableInputStream.close() + } + } + private[sbt] val NullTerminal = new Terminal { + override def close(): Unit = {} + override def getBooleanCapability(capability: String): Boolean = false + override def getHeight: Int = 0 + override def getLastLine: Option[String] = None + override def getLineHeightAndWidth(line: String): (Int, Int) = (0, 0) + override def getNumericCapability(capability: String): Int = -1 + override def getStringCapability(capability: String): String = null + override def getWidth: Int = 0 + override def inputStream: java.io.InputStream = () => { + try this.synchronized(this.wait) + catch { case _: InterruptedException => } + -1 + } + override def isAnsiSupported: Boolean = false + override def isColorEnabled: Boolean = false + override def isEchoEnabled: Boolean = false + override def isSuccessEnabled: Boolean = false + override def isSupershellEnabled: Boolean = false + override def outputStream: java.io.OutputStream = _ => {} + override private[sbt] def name: String = "NullTerminal" + override private[sbt] val printStream: java.io.PrintStream = + new PrintStream(outputStream, false) + override private[sbt] def withPrintStream[T](f: java.io.PrintStream => T): T = f(printStream) + override private[sbt] def write(bytes: Int*): Unit = {} + } } diff --git a/main-actions/src/main/scala/sbt/Console.scala b/main-actions/src/main/scala/sbt/Console.scala index f05a599e0..76332f3e0 100644 --- a/main-actions/src/main/scala/sbt/Console.scala +++ b/main-actions/src/main/scala/sbt/Console.scala @@ -8,6 +8,7 @@ package sbt import java.io.File +import java.nio.channels.ClosedChannelException import sbt.internal.inc.{ AnalyzingCompiler, PlainVirtualFile } import sbt.internal.util.Terminal import sbt.util.Logger @@ -45,14 +46,30 @@ final class Console(compiler: AnalyzingCompiler) { initialCommands: String, cleanupCommands: String )(loader: Option[ClassLoader], bindings: Seq[(String, Any)])(implicit log: Logger): Try[Unit] = { - def console0() = - compiler.console(classpath map { x => + apply(classpath, options, initialCommands, cleanupCommands, Terminal.get)(loader, bindings) + } + def apply( + classpath: Seq[File], + options: Seq[String], + initialCommands: String, + cleanupCommands: String, + terminal: Terminal + )(loader: Option[ClassLoader], bindings: Seq[(String, Any)])(implicit log: Logger): Try[Unit] = { + def console0(): Unit = + try compiler.console(classpath map { x => PlainVirtualFile(x.toPath) }, options, initialCommands, cleanupCommands, log)( loader, bindings ) - Terminal.withRawSystemIn(Run.executeTrapExit(console0, log)) + catch { case _: InterruptedException | _: ClosedChannelException => } + val previous = sys.props.get("scala.color").getOrElse("auto") + try { + sys.props("scala.color") = if (terminal.isColorEnabled) "true" else "false" + terminal.withRawSystemIn(Run.executeTrapExit(console0, log)) + } finally { + sys.props("scala.color") = previous + } } } diff --git a/main-command/src/main/scala/sbt/BasicCommands.scala b/main-command/src/main/scala/sbt/BasicCommands.scala index e6de5b0c1..d74b3568c 100644 --- a/main-command/src/main/scala/sbt/BasicCommands.scala +++ b/main-command/src/main/scala/sbt/BasicCommands.scala @@ -60,7 +60,7 @@ object BasicCommands { oldshell, client, read, - alias + alias, ) def nop: Command = Command.custom(s => success(() => s)) @@ -375,8 +375,7 @@ object BasicCommands { def oldshell: Command = Command.command(OldShell, Help.more(Shell, OldShellDetailed)) { s => val history = (s get historyPath) getOrElse (new File(s.baseDir, ".history")).some val prompt = (s get shellPrompt) match { case Some(pf) => pf(s); case None => "> " } - val reader = - new FullReader(history, s.combinedParser, LineReader.HandleCONT, Terminal.wrappedSystemIn) + val reader = new FullReader(history, s.combinedParser, LineReader.HandleCONT, Terminal.console) val line = reader.readLine(prompt) line match { case Some(line) => diff --git a/main-command/src/main/scala/sbt/internal/CommandChannel.scala b/main-command/src/main/scala/sbt/internal/CommandChannel.scala index dc9f97712..d8ccca5b7 100644 --- a/main-command/src/main/scala/sbt/internal/CommandChannel.scala +++ b/main-command/src/main/scala/sbt/internal/CommandChannel.scala @@ -10,6 +10,7 @@ package internal import java.util.concurrent.ConcurrentLinkedQueue +import sbt.internal.util.Terminal import sbt.protocol.EventMessage /** @@ -44,6 +45,7 @@ abstract class CommandChannel { def publishBytes(bytes: Array[Byte]): Unit def shutdown(): Unit def name: String + private[sbt] def terminal: Terminal } // case class Exec(commandLine: String, source: Option[CommandSource]) diff --git a/main-command/src/main/scala/sbt/internal/ConsoleChannel.scala b/main-command/src/main/scala/sbt/internal/ConsoleChannel.scala index c53440d34..1482b0094 100644 --- a/main-command/src/main/scala/sbt/internal/ConsoleChannel.scala +++ b/main-command/src/main/scala/sbt/internal/ConsoleChannel.scala @@ -31,7 +31,7 @@ private[sbt] final class ConsoleChannel(val name: String) extends CommandChannel history, s.combinedParser, LineReader.HandleCONT, - Terminal.throwOnClosedSystemIn + Terminal.console, ) setDaemon(true) start() @@ -49,7 +49,6 @@ private[sbt] final class ConsoleChannel(val name: String) extends CommandChannel } finally askUserThread.synchronized(askUserThread.set(null)) def redraw(): Unit = { System.out.print(ConsoleAppender.clearLine(0)) - reader.redraw() System.out.print(ConsoleAppender.ClearScreenAfterCursor) System.out.flush() } @@ -80,4 +79,5 @@ private[sbt] final class ConsoleChannel(val name: String) extends CommandChannel case _ => () } } + override private[sbt] def terminal = Terminal.console } diff --git a/main/src/main/scala/sbt/CommandLineUIService.scala b/main/src/main/scala/sbt/CommandLineUIService.scala index f2265a75f..be7fa6783 100644 --- a/main/src/main/scala/sbt/CommandLineUIService.scala +++ b/main/src/main/scala/sbt/CommandLineUIService.scala @@ -27,9 +27,9 @@ trait CommandLineUIService extends InteractionService { } } - override def terminalWidth: Int = Terminal.getWidth + override def terminalWidth: Int = Terminal.get.getWidth - override def terminalHeight: Int = Terminal.getHeight + override def terminalHeight: Int = Terminal.get.getHeight } object CommandLineUIService extends CommandLineUIService diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index cea43f794..5755451a5 100755 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -1501,8 +1501,8 @@ object Defaults extends BuildCommon { Some(s => { def print(st: String) = { scala.Console.out.print(st); scala.Console.out.flush() } print(s) - Terminal.withRawSystemIn { - Terminal.wrappedSystemIn.read match { + Terminal.get.withRawSystemIn { + Terminal.read match { case -1 => None case b => val res = b.toChar.toString diff --git a/main/src/main/scala/sbt/Main.scala b/main/src/main/scala/sbt/Main.scala index f796b16af..03a110754 100644 --- a/main/src/main/scala/sbt/Main.scala +++ b/main/src/main/scala/sbt/Main.scala @@ -9,6 +9,7 @@ package sbt import java.io.{ File, IOException } import java.net.URI +import java.nio.channels.ClosedChannelException import java.nio.file.{ FileAlreadyExistsException, FileSystems, Files } import java.util.Properties import java.util.concurrent.ForkJoinPool @@ -21,6 +22,7 @@ import sbt.internal.CommandStrings.BootCommand import sbt.internal._ import sbt.internal.client.BspClient import sbt.internal.inc.ScalaInstance +import sbt.internal.io.Retry import sbt.internal.nio.CheckBuildSources import sbt.internal.util.Types.{ const, idFun } import sbt.internal.util._ @@ -145,13 +147,17 @@ object StandardMain { /** The common interface to standard output, used for all built-in ConsoleLoggers. */ val console: ConsoleOut = ConsoleOut.systemOutOverwrite(ConsoleOut.overwriteContaining("Resolving ")) + ConsoleOut.setGlobalProxy(console) private[this] def initialGlobalLogging(file: Option[File]): GlobalLogging = { - file.foreach(f => if (!f.exists()) IO.createDirectory(f)) + def createTemp(attempt: Int = 0): File = Retry { + file.foreach(f => if (!f.exists()) IO.createDirectory(f)) + File.createTempFile("sbt-global-log", ".log", file.orNull) + } GlobalLogging.initial( - MainAppender.globalDefault(console), - File.createTempFile("sbt-global-log", ".log", file.orNull), - console + MainAppender.globalDefault(ConsoleOut.globalProxy), + createTemp(), + ConsoleOut.globalProxy ) } def initialGlobalLogging(file: File): GlobalLogging = initialGlobalLogging(Option(file)) @@ -770,12 +776,11 @@ object BuiltinCommands { @tailrec private[this] def doLoadFailed(s: State, loadArg: String): State = { s.log.warn("Project loading failed: (r)etry, (q)uit, (l)ast, or (i)gnore? (default: r)") - val result = Terminal.withRawSystemIn { - Terminal.withEcho(toggle = true)(Terminal.wrappedSystemIn.read() match { - case -1 => 'q'.toInt - case b => b - }) - } + val terminal = Terminal.get + val result = try terminal.withRawSystemIn(terminal.inputStream.read) match { + case -1 => 'q'.toInt + case b => b + } catch { case _: ClosedChannelException => 'q' } def retry: State = loadProjectCommand(LoadProject, loadArg) :: s.clearGlobalLog def ignoreMsg: String = if (Project.isProjectLoaded(s)) "using previously loaded project" else "no project loaded" diff --git a/main/src/main/scala/sbt/internal/CommandExchange.scala b/main/src/main/scala/sbt/internal/CommandExchange.scala index 96cb3844e..af2cb478a 100644 --- a/main/src/main/scala/sbt/internal/CommandExchange.scala +++ b/main/src/main/scala/sbt/internal/CommandExchange.scala @@ -309,6 +309,6 @@ private[sbt] final class CommandExchange { .withChannelName(currentExec.flatMap(_.source.map(_.channelName))) case _ => pe } - ProgressState.updateProgressState(newPE) + channels.foreach(c => ProgressState.updateProgressState(newPE, c.terminal)) } } diff --git a/main/src/main/scala/sbt/internal/ConsoleProject.scala b/main/src/main/scala/sbt/internal/ConsoleProject.scala index 5e8bd9928..82fe28e8e 100644 --- a/main/src/main/scala/sbt/internal/ConsoleProject.scala +++ b/main/src/main/scala/sbt/internal/ConsoleProject.scala @@ -61,7 +61,7 @@ object ConsoleProject { val importString = imports.mkString("", ";\n", ";\n\n") val initCommands = importString + extra - Terminal.withCanonicalIn { + Terminal.get.withCanonicalIn { // TODO - Hook up dsl classpath correctly... (new Console(compiler))( unit.classpath, diff --git a/main/src/main/scala/sbt/internal/Continuous.scala b/main/src/main/scala/sbt/internal/Continuous.scala index 0ed809de4..eac9e76ca 100644 --- a/main/src/main/scala/sbt/internal/Continuous.scala +++ b/main/src/main/scala/sbt/internal/Continuous.scala @@ -272,45 +272,46 @@ private[sbt] object Continuous extends DeprecatedContinuous { f(s, valid, invalid) } - private[this] def withCharBufferedStdIn[R](f: InputStream => R): R = Terminal.withRawSystemIn { - val wrapped = Terminal.wrappedSystemIn - if (Util.isNonCygwinWindows) { - val inputStream: InputStream with AutoCloseable = new InputStream with AutoCloseable { - private[this] val buffer = new java.util.LinkedList[Int] - private[this] val closed = new AtomicBoolean(false) - private[this] val thread = new Thread("Continuous-input-stream-reader") { - setDaemon(true) - start() - @tailrec - override def run(): Unit = { - try { - if (!closed.get()) { - wrapped.read() match { - case -1 => closed.set(true) - case b => buffer.add(b) + private[this] def withCharBufferedStdIn[R](f: InputStream => R): R = + Terminal.get.withRawSystemIn { + val wrapped = Terminal.get.inputStream + if (Util.isNonCygwinWindows) { + val inputStream: InputStream with AutoCloseable = new InputStream with AutoCloseable { + private[this] val buffer = new java.util.LinkedList[Int] + private[this] val closed = new AtomicBoolean(false) + private[this] val thread = new Thread("Continuous-input-stream-reader") { + setDaemon(true) + start() + @tailrec + override def run(): Unit = { + try { + if (!closed.get()) { + wrapped.read() match { + case -1 => closed.set(true) + case b => buffer.add(b) + } } + } catch { + case _: InterruptedException => closed.set(true) } - } catch { - case _: InterruptedException => closed.set(true) + if (!closed.get()) run() } - if (!closed.get()) run() + } + override def available(): Int = buffer.size() + override def read(): Int = buffer.poll() + override def close(): Unit = if (closed.compareAndSet(false, true)) { + thread.interrupt() } } - override def available(): Int = buffer.size() - override def read(): Int = buffer.poll() - override def close(): Unit = if (closed.compareAndSet(false, true)) { - thread.interrupt() + try { + f(inputStream) + } finally { + inputStream.close() } + } else { + f(wrapped) } - try { - f(inputStream) - } finally { - inputStream.close() - } - } else { - f(wrapped) } - } private[sbt] def runToTermination( state: State, diff --git a/main/src/main/scala/sbt/internal/SettingGraph.scala b/main/src/main/scala/sbt/internal/SettingGraph.scala index ae63dd713..be9f83f9d 100644 --- a/main/src/main/scala/sbt/internal/SettingGraph.scala +++ b/main/src/main/scala/sbt/internal/SettingGraph.scala @@ -82,7 +82,7 @@ object Graph { // [info] | // [info] +-quux def toAscii[A](top: A, children: A => Seq[A], display: A => String, defaultWidth: Int): String = { - val maxColumn = math.max(Terminal.getWidth, defaultWidth) - 8 + val maxColumn = math.max(Terminal.get.getWidth, defaultWidth) - 8 val twoSpaces = " " + " " // prevent accidentally being converted into a tab def limitLine(s: String): String = if (s.length > maxColumn) s.slice(0, maxColumn - 2) + ".." diff --git a/main/src/main/scala/sbt/internal/server/NetworkChannel.scala b/main/src/main/scala/sbt/internal/server/NetworkChannel.scala index 417b80689..c82476d92 100644 --- a/main/src/main/scala/sbt/internal/server/NetworkChannel.scala +++ b/main/src/main/scala/sbt/internal/server/NetworkChannel.scala @@ -20,7 +20,7 @@ import sbt.internal.protocol.{ JsonRpcResponseError, JsonRpcResponseMessage } -import sbt.internal.util.ReadJsonFromInputStream +import sbt.internal.util.{ ReadJsonFromInputStream, Terminal } import sbt.internal.util.complete.Parser import sbt.protocol._ import sbt.util.Logger @@ -47,6 +47,8 @@ final class NetworkChannel( private var initialized = false private val pendingRequests: mutable.Map[String, JsonRpcRequestMessage] = mutable.Map() + override private[sbt] def terminal: Terminal = Terminal.NullTerminal + private lazy val callback: ServerCallback = new ServerCallback { def jsonRpcRespond[A: JsonFormat](event: A, execId: Option[String]): Unit = self.respondResult(event, execId) diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 54e20445f..126caab7e 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -83,7 +83,8 @@ object Dependencies { val sjsonNewScalaJson = sjsonNew("sjson-new-scalajson") val sjsonNewMurmurhash = sjsonNew("sjson-new-murmurhash") - val jline = "jline" % "jline" % "2.14.6" + val jline = "org.scala-sbt.jline" % "jline" % "2.14.7-sbt-5e51b9d4f9631ebfa29753ce4accc57808e7fd6b" + val jansi = "org.fusesource.jansi" % "jansi" % "1.12" val scalatest = "org.scalatest" %% "scalatest" % "3.0.8" val scalacheck = "org.scalacheck" %% "scalacheck" % "1.14.0" val specs2 = "org.specs2" %% "specs2-junit" % "4.0.1"