From b85209be782210fa6d31971103d2f1eb20fa3039 Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Sun, 27 Sep 2020 11:50:04 -0700 Subject: [PATCH] Add sbt.Terminal trait It can be useful for plugin and build authors to have access to some of the virtual terminal properties. For instance, when writing a task that needs a password, the author may wish to put the terminal in raw mode with echo disabled. This commit introduces a new Terminal trait at the sbt level and a corresponding task, terminal, that provides a basic terminal api. The Terminal returned by the terminal task will correspond to the terminal that initiated the task so that it should work with sbtn as well as in console mode. --- .../main/scala/sbt/internal/util/JLine3.scala | 7 ++ .../main/scala/sbt/CommandLineUIService.scala | 10 +-- main/src/main/scala/sbt/Defaults.scala | 15 ++-- main/src/main/scala/sbt/Keys.scala | 2 + main/src/main/scala/sbt/Main.scala | 20 ++--- main/src/main/scala/sbt/MainLoop.scala | 22 +++--- main/src/main/scala/sbt/Terminal.scala | 73 +++++++++++++++++++ main/src/test/scala/PluginCommandTest.scala | 4 +- 8 files changed, 118 insertions(+), 35 deletions(-) create mode 100644 main/src/main/scala/sbt/Terminal.scala diff --git a/internal/util-logging/src/main/scala/sbt/internal/util/JLine3.scala b/internal/util-logging/src/main/scala/sbt/internal/util/JLine3.scala index 6f34a39a1..ccb3fad79 100644 --- a/internal/util-logging/src/main/scala/sbt/internal/util/JLine3.scala +++ b/internal/util-logging/src/main/scala/sbt/internal/util/JLine3.scala @@ -261,6 +261,13 @@ private[sbt] object JLine3 { LocalFlag.values.map(f => f.name.toLowerCase -> f).toMap private[this] val charMap: Map[String, Attributes.ControlChar] = Attributes.ControlChar.values().map(f => f.name.toLowerCase -> f).toMap + private[sbt] def setMode(term: Terminal, canonical: Boolean, echo: Boolean): Unit = { + val prev = attributesFromMap(term.getAttributes) + val newAttrs = new Attributes(prev) + newAttrs.setLocalFlag(LocalFlag.ICANON, canonical) + newAttrs.setLocalFlag(LocalFlag.ECHO, echo) + term.setAttributes(toMap(newAttrs)) + } private[util] def attributesFromMap(map: Map[String, String]): Attributes = { val attributes = new Attributes map.get("iflag").foreach { flags => diff --git a/main/src/main/scala/sbt/CommandLineUIService.scala b/main/src/main/scala/sbt/CommandLineUIService.scala index 319021645..39c3db302 100644 --- a/main/src/main/scala/sbt/CommandLineUIService.scala +++ b/main/src/main/scala/sbt/CommandLineUIService.scala @@ -7,12 +7,12 @@ package sbt -import sbt.internal.util.{ SimpleReader, Terminal } +import sbt.internal.util.{ SimpleReader, Terminal => ITerminal } trait CommandLineUIService extends InteractionService { override def readLine(prompt: String, mask: Boolean): Option[String] = { val maskChar = if (mask) Some('*') else None - SimpleReader(Terminal.get).readLine(prompt, maskChar) + SimpleReader(ITerminal.get).readLine(prompt, maskChar) } // TODO - Implement this better. override def confirm(msg: String): Boolean = { @@ -21,15 +21,15 @@ trait CommandLineUIService extends InteractionService { (in == "y" || in == "yes") } } - SimpleReader(Terminal.get).readLine(msg + " (yes/no): ", None) match { + SimpleReader(ITerminal.get).readLine(msg + " (yes/no): ", None) match { case Some(Assent()) => true case _ => false } } - override def terminalWidth: Int = Terminal.get.getWidth + override def terminalWidth: Int = ITerminal.get.getWidth - override def terminalHeight: Int = Terminal.get.getHeight + override def terminalHeight: Int = ITerminal.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 1fc41c277..28fa89f30 100644 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -57,7 +57,7 @@ import sbt.internal.server.{ import sbt.internal.testing.TestLogger import sbt.internal.util.Attributed.data import sbt.internal.util.Types._ -import sbt.internal.util._ +import sbt.internal.util.{ Terminal => ITerminal, _ } import sbt.internal.util.complete._ import sbt.io.Path._ import sbt.io._ @@ -333,7 +333,7 @@ object Defaults extends BuildCommon { if (!useScalaReplJLine.value) classOf[org.jline.terminal.Terminal].getClassLoader else appConfiguration.value.provider.scalaProvider.launcher.topLoader.getParent }, - useSuperShell := { if (insideCI.value) false else Terminal.console.isSupershellEnabled }, + useSuperShell := { if (insideCI.value) false else ITerminal.console.isSupershellEnabled }, superShellThreshold :== SysProp.supershellThreshold, superShellMaxTasks :== SysProp.supershellMaxTasks, superShellSleep :== SysProp.supershellSleep.millis, @@ -388,6 +388,7 @@ object Defaults extends BuildCommon { pollInterval :== Watch.defaultPollInterval, canonicalInput :== true, echoInput :== true, + terminal := state.value.get(terminalKey).getOrElse(Terminal(ITerminal.get)), ) ++ LintUnused.lintSettings ++ DefaultBackgroundJobService.backgroundJobServiceSettings ++ RemoteCache.globalSettings @@ -1663,13 +1664,13 @@ object Defaults extends BuildCommon { def askForMainClass(classes: Seq[String]): Option[String] = sbt.SelectMainClass( - if (classes.length >= 10) Some(SimpleReader(Terminal.get).readLine(_)) + if (classes.length >= 10) Some(SimpleReader(ITerminal.get).readLine(_)) else Some(s => { def print(st: String) = { scala.Console.out.print(st); scala.Console.out.flush() } print(s) - Terminal.get.withRawInput { - try Terminal.get.inputStream.read match { + ITerminal.get.withRawInput { + try ITerminal.get.inputStream.read match { case -1 | -2 => None case b => val res = b.toChar.toString @@ -1705,7 +1706,7 @@ object Defaults extends BuildCommon { private[this] def termWrapper(canonical: Boolean, echo: Boolean): (() => Unit) => (() => Unit) = (f: () => Unit) => () => { - val term = Terminal.get + val term = ITerminal.get if (!canonical) { term.enterRawMode() if (echo) term.setEchoEnabled(echo) @@ -3952,7 +3953,7 @@ object Classpaths { } } - def shellPromptFromState: State => String = shellPromptFromState(Terminal.console.isColorEnabled) + def shellPromptFromState: State => String = shellPromptFromState(ITerminal.console.isColorEnabled) def shellPromptFromState(isColorEnabled: Boolean): State => String = { s: State => val extracted = Project.extract(s) (name in extracted.currentRef).get(extracted.structure.data) match { diff --git a/main/src/main/scala/sbt/Keys.scala b/main/src/main/scala/sbt/Keys.scala index 2e25ea715..f5ea65228 100644 --- a/main/src/main/scala/sbt/Keys.scala +++ b/main/src/main/scala/sbt/Keys.scala @@ -70,6 +70,8 @@ object Keys { val serverLog = taskKey[Unit]("A dummy task to set server log level using Global / serverLog / logLevel.").withRank(CTask) val canonicalInput = settingKey[Boolean]("Toggles whether a task should use canonical input (line buffered with echo) or raw input").withRank(DSetting) val echoInput = settingKey[Boolean]("Toggles whether a task should echo user input").withRank(DSetting) + val terminal = taskKey[Terminal]("The Terminal associated with a task").withRank(DTask) + private[sbt] val terminalKey = AttributeKey[Terminal]("terminal-key", Invisible) // Project keys val autoGeneratedProject = settingKey[Boolean]("If it exists, represents that the project (and name) were automatically created, rather than user specified.").withRank(DSetting) diff --git a/main/src/main/scala/sbt/Main.scala b/main/src/main/scala/sbt/Main.scala index 3e08c60a4..4207d7fd0 100644 --- a/main/src/main/scala/sbt/Main.scala +++ b/main/src/main/scala/sbt/Main.scala @@ -26,7 +26,7 @@ import sbt.internal.inc.ScalaInstance import sbt.internal.nio.{ CheckBuildSources, FileTreeRepository } import sbt.internal.server.{ BuildServerProtocol, NetworkChannel } import sbt.internal.util.Types.{ const, idFun } -import sbt.internal.util._ +import sbt.internal.util.{ Terminal => ITerminal, _ } import sbt.internal.util.complete.{ Parser, SizeParser } import sbt.io._ import sbt.io.syntax._ @@ -78,8 +78,8 @@ private[sbt] object xMain { if (userCommands.exists(isBsp)) { BspClient.run(dealiasBaseDirectory(configuration)) } else { - bootServerSocket.foreach(l => Terminal.setBootStreams(l.inputStream, l.outputStream)) - Terminal.withStreams(true) { + bootServerSocket.foreach(l => ITerminal.setBootStreams(l.inputStream, l.outputStream)) + ITerminal.withStreams(true) { if (clientModByEnv || userCommands.exists(isClient)) { val args = userCommands.toList.filterNot(isClient) NetworkClient.run(dealiasBaseDirectory(configuration), args) @@ -105,7 +105,7 @@ private[sbt] object xMain { } finally { // Clear any stray progress lines ShutdownHooks.close() - if (Terminal.formatEnabledInEnv) { + if (ITerminal.formatEnabledInEnv) { System.out.print(ConsoleAppender.ClearScreenAfterCursor) System.out.flush() } @@ -118,9 +118,9 @@ private[sbt] object xMain { try (Some(new BootServerSocket(configuration)) -> None) catch { case _: ServerAlreadyBootingException - if System.console != null && !Terminal.startedByRemoteClient => + if System.console != null && !ITerminal.startedByRemoteClient => println("sbt server is already booting. Create a new server? y/n (default y)") - val exit = Terminal.get.withRawInput(System.in.read) match { + val exit = ITerminal.get.withRawInput(System.in.read) match { case 110 => Some(Exit(1)) case _ => None } @@ -841,7 +841,7 @@ 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 = try Terminal.get.withRawInput(System.in.read) match { + val result = try ITerminal.get.withRawInput(System.in.read) match { case -1 => 'q'.toInt case b => b } catch { case _: ClosedChannelException => 'q' } @@ -957,7 +957,7 @@ object BuiltinCommands { val threshold = extracted.getOpt(Keys.superShellThreshold).getOrElse(SysProp.supershellThreshold) val maxItems = extracted.getOpt(Keys.superShellMaxTasks).getOrElse(SysProp.supershellMaxTasks) - Terminal.setConsoleProgressState(new ProgressState(1, maxItems)) + ITerminal.setConsoleProgressState(new ProgressState(1, maxItems)) s.put(Keys.superShellSleep.key, sleep) .put(Keys.superShellThreshold.key, threshold) .put(Keys.superShellMaxTasks.key, maxItems) @@ -1032,10 +1032,10 @@ object BuiltinCommands { * by a remote client and only one is able to start a server. This seems to * happen primarily on windows. */ - if (Terminal.startedByRemoteClient && !exchange.hasServer) { + if (ITerminal.startedByRemoteClient && !exchange.hasServer) { Exec(Shutdown, None) +: s1 } else { - if (Terminal.console.prompt == Prompt.Batch) Terminal.console.setPrompt(Prompt.Pending) + if (ITerminal.console.prompt == Prompt.Batch) ITerminal.console.setPrompt(Prompt.Pending) exchange prompt ConsolePromptEvent(s0) val minGCInterval = Project .extract(s1) diff --git a/main/src/main/scala/sbt/MainLoop.scala b/main/src/main/scala/sbt/MainLoop.scala index 1fa2f686d..00205d251 100644 --- a/main/src/main/scala/sbt/MainLoop.scala +++ b/main/src/main/scala/sbt/MainLoop.scala @@ -15,7 +15,7 @@ import sbt.internal.ShutdownHooks import sbt.internal.langserver.ErrorCodes import sbt.internal.protocol.JsonRpcResponseError import sbt.internal.nio.CheckBuildSources.CheckBuildSourcesKey -import sbt.internal.util.{ ErrorHandling, GlobalLogBacking, Prompt, Terminal } +import sbt.internal.util.{ ErrorHandling, GlobalLogBacking, Prompt, Terminal => ITerminal } import sbt.internal.{ ShutdownHooks, TaskProgress } import sbt.io.{ IO, Using } import sbt.protocol._ @@ -35,7 +35,7 @@ object MainLoop { // We've disabled jline shutdown hooks to prevent classloader leaks, and have been careful to always restore // the jline terminal in finally blocks, but hitting ctrl+c prevents finally blocks from being executed, in that // case the only way to restore the terminal is in a shutdown hook. - val shutdownHook = ShutdownHooks.add(Terminal.restore) + val shutdownHook = ShutdownHooks.add(ITerminal.restore) try { runLoggedLoop(state, state.globalLogging.backing) @@ -219,19 +219,19 @@ object MainLoop { } exchange.setState(progressState) exchange.setExec(Some(exec)) - val restoreTerminal = channelName.flatMap(exchange.channelForName) match { + val (restoreTerminal, termState) = channelName.flatMap(exchange.channelForName) match { case Some(c) => - val prevTerminal = Terminal.set(c.terminal) + val prevTerminal = ITerminal.set(c.terminal) val prevPrompt = c.terminal.prompt // temporarily set the prompt to running during task evaluation c.terminal.setPrompt(Prompt.Running) - () => { + (() => { c.terminal.setPrompt(prevPrompt) - Terminal.set(prevTerminal) + ITerminal.set(prevTerminal) c.terminal.setPrompt(prevPrompt) c.terminal.flush() - } - case _ => () => () + }) -> progressState.put(Keys.terminalKey, Terminal(c.terminal)) + case _ => (() => ()) -> progressState.put(Keys.terminalKey, Terminal(ITerminal.get)) } /* * FastTrackCommands.evaluate can be significantly faster than Command.process because @@ -241,8 +241,8 @@ object MainLoop { */ val newState = try { FastTrackCommands - .evaluate(progressState, exec.commandLine) - .getOrElse(Command.process(exec.commandLine, progressState)) + .evaluate(termState, exec.commandLine) + .getOrElse(Command.process(exec.commandLine, termState)) } finally { // Flush the terminal output after command evaluation to ensure that all output // is displayed in the thin client before we report the command status. Also @@ -262,7 +262,7 @@ object MainLoop { } exchange.setExec(None) newState.get(sbt.Keys.currentTaskProgress).foreach(_.progress.stop()) - newState.remove(sbt.Keys.currentTaskProgress) + newState.remove(sbt.Keys.currentTaskProgress).remove(Keys.terminalKey) } state.get(CheckBuildSourcesKey) match { case Some(cbs) => diff --git a/main/src/main/scala/sbt/Terminal.scala b/main/src/main/scala/sbt/Terminal.scala new file mode 100644 index 000000000..8d84cd167 --- /dev/null +++ b/main/src/main/scala/sbt/Terminal.scala @@ -0,0 +1,73 @@ +/* + * sbt + * Copyright 2011 - 2018, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt + +import java.io.{ InputStream, PrintStream } +import sbt.internal.util.{ JLine3, Terminal => ITerminal } + +/** + * A Terminal represents a ui connection to sbt. It may control the embedded console + * for an sbt server or it may control a remote client connected through sbtn. The + * Terminal is particularly useful whenever an sbt task needs to receive input from + * the user. + * + */ +trait Terminal { + + /** + * Returns the width of the terminal. + * + * @return the width ot the terminal + */ + def getWidth: Int + + /** + * Returns the height of the terminal + * + * @return the height of the terminal + */ + def getHeight: Int + + /** + * An input stream associated with the terminal. Bytes inputted by the + * user may be read from this input stream. + * + * @return the terminal's input stream + */ + def inputStream: InputStream + + /** + * A print stream associated with the terminal. Writing to this output + * stream should display text on the terminal. + * + * @return the terminal's input stream + */ + def printStream: PrintStream + + /** + * Sets the mode of the terminal. By default,the terminal will be in canonical mode + * with echo enabled. This means that the terminal's inputStream will not return any + * bytes until a newline is received and that all of the characters inputed by the + * user will be echoed to the terminal's output stream. + * + * @param canonical toggles whether or not the terminal input stream is line buffered + * @param echo toggles whether or not to echo the characters received from the terminal input stream + */ + def setMode(canonical: Boolean, echo: Boolean): Unit + +} +private[sbt] object Terminal { + private[sbt] def apply(term: ITerminal): Terminal = new Terminal { + override def getHeight: Int = term.getHeight + override def getWidth: Int = term.getWidth + override def inputStream: InputStream = term.inputStream + override def printStream: PrintStream = term.printStream + override def setMode(canonical: Boolean, echo: Boolean): Unit = + JLine3.setMode(term, canonical, echo) + } +} diff --git a/main/src/test/scala/PluginCommandTest.scala b/main/src/test/scala/PluginCommandTest.scala index 0ea4c5303..abca2df02 100644 --- a/main/src/test/scala/PluginCommandTest.scala +++ b/main/src/test/scala/PluginCommandTest.scala @@ -18,7 +18,7 @@ import sbt.internal.util.{ GlobalLogging, MainAppender, Settings, - Terminal + Terminal => ITerminal, } object PluginCommandTestPlugin0 extends AutoPlugin { override def requires = empty } @@ -77,7 +77,7 @@ object FakeState { val logFile = File.createTempFile("sbt", ".log") try { val state = FakeState(logFile, enabledPlugins: _*) - Terminal.withOut(new PrintStream(outBuffer, true)) { + ITerminal.withOut(new PrintStream(outBuffer, true)) { MainLoop.processCommand(Exec(input, None), state) } new String(outBuffer.toByteArray)