diff --git a/.gitignore b/.gitignore index f485f1b53c67..ce4e4a44058a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ *.class *.log *~ +*.swp # sbt specific dist/* diff --git a/project/Build.scala b/project/Build.scala index f64dd0f338f9..35482303e71b 100644 --- a/project/Build.scala +++ b/project/Build.scala @@ -64,6 +64,10 @@ object DottyBuild extends Build { unmanagedSourceDirectories in Compile := Seq((scalaSource in Compile).value), unmanagedSourceDirectories in Test := Seq((scalaSource in Test).value), + // set system in/out for repl + connectInput in run := true, + outputStrategy := Some(StdoutOutput), + // Generate compiler.properties, used by sbt resourceGenerators in Compile += Def.task { val file = (resourceManaged in Compile).value / "compiler.properties" diff --git a/src/dotty/tools/dotc/printing/SyntaxHighlighting.scala b/src/dotty/tools/dotc/printing/SyntaxHighlighting.scala new file mode 100644 index 000000000000..14b3a505072c --- /dev/null +++ b/src/dotty/tools/dotc/printing/SyntaxHighlighting.scala @@ -0,0 +1,262 @@ +package dotty.tools +package dotc +package printing + +import parsing.Tokens._ +import scala.annotation.switch +import scala.collection.mutable.StringBuilder + +/** This object provides functions for syntax highlighting in the REPL */ +object SyntaxHighlighting { + val NoColor = Console.RESET + val CommentColor = Console.GREEN + val KeywordColor = Console.CYAN + val LiteralColor = Console.MAGENTA + val TypeColor = Console.GREEN + val AnnotationColor = Console.RED + + private def none(str: String) = str + private def keyword(str: String) = KeywordColor + str + NoColor + private def typeDef(str: String) = TypeColor + str + NoColor + private def literal(str: String) = LiteralColor + str + NoColor + private def annotation(str: String) = AnnotationColor + str + NoColor + + private val keywords: Seq[String] = for { + index <- IF to FORSOME // All alpha keywords + } yield tokenString(index) + + private val interpolationPrefixes = + 'A' :: 'B' :: 'C' :: 'D' :: 'E' :: 'F' :: 'G' :: 'H' :: 'I' :: 'J' :: 'K' :: + 'L' :: 'M' :: 'N' :: 'O' :: 'P' :: 'Q' :: 'R' :: 'S' :: 'T' :: 'U' :: 'V' :: + 'W' :: 'X' :: 'Y' :: 'Z' :: '$' :: '_' :: 'a' :: 'b' :: 'c' :: 'd' :: 'e' :: + 'f' :: 'g' :: 'h' :: 'i' :: 'j' :: 'k' :: 'l' :: 'm' :: 'n' :: 'o' :: 'p' :: + 'q' :: 'r' :: 's' :: 't' :: 'u' :: 'v' :: 'w' :: 'x' :: 'y' :: 'z' :: Nil + + private val typeEnders = + '{' :: '}' :: ')' :: '(' :: '=' :: ' ' :: ',' :: '.' :: '\n' :: Nil + + def apply(chars: Iterable[Char]): Vector[Char] = { + var prev: Char = 0 + var remaining = chars.toStream + val newBuf = new StringBuilder + + @inline def keywordStart = + prev == 0 || prev == ' ' || prev == '{' || prev == '(' || prev == '\n' + + @inline def numberStart(c: Char) = + c.isDigit && (!prev.isLetter || prev == '.' || prev == ' ' || prev == '(' || prev == '\u0000') + + def takeChar(): Char = takeChars(1).head + def takeChars(x: Int): Seq[Char] = { + val taken = remaining.take(x) + remaining = remaining.drop(x) + taken + } + + while (remaining.nonEmpty) { + val n = takeChar() + if (interpolationPrefixes.contains(n)) { + // Interpolation prefixes are a superset of the keyword start chars + val next = remaining.take(3).mkString + if (next.startsWith("\"")) { + newBuf += n + prev = n + if (remaining.nonEmpty) takeChar() // drop 1 for appendLiteral + appendLiteral('"', next == "\"\"\"") + } else { + if (n.isUpper && keywordStart) { + appendWhile(n, !typeEnders.contains(_), typeDef) + } else if (keywordStart) { + append(n, keywords.contains(_), keyword) + } else { + newBuf += n + prev = n + } + } + } else { + (n: @switch) match { + case '/' => + if (remaining.nonEmpty) { + takeChar() match { + case '/' => eolComment() + case '*' => blockComment() + case x => newBuf += '/'; remaining = x #:: remaining + } + } else newBuf += '/' + case '=' => + append('=', _ == "=>", keyword) + case '<' => + append('<', { x => x == "<-" || x == "<:" || x == "<%" }, keyword) + case '>' => + append('>', { x => x == ">:" }, keyword) + case '#' if prev != ' ' && prev != '.' => + newBuf append keyword("#") + prev = '#' + case '@' => + appendWhile('@', _ != ' ', annotation) + case '\"' => + appendLiteral('\"', multiline = remaining.take(2).mkString == "\"\"") + case '\'' => + appendLiteral('\'') + case '`' => + appendTo('`', _ == '`', none) + case c if c.isUpper && keywordStart => + appendWhile(c, !typeEnders.contains(_), typeDef) + case c if numberStart(c) => + appendWhile(c, { x => x.isDigit || x == '.' || x == '\u0000'}, literal) + case c => + newBuf += c; prev = c + } + } + } + + def eolComment() = { + newBuf append (CommentColor + "//") + var curr = '/' + while (curr != '\n' && remaining.nonEmpty) { + curr = takeChar() + newBuf += curr + } + prev = curr + newBuf append NoColor + } + + def blockComment() = { + newBuf append (CommentColor + "/*") + var curr = '*' + var open = 1 + while (open > 0 && remaining.nonEmpty) { + curr = takeChar() + newBuf += curr + + if (curr == '*' && remaining.nonEmpty) { + curr = takeChar() + newBuf += curr + if (curr == '/') open -= 1 + } else if (curr == '/' && remaining.nonEmpty) { + curr = takeChar() + newBuf += curr + if (curr == '*') open += 1 + } + } + prev = curr + newBuf append NoColor + } + + def appendLiteral(delim: Char, multiline: Boolean = false) = { + var curr: Char = 0 + var continue = true + var closing = 0 + val inInterpolation = interpolationPrefixes.contains(prev) + newBuf append (LiteralColor + delim) + + def shouldInterpolate = + inInterpolation && curr == '$' && prev != '$' && remaining.nonEmpty + + def interpolate() = { + val next = takeChar() + if (next == '$') { + newBuf += curr + newBuf += next + prev = '$' + } else if (next == '{') { + var open = 1 // keep track of open blocks + newBuf append (KeywordColor + curr) + newBuf += next + while (remaining.nonEmpty && open > 0) { + var c = takeChar() + newBuf += c + if (c == '}') open -= 1 + else if (c == '{') open += 1 + } + newBuf append LiteralColor + } else { + newBuf append (KeywordColor + curr) + newBuf += next + var c: Char = 'a' + while (c.isLetterOrDigit && remaining.nonEmpty) { + c = takeChar() + if (c != '"') newBuf += c + } + newBuf append LiteralColor + if (c == '"') { + newBuf += c + continue = false + } + } + closing = 0 + } + + while (continue && remaining.nonEmpty) { + curr = takeChar() + if (curr == '\\' && remaining.nonEmpty) { + val next = takeChar() + newBuf append (KeywordColor + curr) + if (next == 'u') { + val code = "u" + takeChars(4).mkString + newBuf append code + } else newBuf += next + newBuf append LiteralColor + closing = 0 + } else if (shouldInterpolate) { + interpolate() + } else if (curr == delim && multiline) { + closing += 1 + if (closing == 3) continue = false + newBuf += curr + } else if (curr == delim) { + continue = false + newBuf += curr + } else { + newBuf += curr + closing = 0 + } + } + newBuf append NoColor + prev = curr + } + + def append(c: Char, shouldHL: String => Boolean, highlight: String => String) = { + var curr: Char = 0 + val sb = new StringBuilder(s"$c") + while (remaining.nonEmpty && curr != ' ' && curr != '(' && curr != '\n') { + curr = takeChar() + if (curr != ' ' && curr != '\n') sb += curr + } + + val str = sb.toString + val toAdd = if (shouldHL(str)) highlight(str) else str + val suffix = if (curr == ' ' || curr == '\n') s"$curr" else "" + newBuf append (toAdd + suffix) + prev = curr + } + + def appendWhile(c: Char, pred: Char => Boolean, highlight: String => String) = { + var curr: Char = 0 + val sb = new StringBuilder(s"$c") + while (remaining.nonEmpty && pred(curr)) { + curr = takeChar() + if (pred(curr)) sb += curr + } + + val str = sb.toString + val suffix = if (!pred(curr)) s"$curr" else "" + newBuf append (highlight(str) + suffix) + prev = curr + } + + def appendTo(c: Char, pred: Char => Boolean, highlight: String => String) = { + var curr: Char = 0 + val sb = new StringBuilder(s"$c") + while (remaining.nonEmpty && !pred(curr)) { + curr = takeChar() + sb += curr + } + + newBuf append highlight(sb.toString) + prev = curr + } + + newBuf.toVector + } +} diff --git a/src/dotty/tools/dotc/repl/AmmoniteReader.scala b/src/dotty/tools/dotc/repl/AmmoniteReader.scala new file mode 100644 index 000000000000..e399105b3338 --- /dev/null +++ b/src/dotty/tools/dotc/repl/AmmoniteReader.scala @@ -0,0 +1,78 @@ +package dotty.tools +package dotc +package repl + +import core.Contexts._ +import ammonite.terminal._ +import LazyList._ +import Ansi.Color +import filters._ +import BasicFilters._ +import GUILikeFilters._ +import util.SourceFile +import printing.SyntaxHighlighting + +class AmmoniteReader(val interpreter: Interpreter)(implicit ctx: Context) extends InteractiveReader { + val interactive = true + + def incompleteInput(str: String): Boolean = + interpreter.delayOutputDuring(interpreter.interpret(str)) match { + case Interpreter.Incomplete => true + case _ => false + } + + val reader = new java.io.InputStreamReader(System.in) + val writer = new java.io.OutputStreamWriter(System.out) + val cutPasteFilter = ReadlineFilters.CutPasteFilter() + var history = List.empty[String] + val selectionFilter = GUILikeFilters.SelectionFilter(indent = 2) + val multilineFilter: Filter = Filter("multilineFilter") { + case TermState(lb ~: rest, b, c, _) + if (lb == 10 || lb == 13) && incompleteInput(b.mkString) => + BasicFilters.injectNewLine(b, c, rest) + } + + def readLine(prompt: String): String = { + val historyFilter = new HistoryFilter( + () => history.toVector, + Console.BLUE, + AnsiNav.resetForegroundColor + ) + + val allFilters = Filter.merge( + UndoFilter(), + historyFilter, + selectionFilter, + GUILikeFilters.altFilter, + GUILikeFilters.fnFilter, + ReadlineFilters.navFilter, + cutPasteFilter, + multilineFilter, + BasicFilters.all + ) + + Terminal.readLine( + Console.BLUE + prompt + Console.RESET, + reader, + writer, + allFilters, + displayTransform = (buffer, cursor) => { + val ansiBuffer = Ansi.Str.parse(SyntaxHighlighting(buffer)) + val (newBuffer, cursorOffset) = SelectionFilter.mangleBuffer( + selectionFilter, ansiBuffer, cursor, Ansi.Reversed.On + ) + val newNewBuffer = HistoryFilter.mangleBuffer( + historyFilter, newBuffer, cursor, + Ansi.Color.Green + ) + + (newNewBuffer, cursorOffset) + } + ) match { + case Some(res) => + history = res :: history; + res + case None => ":q" + } + } +} diff --git a/src/dotty/tools/dotc/repl/CompilingInterpreter.scala b/src/dotty/tools/dotc/repl/CompilingInterpreter.scala index bc898488dd74..b3b7ab13c98e 100644 --- a/src/dotty/tools/dotc/repl/CompilingInterpreter.scala +++ b/src/dotty/tools/dotc/repl/CompilingInterpreter.scala @@ -78,6 +78,25 @@ class CompilingInterpreter(out: PrintWriter, ictx: Context) extends Compiler wit /** whether to print out result lines */ private var printResults: Boolean = true + private var delayOutput: Boolean = false + + var previousOutput: List[String] = Nil + + override def lastOutput() = { + val prev = previousOutput + previousOutput = Nil + prev + } + + override def delayOutputDuring[T](operation: => T): T = { + val old = delayOutput + try { + delayOutput = true + operation + } finally { + delayOutput = old + } + } /** Temporarily be quiet */ override def beQuietDuring[T](operation: => T): T = { @@ -92,14 +111,18 @@ class CompilingInterpreter(out: PrintWriter, ictx: Context) extends Compiler wit private def newReporter = new ConsoleReporter(Console.in, out) { override def printMessage(msg: String) = { - out.print(/*clean*/(msg) + "\n") - // Suppress clean for now for compiler messages - // Otherwise we will completely delete all references to - // line$object$ module classes. The previous interpreter did not - // have the project because the module class was written without the final `$' - // and therefore escaped the purge. We can turn this back on once - // we drop the final `$' from module classes. - out.flush() + if (!delayOutput) { + out.print(/*clean*/(msg) + "\n") + // Suppress clean for now for compiler messages + // Otherwise we will completely delete all references to + // line$object$ module classes. The previous interpreter did not + // have the project because the module class was written without the final `$' + // and therefore escaped the purge. We can turn this back on once + // we drop the final `$' from module classes. + out.flush() + } else { + previousOutput = (/*clean*/(msg) + "\n") :: previousOutput + } } } @@ -188,7 +211,9 @@ class CompilingInterpreter(out: PrintWriter, ictx: Context) extends Compiler wit Interpreter.Error // an error happened during compilation, e.g. a type error else { val (interpreterResultString, succeeded) = req.loadAndRun() - if (printResults || !succeeded) + if (delayOutput) + previousOutput = clean(interpreterResultString) :: previousOutput + else if (printResults || !succeeded) out.print(clean(interpreterResultString)) if (succeeded) { prevRequests += req @@ -727,10 +752,10 @@ class CompilingInterpreter(out: PrintWriter, ictx: Context) extends Compiler wit return str val trailer = "..." - if (maxpr >= trailer.length+1) - return str.substring(0, maxpr-3) + trailer - - str.substring(0, maxpr) + if (maxpr >= trailer.length-1) + str.substring(0, maxpr-3) + trailer + "\n" + else + str.substring(0, maxpr-1) } /** Clean up a string for output */ diff --git a/src/dotty/tools/dotc/repl/InteractiveReader.scala b/src/dotty/tools/dotc/repl/InteractiveReader.scala index 29ecd3c9d671..6ec6a0463882 100644 --- a/src/dotty/tools/dotc/repl/InteractiveReader.scala +++ b/src/dotty/tools/dotc/repl/InteractiveReader.scala @@ -2,6 +2,8 @@ package dotty.tools package dotc package repl +import dotc.core.Contexts.Context + /** Reads lines from an input stream */ trait InteractiveReader { def readLine(prompt: String): String @@ -14,13 +16,14 @@ object InteractiveReader { /** Create an interactive reader. Uses JLine if the * library is available, but otherwise uses a * SimpleReader. */ - def createDefault(): InteractiveReader = { + def createDefault(in: Interpreter)(implicit ctx: Context): InteractiveReader = { try { - new JLineReader() - } catch { - case e => - //out.println("jline is not available: " + e) //debug - new SimpleReader() + new AmmoniteReader(in) + } catch { case e => + //out.println("jline is not available: " + e) //debug + e.printStackTrace() + println("Could not use ammonite, falling back to simple reader") + new SimpleReader() } } } diff --git a/src/dotty/tools/dotc/repl/Interpreter.scala b/src/dotty/tools/dotc/repl/Interpreter.scala index ea587a097a27..6a292dfe26d1 100644 --- a/src/dotty/tools/dotc/repl/Interpreter.scala +++ b/src/dotty/tools/dotc/repl/Interpreter.scala @@ -33,4 +33,10 @@ trait Interpreter { /** Suppress output during evaluation of `operation`. */ def beQuietDuring[T](operation: => T): T + + /** Suppresses output and saves it for `lastOutput` to collect */ + def delayOutputDuring[T](operation: => T): T + + /** Gets the last output not printed immediately */ + def lastOutput(): List[String] } diff --git a/src/dotty/tools/dotc/repl/InterpreterLoop.scala b/src/dotty/tools/dotc/repl/InterpreterLoop.scala index 4ac9602e763c..14a50fdf1793 100644 --- a/src/dotty/tools/dotc/repl/InterpreterLoop.scala +++ b/src/dotty/tools/dotc/repl/InterpreterLoop.scala @@ -18,16 +18,16 @@ import scala.concurrent.ExecutionContext.Implicits.global * After instantiation, clients should call the `run` method. * * @author Moez A. Abdel-Gawad - * @author Lex Spoon + * @author Lex Spoon * @author Martin Odersky */ class InterpreterLoop(compiler: Compiler, config: REPL.Config)(implicit ctx: Context) { import config._ - private var in = input - val interpreter = compiler.asInstanceOf[Interpreter] + private var in = input(interpreter) + /** The context class loader at the time this object was created */ protected val originalClassLoader = Thread.currentThread.getContextClassLoader @@ -74,10 +74,9 @@ class InterpreterLoop(compiler: Compiler, config: REPL.Config)(implicit ctx: Con * line of input to be entered. */ def firstLine(): String = { - val futLine = Future(in.readLine(prompt)) interpreter.beQuietDuring( interpreter.interpret("val theAnswerToLifeInTheUniverseAndEverything = 21 * 2")) - Await.result(futLine, Duration.Inf) + in.readLine(prompt) } /** The main read-eval-print loop for the interpreter. It calls @@ -149,6 +148,7 @@ class InterpreterLoop(compiler: Compiler, config: REPL.Config)(implicit ctx: Con val quitRegexp = ":q(u(i(t)?)?)?" val loadRegexp = ":l(o(a(d)?)?)?.*" val replayRegexp = ":r(e(p(l(a(y)?)?)?)?)?.*" + val lastOutput = interpreter.lastOutput() var shouldReplay: Option[String] = None @@ -167,7 +167,12 @@ class InterpreterLoop(compiler: Compiler, config: REPL.Config)(implicit ctx: Con else if (line startsWith ":") output.println("Unknown command. Type :help for help.") else - shouldReplay = interpretStartingWith(line) + shouldReplay = lastOutput match { // don't interpret twice + case Nil => interpretStartingWith(line) + case oldRes => + oldRes foreach output.print + Some(line) + } (true, shouldReplay) } @@ -178,23 +183,11 @@ class InterpreterLoop(compiler: Compiler, config: REPL.Config)(implicit ctx: Con * read, go ahead and interpret it. Return the full string * to be recorded for replay, if any. */ - def interpretStartingWith(code: String): Option[String] = { + def interpretStartingWith(code: String): Option[String] = interpreter.interpret(code) match { case Interpreter.Success => Some(code) - case Interpreter.Error => None - case Interpreter.Incomplete => - if (in.interactive && code.endsWith("\n\n")) { - output.println("You typed two blank lines. Starting a new command.") - None - } else { - val nextLine = in.readLine(continuationPrompt) - if (nextLine == null) - None // end of file - else - interpretStartingWith(code + "\n" + nextLine) - } + case _ => None } - } /* def loadFiles(settings: Settings) { settings match { diff --git a/src/dotty/tools/dotc/repl/JLineReader.scala b/src/dotty/tools/dotc/repl/JLineReader.scala index 592b19df598a..73463cd7cc1f 100644 --- a/src/dotty/tools/dotc/repl/JLineReader.scala +++ b/src/dotty/tools/dotc/repl/JLineReader.scala @@ -2,6 +2,7 @@ package dotty.tools package dotc package repl +import dotc.core.Contexts.Context import jline.console.ConsoleReader /** Adaptor for JLine diff --git a/src/dotty/tools/dotc/repl/REPL.scala b/src/dotty/tools/dotc/repl/REPL.scala index e5ff2d3afd5e..1f5e3347b340 100644 --- a/src/dotty/tools/dotc/repl/REPL.scala +++ b/src/dotty/tools/dotc/repl/REPL.scala @@ -46,11 +46,11 @@ object REPL { val version = ".next (pre-alpha)" /** The default input reader */ - def input(implicit ctx: Context): InteractiveReader = { + def input(in: Interpreter)(implicit ctx: Context): InteractiveReader = { val emacsShell = System.getProperty("env.emacs", "") != "" //println("emacsShell="+emacsShell) //debug if (ctx.settings.Xnojline.value || emacsShell) new SimpleReader() - else InteractiveReader.createDefault() + else InteractiveReader.createDefault(in) } /** The default output writer */ diff --git a/src/dotty/tools/dotc/repl/SimpleReader.scala b/src/dotty/tools/dotc/repl/SimpleReader.scala index 9fd563382155..5fab47bbe897 100644 --- a/src/dotty/tools/dotc/repl/SimpleReader.scala +++ b/src/dotty/tools/dotc/repl/SimpleReader.scala @@ -3,6 +3,7 @@ package dotc package repl import java.io.{BufferedReader, PrintWriter} +import dotc.core.Contexts.Context /** Reads using standard JDK API */ diff --git a/src/dotty/tools/dotc/repl/ammonite/Ansi.scala b/src/dotty/tools/dotc/repl/ammonite/Ansi.scala new file mode 100644 index 000000000000..37c4de7b56c1 --- /dev/null +++ b/src/dotty/tools/dotc/repl/ammonite/Ansi.scala @@ -0,0 +1,256 @@ +package dotty.tools +package dotc +package repl +package ammonite.terminal + +object Ansi { + + /** + * Represents a single, atomic ANSI escape sequence that results in a + * color, background or decoration being added to the output. + * + * @param escape the actual ANSI escape sequence corresponding to this Attr + */ + case class Attr private[Ansi](escape: Option[String], resetMask: Int, applyMask: Int) { + override def toString = escape.getOrElse("") + Console.RESET + def transform(state: Short) = ((state & ~resetMask) | applyMask).toShort + + def matches(state: Short) = (state & resetMask) == applyMask + def apply(s: Ansi.Str) = s.overlay(this, 0, s.length) + } + + object Attr { + val Reset = new Attr(Some(Console.RESET), Short.MaxValue, 0) + + /** + * Quickly convert string-colors into [[Ansi.Attr]]s + */ + val ParseMap = { + val pairs = for { + cat <- categories + color <- cat.all + str <- color.escape + } yield (str, color) + (pairs :+ (Console.RESET -> Reset)).toMap + } + } + + /** + * Represents a set of [[Ansi.Attr]]s all occupying the same bit-space + * in the state `Short` + */ + sealed abstract class Category() { + val mask: Int + val all: Seq[Attr] + lazy val bitsMap = all.map{ m => m.applyMask -> m}.toMap + def makeAttr(s: Option[String], applyMask: Int) = { + new Attr(s, mask, applyMask) + } + } + + object Color extends Category { + + val mask = 15 << 7 + val Reset = makeAttr(Some("\u001b[39m"), 0 << 7) + val Black = makeAttr(Some(Console.BLACK), 1 << 7) + val Red = makeAttr(Some(Console.RED), 2 << 7) + val Green = makeAttr(Some(Console.GREEN), 3 << 7) + val Yellow = makeAttr(Some(Console.YELLOW), 4 << 7) + val Blue = makeAttr(Some(Console.BLUE), 5 << 7) + val Magenta = makeAttr(Some(Console.MAGENTA), 6 << 7) + val Cyan = makeAttr(Some(Console.CYAN), 7 << 7) + val White = makeAttr(Some(Console.WHITE), 8 << 7) + + val all = Vector( + Reset, Black, Red, Green, Yellow, + Blue, Magenta, Cyan, White + ) + } + + object Back extends Category { + val mask = 15 << 3 + + val Reset = makeAttr(Some("\u001b[49m"), 0 << 3) + val Black = makeAttr(Some(Console.BLACK_B), 1 << 3) + val Red = makeAttr(Some(Console.RED_B), 2 << 3) + val Green = makeAttr(Some(Console.GREEN_B), 3 << 3) + val Yellow = makeAttr(Some(Console.YELLOW_B), 4 << 3) + val Blue = makeAttr(Some(Console.BLUE_B), 5 << 3) + val Magenta = makeAttr(Some(Console.MAGENTA_B), 6 << 3) + val Cyan = makeAttr(Some(Console.CYAN_B), 7 << 3) + val White = makeAttr(Some(Console.WHITE_B), 8 << 3) + + val all = Seq( + Reset, Black, Red, Green, Yellow, + Blue, Magenta, Cyan, White + ) + } + + object Bold extends Category { + val mask = 1 << 0 + val On = makeAttr(Some(Console.BOLD), 1 << 0) + val Off = makeAttr(None , 0 << 0) + val all = Seq(On, Off) + } + + object Underlined extends Category { + val mask = 1 << 1 + val On = makeAttr(Some(Console.UNDERLINED), 1 << 1) + val Off = makeAttr(None, 0 << 1) + val all = Seq(On, Off) + } + + object Reversed extends Category { + val mask = 1 << 2 + val On = makeAttr(Some(Console.REVERSED), 1 << 2) + val Off = makeAttr(None, 0 << 2) + val all = Seq(On, Off) + } + + val hardOffMask = Bold.mask | Underlined.mask | Reversed.mask + val categories = List(Color, Back, Bold, Underlined, Reversed) + + object Str { + @sharable lazy val ansiRegex = "\u001B\\[[;\\d]*m".r + + implicit def parse(raw: CharSequence): Str = { + val chars = new Array[Char](raw.length) + val colors = new Array[Short](raw.length) + var currentIndex = 0 + var currentColor = 0.toShort + + val matches = ansiRegex.findAllMatchIn(raw) + val indices = Seq(0) ++ matches.flatMap { m => Seq(m.start, m.end) } ++ Seq(raw.length) + + for { + Seq(start, end) <- indices.sliding(2).toSeq + if start != end + } { + val frag = raw.subSequence(start, end).toString + if (frag.charAt(0) == '\u001b' && Attr.ParseMap.contains(frag)) { + currentColor = Attr.ParseMap(frag).transform(currentColor) + } else { + var i = 0 + while(i < frag.length){ + chars(currentIndex) = frag(i) + colors(currentIndex) = currentColor + i += 1 + currentIndex += 1 + } + } + } + + Str(chars.take(currentIndex), colors.take(currentIndex)) + } + } + + /** + * An [[Ansi.Str]]'s `color`s array is filled with shorts, each representing + * the ANSI state of one character encoded in its bits. Each [[Attr]] belongs + * to a [[Category]] that occupies a range of bits within each short: + * + * 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0 + * |-----------| |--------| |--------| | | |bold + * | | | | |reversed + * | | | |underlined + * | | |foreground-color + * | |background-color + * |unused + * + * + * The `0000 0000 0000 0000` short corresponds to plain text with no decoration + * + */ + type State = Short + + /** + * Encapsulates a string with associated ANSI colors and text decorations. + * + * Contains some basic string methods, as well as some ansi methods to e.g. + * apply particular colors or other decorations to particular sections of + * the [[Ansi.Str]]. [[render]] flattens it out into a `java.lang.String` + * with all the colors present as ANSI escapes. + * + */ + case class Str private(chars: Array[Char], colors: Array[State]) { + require(chars.length == colors.length) + + def ++(other: Str) = Str(chars ++ other.chars, colors ++ other.colors) + def splitAt(index: Int) = { + val (leftChars, rightChars) = chars.splitAt(index) + val (leftColors, rightColors) = colors.splitAt(index) + (new Str(leftChars, leftColors), new Str(rightChars, rightColors)) + } + + def length = chars.length + override def toString = render + + def plainText = new String(chars.toArray) + def render = { + // Pre-size StringBuilder with approximate size (ansi colors tend + // to be about 5 chars long) to avoid re-allocations during growth + val output = new StringBuilder(chars.length + colors.length * 5) + + + var currentState = 0.toShort + /** + * Emit the ansi escapes necessary to transition + * between two states, if necessary. + */ + def emitDiff(nextState: Short) = if (currentState != nextState){ + // Any of these transitions from 1 to 0 within the hardOffMask + // categories cannot be done with a single ansi escape, and need + // you to emit a RESET followed by re-building whatever ansi state + // you previous had from scratch + if ((currentState & ~nextState & hardOffMask) != 0){ + output.append(Console.RESET) + currentState = 0 + } + + var categoryIndex = 0 + while(categoryIndex < categories.length){ + val cat = categories(categoryIndex) + if ((cat.mask & currentState) != (cat.mask & nextState)){ + val attr = cat.bitsMap(nextState & cat.mask) + + if (attr.escape.isDefined) { + output.append(attr.escape.get) + } + } + categoryIndex += 1 + } + } + + var i = 0 + while(i < colors.length){ + // Emit ANSI escapes to change colors where necessary + emitDiff(colors(i)) + currentState = colors(i) + output.append(chars(i)) + i += 1 + } + + // Cap off the left-hand-side of the rendered string with any ansi escape + // codes necessary to rest the state to 0 + emitDiff(0) + output.toString + } + + /** + * Overlays the desired color over the specified range of the [[Ansi.Str]]. + */ + def overlay(overlayColor: Attr, start: Int, end: Int) = { + require(end >= start, + s"end:$end must be greater than start:$end in AnsiStr#overlay call" + ) + val colorsOut = new Array[Short](colors.length) + var i = 0 + while(i < colors.length){ + if (i >= start && i < end) colorsOut(i) = overlayColor.transform(colors(i)) + else colorsOut(i) = colors(i) + i += 1 + } + new Str(chars, colorsOut) + } + } +} diff --git a/src/dotty/tools/dotc/repl/ammonite/Filter.scala b/src/dotty/tools/dotc/repl/ammonite/Filter.scala new file mode 100644 index 000000000000..9d34bb0f234b --- /dev/null +++ b/src/dotty/tools/dotc/repl/ammonite/Filter.scala @@ -0,0 +1,61 @@ +package dotty.tools +package dotc +package repl +package ammonite.terminal + +object Filter { + def apply(id: String)(f: PartialFunction[TermInfo, TermAction]): Filter = + new Filter { + val op = f.lift + def identifier = id + } + + def wrap(id: String)(f: TermInfo => Option[TermAction]): Filter = + new Filter { + val op = f + def identifier = id + } + + /** Merges multiple [[Filter]]s into one. */ + def merge(pfs: Filter*) = new Filter { + val op = (v1: TermInfo) => pfs.iterator.map(_.op(v1)).find(_.isDefined).flatten + def identifier = pfs.iterator.map(_.identifier).mkString(":") + } + + val empty = Filter.merge() +} + +/** + * The way you configure your terminal behavior; a trivial wrapper around a + * function, though you should provide a good `.toString` method to make + * debugging easier. The [[TermInfo]] and [[TermAction]] types are its + * interface to the terminal. + * + * [[Filter]]s are composed sequentially: if a filter returns `None` the next + * filter is tried, while if a filter returns `Some` that ends the cascade. + * While your `op` function interacts with the terminal purely through + * immutable case classes, the Filter itself is free to maintain its own state + * and mutate it whenever, even when returning `None` to continue the cascade. + */ +trait Filter { + val op: TermInfo => Option[TermAction] + + /** + * the `.toString` of this object, except by making it separate we force + * the implementer to provide something and stop them from accidentally + * leaving it as the meaningless default. + */ + def identifier: String + override def toString = identifier +} + +/** + * A filter as an abstract class, letting you provide a [[filter]] instead of + * an `op`, automatically providing a good `.toString` for debugging, and + * providing a reasonable "place" inside the inheriting class/object to put + * state or helpers or other logic associated with the filter. + */ +abstract class DelegateFilter() extends Filter { + def filter: Filter + val op = filter.op +} diff --git a/src/dotty/tools/dotc/repl/ammonite/FilterTools.scala b/src/dotty/tools/dotc/repl/ammonite/FilterTools.scala new file mode 100644 index 000000000000..c18b6a927844 --- /dev/null +++ b/src/dotty/tools/dotc/repl/ammonite/FilterTools.scala @@ -0,0 +1,80 @@ +package dotty.tools +package dotc +package repl +package ammonite.terminal + +/** + * A collection of helpers that to simpify the common case of building filters + */ +object FilterTools { + val ansiRegex = "\u001B\\[[;\\d]*." + + def offsetIndex(buffer: Vector[Char], in: Int) = { + var splitIndex = 0 + var length = 0 + + while(length < in) { + ansiRegex.r.findPrefixOf(buffer.drop(splitIndex)) match { + case None => + splitIndex += 1 + length += 1 + case Some(s) => + splitIndex += s.length + } + } + splitIndex + } + + /** + * Shorthand to construct a filter in the common case where you're + * switching on the prefix of the input stream and want to run some + * transformation on the buffer/cursor + */ + def Case(s: String) + (f: (Vector[Char], Int, TermInfo) => (Vector[Char], Int)) = new Filter { + val op = new PartialFunction[TermInfo, TermAction] { + def isDefinedAt(x: TermInfo) = { + + def rec(i: Int, c: LazyList[Int]): Boolean = { + if (i >= s.length) true + else if (c.head == s(i)) rec(i + 1, c.tail) + else false + } + rec(0, x.ts.inputs) + } + + def apply(v1: TermInfo) = { + val (buffer1, cursor1) = f(v1.ts.buffer, v1.ts.cursor, v1) + TermState( + v1.ts.inputs.dropPrefix(s.map(_.toInt)).get, + buffer1, + cursor1 + ) + } + + }.lift + def identifier = "Case" + } + + /** Shorthand for pattern matching on [[TermState]] */ + val TS = TermState + + def findChunks(b: Vector[Char], c: Int) = { + val chunks = Terminal.splitBuffer(b) + // The index of the first character in each chunk + val chunkStarts = chunks.inits.map(x => x.length + x.sum).toStream.reverse + // Index of the current chunk that contains the cursor + val chunkIndex = chunkStarts.indexWhere(_ > c) match { + case -1 => chunks.length-1 + case x => x - 1 + } + (chunks, chunkStarts, chunkIndex) + } + + def firstRow(cursor: Int, buffer: Vector[Char], width: Int) = + cursor < width && (buffer.indexOf('\n') >= cursor || buffer.indexOf('\n') == -1) + + def lastRow(cursor: Int, buffer: Vector[Char], width: Int) = + (buffer.length - cursor) < width && + (buffer.lastIndexOf('\n') < cursor || buffer.lastIndexOf('\n') == -1) +} diff --git a/src/dotty/tools/dotc/repl/ammonite/LICENSE b/src/dotty/tools/dotc/repl/ammonite/LICENSE new file mode 100644 index 000000000000..b15103580d82 --- /dev/null +++ b/src/dotty/tools/dotc/repl/ammonite/LICENSE @@ -0,0 +1,25 @@ +License +======= + + +The MIT License (MIT) + +Copyright (c) 2014 Li Haoyi (haoyi.sg@gmail.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/src/dotty/tools/dotc/repl/ammonite/Protocol.scala b/src/dotty/tools/dotc/repl/ammonite/Protocol.scala new file mode 100644 index 000000000000..34d31aecada9 --- /dev/null +++ b/src/dotty/tools/dotc/repl/ammonite/Protocol.scala @@ -0,0 +1,30 @@ +package dotty.tools +package dotc +package repl +package ammonite.terminal + +case class TermInfo(ts: TermState, width: Int) + +sealed trait TermAction +case class Printing(ts: TermState, stdout: String) extends TermAction +case class TermState( + inputs: LazyList[Int], + buffer: Vector[Char], + cursor: Int, + msg: Ansi.Str = "" +) extends TermAction + +object TermState { + def unapply(ti: TermInfo): Option[(LazyList[Int], Vector[Char], Int, Ansi.Str)] = + TermState.unapply(ti.ts) + + def unapply(ti: TermAction): Option[(LazyList[Int], Vector[Char], Int, Ansi.Str)] = + ti match { + case ts: TermState => TermState.unapply(ts) + case _ => None + } +} + +case class ClearScreen(ts: TermState) extends TermAction +case object Exit extends TermAction +case class Result(s: String) extends TermAction diff --git a/src/dotty/tools/dotc/repl/ammonite/SpecialKeys.scala b/src/dotty/tools/dotc/repl/ammonite/SpecialKeys.scala new file mode 100644 index 000000000000..d834cc10b9e9 --- /dev/null +++ b/src/dotty/tools/dotc/repl/ammonite/SpecialKeys.scala @@ -0,0 +1,81 @@ +package dotty.tools +package dotc +package repl +package ammonite.terminal + +/** + * One place to assign all the esotic control key input snippets to + * easy-to-remember names + */ +object SpecialKeys { + + /** + * Lets you easily pattern match on characters modified by ctrl, + * or convert a character into its ctrl-ed version + */ + object Ctrl { + def apply(c: Char) = (c - 96).toChar.toString + def unapply(i: Int): Option[Int] = Some(i + 96) + } + + /** + * The string value you get when you hit the alt key + */ + def Alt = "\u001b" + + + val Up = Alt+"[A" + val Down = Alt+"[B" + val Right = Alt+"[C" + val Left = Alt+"[D" + + val Home = Alt+"OH" + val End = Alt+"OF" + + // For some reason Screen makes these print different incantations + // from a normal snippet, so this causes issues like + // https://github.com/lihaoyi/Ammonite/issues/152 unless we special + // case them + val HomeScreen = Alt+"[1~" + val EndScreen = Alt+"[4~" + + val ShiftUp = Alt+"[1;2A" + val ShiftDown = Alt+"[1;2B" + val ShiftRight = Alt+"[1;2C" + val ShiftLeft = Alt+"[1;2D" + + val FnUp = Alt+"[5~" + val FnDown = Alt+"[6~" + val FnRight = Alt+"[F" + val FnLeft = Alt+"[H" + + val AltUp = Alt*2+"[A" + val AltDown = Alt*2+"[B" + val AltRight = Alt*2+"[C" + val AltLeft = Alt*2+"[D" + + val LinuxCtrlRight = Alt+"[1;5C" + val LinuxCtrlLeft = Alt+"[1;5D" + + val FnAltUp = Alt*2+"[5~" + val FnAltDown = Alt*2+"[6~" + val FnAltRight = Alt+"[1;9F" + val FnAltLeft = Alt+"[1;9H" + + // Same as fn-alt-{up, down} +// val FnShiftUp = Alt*2+"[5~" +// val FnShiftDown = Alt*2+"[6~" + val FnShiftRight = Alt+"[1;2F" + val FnShiftLeft = Alt+"[1;2H" + + val AltShiftUp = Alt+"[1;10A" + val AltShiftDown = Alt+"[1;10B" + val AltShiftRight = Alt+"[1;10C" + val AltShiftLeft = Alt+"[1;10D" + + // Same as fn-alt-{up, down} +// val FnAltShiftUp = Alt*2+"[5~" +// val FnAltShiftDown = Alt*2+"[6~" + val FnAltShiftRight = Alt+"[1;10F" + val FnAltShiftLeft = Alt+"[1;10H" +} diff --git a/src/dotty/tools/dotc/repl/ammonite/Terminal.scala b/src/dotty/tools/dotc/repl/ammonite/Terminal.scala new file mode 100644 index 000000000000..4b18b38e3a74 --- /dev/null +++ b/src/dotty/tools/dotc/repl/ammonite/Terminal.scala @@ -0,0 +1,320 @@ +package dotty.tools +package dotc +package repl +package ammonite +package terminal + +import scala.annotation.tailrec +import scala.collection.mutable + +/** + * The core logic around a terminal; it defines the base `filters` API + * through which anything (including basic cursor-navigation and typing) + * interacts with the terminal. + * + * Maintains basic invariants, such as "cursor should always be within + * the buffer", and "ansi terminal should reflect most up to date TermState" + */ +object Terminal { + + /** + * Computes how tall a line of text is when wrapped at `width`. + * + * Even 0-character lines still take up one row! + * + * width = 2 + * 0 -> 1 + * 1 -> 1 + * 2 -> 1 + * 3 -> 2 + * 4 -> 2 + * 5 -> 3 + */ + def fragHeight(length: Int, width: Int) = math.max(1, (length - 1) / width + 1) + + def splitBuffer(buffer: Vector[Char]) = { + val frags = mutable.Buffer.empty[Int] + frags.append(0) + for(c <- buffer){ + if (c == '\n') frags.append(0) + else frags(frags.length - 1) = frags.last + 1 + } + frags + } + def calculateHeight(buffer: Vector[Char], + width: Int, + prompt: String): Seq[Int] = { + val rowLengths = splitBuffer(buffer) + + calculateHeight0(rowLengths, width - prompt.length) + } + + /** + * Given a buffer with characters and newlines, calculates how high + * the buffer is and where the cursor goes inside of it. + */ + def calculateHeight0(rowLengths: Seq[Int], + width: Int): Seq[Int] = { + val fragHeights = + rowLengths + .inits + .toVector + .reverse // We want shortest-to-longest, inits gives longest-to-shortest + .filter(_.nonEmpty) // Without the first empty prefix + .map{ x => + fragHeight( + // If the frag barely fits on one line, give it + // an extra spot for the cursor on the next line + x.last + 1, + width + ) + } +// Debug("fragHeights " + fragHeights) + fragHeights + } + + def positionCursor(cursor: Int, + rowLengths: Seq[Int], + fragHeights: Seq[Int], + width: Int) = { + var leftoverCursor = cursor + // Debug("leftoverCursor " + leftoverCursor) + var totalPreHeight = 0 + var done = false + // Don't check if the cursor exceeds the last chunk, because + // even if it does there's nowhere else for it to go + for(i <- 0 until rowLengths.length -1 if !done) { + // length of frag and the '\n' after it + val delta = rowLengths(i) + 1 + // Debug("delta " + delta) + val nextCursor = leftoverCursor - delta + if (nextCursor >= 0) { + // Debug("nextCursor " + nextCursor) + leftoverCursor = nextCursor + totalPreHeight += fragHeights(i) + }else done = true + } + + val cursorY = totalPreHeight + leftoverCursor / width + val cursorX = leftoverCursor % width + + (cursorY, cursorX) + } + + + type Action = (Vector[Char], Int) => (Vector[Char], Int) + type MsgAction = (Vector[Char], Int) => (Vector[Char], Int, String) + + + def noTransform(x: Vector[Char], i: Int) = (Ansi.Str.parse(x), i) + /** + * Blockingly reads a line from the given input stream and returns it. + * + * @param prompt The prompt to display when requesting input + * @param reader The input-stream where characters come in, e.g. System.in + * @param writer The output-stream where print-outs go, e.g. System.out + * @param filters A set of actions that can be taken depending on the input, + * @param displayTransform code to manipulate the display of the buffer and + * cursor, without actually changing the logical + * values inside them. + */ + def readLine(prompt: Prompt, + reader: java.io.Reader, + writer: java.io.Writer, + filters: Filter, + displayTransform: (Vector[Char], Int) => (Ansi.Str, Int) = noTransform) + : Option[String] = { + + /** + * Erases the previous line and re-draws it with the new buffer and + * cursor. + * + * Relies on `ups` to know how "tall" the previous line was, to go up + * and erase that many rows in the console. Performs a lot of horrific + * math all over the place, incredibly prone to off-by-ones, in order + * to at the end of the day position the cursor in the right spot. + */ + def redrawLine(buffer: Ansi.Str, + cursor: Int, + ups: Int, + rowLengths: Seq[Int], + fullPrompt: Boolean = true, + newlinePrompt: Boolean = false) = { + + + // Enable this in certain cases (e.g. cursor near the value you are + // interested into) see what's going on with all the ansi screen-cursor + // movement + def debugDelay() = if (false){ + Thread.sleep(200) + writer.flush() + } + + + val promptLine = + if (fullPrompt) prompt.full + else prompt.lastLine + + val promptWidth = if(newlinePrompt) 0 else prompt.lastLine.length + val actualWidth = width - promptWidth + + ansi.up(ups) + ansi.left(9999) + ansi.clearScreen(0) + writer.write(promptLine.toString) + if (newlinePrompt) writer.write("\n") + + // I'm not sure why this is necessary, but it seems that without it, a + // cursor that "barely" overshoots the end of a line, at the end of the + // buffer, does not properly wrap and ends up dangling off the + // right-edge of the terminal window! + // + // This causes problems later since the cursor is at the wrong X/Y, + // confusing the rest of the math and ending up over-shooting on the + // `ansi.up` calls, over-writing earlier lines. This prints a single + // space such that instead of dangling it forces the cursor onto the + // next line for-realz. If it isn't dangling the extra space is a no-op + val lineStuffer = ' ' + // Under `newlinePrompt`, we print the thing almost-verbatim, since we + // want to avoid breaking code by adding random indentation. If not, we + // are guaranteed that the lines are short, so we can indent the newlines + // without fear of wrapping + val newlineReplacement = + if (newlinePrompt) { + + Array(lineStuffer, '\n') + } else { + val indent = " " * prompt.lastLine.length + Array('\n', indent:_*) + } + + writer.write( + buffer.render.flatMap{ + case '\n' => newlineReplacement + case x => Array(x) + }.toArray + ) + writer.write(lineStuffer) + + val fragHeights = calculateHeight0(rowLengths, actualWidth) + val (cursorY, cursorX) = positionCursor( + cursor, + rowLengths, + fragHeights, + actualWidth + ) + ansi.up(fragHeights.sum - 1) + ansi.left(9999) + ansi.down(cursorY) + ansi.right(cursorX) + if (!newlinePrompt) ansi.right(prompt.lastLine.length) + + writer.flush() + } + + @tailrec + def readChar(lastState: TermState, ups: Int, fullPrompt: Boolean = true): Option[String] = { + val moreInputComing = reader.ready() + + lazy val (transformedBuffer0, cursorOffset) = displayTransform( + lastState.buffer, + lastState.cursor + ) + + lazy val transformedBuffer = transformedBuffer0 ++ lastState.msg + lazy val lastOffsetCursor = lastState.cursor + cursorOffset + lazy val rowLengths = splitBuffer( + lastState.buffer ++ lastState.msg.plainText + ) + val narrowWidth = width - prompt.lastLine.length + val newlinePrompt = rowLengths.exists(_ >= narrowWidth) + val promptWidth = if(newlinePrompt) 0 else prompt.lastLine.length + val actualWidth = width - promptWidth + val newlineUp = if (newlinePrompt) 1 else 0 + if (!moreInputComing) redrawLine( + transformedBuffer, + lastOffsetCursor, + ups, + rowLengths, + fullPrompt, + newlinePrompt + ) + + lazy val (oldCursorY, _) = positionCursor( + lastOffsetCursor, + rowLengths, + calculateHeight0(rowLengths, actualWidth), + actualWidth + ) + + def updateState(s: LazyList[Int], + b: Vector[Char], + c: Int, + msg: Ansi.Str): (Int, TermState) = { + + val newCursor = math.max(math.min(c, b.length), 0) + val nextUps = + if (moreInputComing) ups + else oldCursorY + newlineUp + + val newState = TermState(s, b, newCursor, msg) + + (nextUps, newState) + } + // `.get` because we assume that *some* filter is going to match each + // character, even if only to dump the character to the screen. If nobody + // matches the character then we can feel free to blow up + filters.op(TermInfo(lastState, actualWidth)).get match { + case Printing(TermState(s, b, c, msg), stdout) => + writer.write(stdout) + val (nextUps, newState) = updateState(s, b, c, msg) + readChar(newState, nextUps) + + case TermState(s, b, c, msg) => + val (nextUps, newState) = updateState(s, b, c, msg) + readChar(newState, nextUps, false) + + case Result(s) => + redrawLine( + transformedBuffer, lastState.buffer.length, + oldCursorY + newlineUp, rowLengths, false, newlinePrompt + ) + writer.write(10) + writer.write(13) + writer.flush() + Some(s) + case ClearScreen(ts) => + ansi.clearScreen(2) + ansi.up(9999) + ansi.left(9999) + readChar(ts, ups) + case Exit => + None + } + } + + lazy val ansi = new AnsiNav(writer) + lazy val (width, _, initialConfig) = TTY.init() + try { + readChar(TermState(LazyList.continually(reader.read()), Vector.empty, 0, ""), 0) + }finally{ + + // Don't close these! Closing these closes stdin/stdout, + // which seems to kill the entire program + + // reader.close() + // writer.close() + TTY.stty(initialConfig) + } + } +} +object Prompt { + implicit def construct(prompt: String): Prompt = { + val parsedPrompt = Ansi.Str.parse(prompt) + val index = parsedPrompt.plainText.lastIndexOf('\n') + val (_, last) = parsedPrompt.splitAt(index+1) + Prompt(parsedPrompt, last) + } +} + +case class Prompt(full: Ansi.Str, lastLine: Ansi.Str) diff --git a/src/dotty/tools/dotc/repl/ammonite/Utils.scala b/src/dotty/tools/dotc/repl/ammonite/Utils.scala new file mode 100644 index 000000000000..64a2c14765b7 --- /dev/null +++ b/src/dotty/tools/dotc/repl/ammonite/Utils.scala @@ -0,0 +1,169 @@ +package dotty.tools +package dotc +package repl +package ammonite.terminal + +import java.io.{FileOutputStream, Writer, File => JFile} +import scala.annotation.tailrec + +/** + * Prints stuff to an ad-hoc logging file when running the repl or terminal in + * development mode + * + * Very handy for the common case where you're debugging terminal interactions + * and cannot use `println` because it will stomp all over your already messed + * up terminal state and block debugging. With [[Debug]], you can have a + * separate terminal open tailing the log file and log as verbosely as you + * want without affecting the primary terminal you're using to interact with + * Ammonite. + */ +object Debug { + lazy val debugOutput = + new FileOutputStream(new JFile("terminal/target/log")) + + def apply(s: Any) = + if (System.getProperty("ammonite-sbt-build") == "true") + debugOutput.write((System.currentTimeMillis() + "\t\t" + s + "\n").getBytes) +} + +class AnsiNav(output: Writer) { + def control(n: Int, c: Char) = output.write(s"\033[" + n + c) + + /** + * Move up `n` squares + */ + def up(n: Int) = if (n == 0) "" else control(n, 'A') + /** + * Move down `n` squares + */ + def down(n: Int) = if (n == 0) "" else control(n, 'B') + /** + * Move right `n` squares + */ + def right(n: Int) = if (n == 0) "" else control(n, 'C') + /** + * Move left `n` squares + */ + def left(n: Int) = if (n == 0) "" else control(n, 'D') + + /** + * Clear the screen + * + * n=0: clear from cursor to end of screen + * n=1: clear from cursor to start of screen + * n=2: clear entire screen + */ + def clearScreen(n: Int) = control(n, 'J') + /** + * Clear the current line + * + * n=0: clear from cursor to end of line + * n=1: clear from cursor to start of line + * n=2: clear entire line + */ + def clearLine(n: Int) = control(n, 'K') +} + +object AnsiNav { + val resetUnderline = "\u001b[24m" + val resetForegroundColor = "\u001b[39m" + val resetBackgroundColor = "\u001b[49m" +} + +object TTY { + + // Prefer standard tools. Not sure why we need to do this, but for some + // reason the version installed by gnu-coreutils blows up sometimes giving + // "unable to perform all requested operations" + val pathedTput = if (new java.io.File("/usr/bin/tput").exists()) "/usr/bin/tput" else "tput" + val pathedStty = if (new java.io.File("/bin/stty").exists()) "/bin/stty" else "stty" + + def consoleDim(s: String) = { + import sys.process._ + Seq("bash", "-c", s"$pathedTput $s 2> /dev/tty").!!.trim.toInt + } + def init() = { + stty("-a") + + val width = consoleDim("cols") + val height = consoleDim("lines") +// Debug("Initializing, Width " + width) +// Debug("Initializing, Height " + height) + val initialConfig = stty("-g").trim + stty("-icanon min 1 -icrnl -inlcr -ixon") + sttyFailTolerant("dsusp undef") + stty("-echo") + stty("intr undef") +// Debug("") + (width, height, initialConfig) + } + + private def sttyCmd(s: String) = { + import sys.process._ + Seq("bash", "-c", s"$pathedStty $s < /dev/tty"): ProcessBuilder + } + + def stty(s: String) = + sttyCmd(s).!! + /* + * Executes a stty command for which failure is expected, hence the return + * status can be non-null and errors are ignored. + * This is appropriate for `stty dsusp undef`, since it's unsupported on Linux + * (http://man7.org/linux/man-pages/man3/termios.3.html). + */ + def sttyFailTolerant(s: String) = + sttyCmd(s ++ " 2> /dev/null").! + + def restore(initialConfig: String) = { + stty(initialConfig) + } +} + +/** + * A truly-lazy implementation of scala.Stream + */ +case class LazyList[T](headThunk: () => T, tailThunk: () => LazyList[T]) { + var rendered = false + lazy val head = { + rendered = true + headThunk() + } + + lazy val tail = tailThunk() + + def dropPrefix(prefix: Seq[T]) = { + @tailrec def rec(n: Int, l: LazyList[T]): Option[LazyList[T]] = { + if (n >= prefix.length) Some(l) + else if (prefix(n) == l.head) rec(n + 1, l.tail) + else None + } + rec(0, this) + } + override def toString = { + + @tailrec def rec(l: LazyList[T], res: List[T]): List[T] = { + if (l.rendered) rec(l.tailThunk(), l.head :: res) + else res + } + s"LazyList(${(rec(this, Nil).reverse ++ Seq("...")).mkString(",")})" + } + + def ~:(other: => T) = LazyList(() => other, () => this) +} + +object LazyList { + object ~: { + def unapply[T](x: LazyList[T]) = Some((x.head, x.tail)) + } + + def continually[T](t: => T): LazyList[T] = LazyList(() => t, () =>continually(t)) + + implicit class CS(ctx: StringContext) { + val base = ctx.parts.mkString + object p { + def unapply(s: LazyList[Int]): Option[LazyList[Int]] = { + s.dropPrefix(base.map(_.toInt)) + } + } + } +} diff --git a/src/dotty/tools/dotc/repl/ammonite/filters/BasicFilters.scala b/src/dotty/tools/dotc/repl/ammonite/filters/BasicFilters.scala new file mode 100644 index 000000000000..ebbcf214800b --- /dev/null +++ b/src/dotty/tools/dotc/repl/ammonite/filters/BasicFilters.scala @@ -0,0 +1,164 @@ +package dotty.tools +package dotc +package repl +package ammonite +package terminal +package filters + +import ammonite.terminal.FilterTools._ +import ammonite.terminal.LazyList._ +import ammonite.terminal.SpecialKeys._ +import ammonite.terminal.Filter +import ammonite.terminal._ + +/** + * Filters for simple operation of a terminal: cursor-navigation + * (including with all the modifier keys), enter/ctrl-c-exit, etc. + */ +object BasicFilters { + def all = Filter.merge( + navFilter, + exitFilter, + enterFilter, + clearFilter, + //loggingFilter, + typingFilter + ) + + def injectNewLine(b: Vector[Char], c: Int, rest: LazyList[Int]) = { + val (first, last) = b.splitAt(c) + TermState(rest, (first :+ '\n') ++ last, c + 1) + } + + + def navFilter = Filter.merge( + Case(Up)((b, c, m) => moveUp(b, c, m.width)), + Case(Down)((b, c, m) => moveDown(b, c, m.width)), + Case(Right)((b, c, m) => (b, c + 1)), + Case(Left)((b, c, m) => (b, c - 1)) + ) + + def tabColumn(indent: Int, b: Vector[Char], c: Int, rest: LazyList[Int]) = { + val (chunks, chunkStarts, chunkIndex) = FilterTools.findChunks(b, c) + val chunkCol = c - chunkStarts(chunkIndex) + val spacesToInject = indent - (chunkCol % indent) + val (lhs, rhs) = b.splitAt(c) + TS(rest, lhs ++ Vector.fill(spacesToInject)(' ') ++ rhs, c + spacesToInject) + } + + def tabFilter(indent: Int): Filter = Filter("tabFilter") { + case TS(9 ~: rest, b, c, _) => tabColumn(indent, b, c, rest) + } + + def loggingFilter: Filter = Filter("loggingFilter") { + case TS(Ctrl('q') ~: rest, b, c, _) => + println("Char Display Mode Enabled! Ctrl-C to exit") + var curr = rest + while (curr.head != 3) { + println("Char " + curr.head) + curr = curr.tail + } + TS(curr, b, c) + } + + def typingFilter: Filter = Filter("typingFilter") { + case TS(p"\u001b[3~$rest", b, c, _) => +// Debug("fn-delete") + val (first, last) = b.splitAt(c) + TS(rest, first ++ last.drop(1), c) + + case TS(127 ~: rest, b, c, _) => // Backspace + val (first, last) = b.splitAt(c) + TS(rest, first.dropRight(1) ++ last, c - 1) + + case TS(char ~: rest, b, c, _) => +// Debug("NORMAL CHAR " + char) + val (first, last) = b.splitAt(c) + TS(rest, (first :+ char.toChar) ++ last, c + 1) + } + + def doEnter(b: Vector[Char], c: Int, rest: LazyList[Int]) = { + val (chunks, chunkStarts, chunkIndex) = FilterTools.findChunks(b, c) + if (chunkIndex == chunks.length - 1) Result(b.mkString) + else injectNewLine(b, c, rest) + } + + def enterFilter: Filter = Filter("enterFilter") { + case TS(13 ~: rest, b, c, _) => doEnter(b, c, rest) // Enter + case TS(10 ~: rest, b, c, _) => doEnter(b, c, rest) // Enter + case TS(10 ~: 13 ~: rest, b, c, _) => doEnter(b, c, rest) // Enter + case TS(13 ~: 10 ~: rest, b, c, _) => doEnter(b, c, rest) // Enter + } + + def exitFilter: Filter = Filter("exitFilter") { + case TS(Ctrl('c') ~: rest, b, c, _) => + Result("") + case TS(Ctrl('d') ~: rest, b, c, _) => + // only exit if the line is empty, otherwise, behave like + // "delete" (i.e. delete one char to the right) + if (b.isEmpty) Exit else { + val (first, last) = b.splitAt(c) + TS(rest, first ++ last.drop(1), c) + } + case TS(-1 ~: rest, b, c, _) => Exit // java.io.Reader.read() produces -1 on EOF + } + + def clearFilter: Filter = Filter("clearFilter") { + case TS(Ctrl('l') ~: rest, b, c, _) => ClearScreen(TS(rest, b, c)) + } + + def moveStart(b: Vector[Char], c: Int, w: Int) = { + val (_, chunkStarts, chunkIndex) = findChunks(b, c) + val currentColumn = (c - chunkStarts(chunkIndex)) % w + b -> (c - currentColumn) + } + + def moveEnd(b: Vector[Char], c: Int, w: Int) = { + val (chunks, chunkStarts, chunkIndex) = findChunks(b, c) + val currentColumn = (c - chunkStarts(chunkIndex)) % w + val c1 = chunks.lift(chunkIndex + 1) match { + case Some(next) => + val boundary = chunkStarts(chunkIndex + 1) - 1 + if ((boundary - c) > (w - currentColumn)) { + val delta= w - currentColumn + c + delta + } + else boundary + case None => + c + 1 * 9999 + } + b -> c1 + } + + def moveUpDown( + b: Vector[Char], + c: Int, + w: Int, + boundaryOffset: Int, + nextChunkOffset: Int, + checkRes: Int, + check: (Int, Int) => Boolean, + isDown: Boolean + ) = { + val (chunks, chunkStarts, chunkIndex) = findChunks(b, c) + val offset = chunkStarts(chunkIndex + boundaryOffset) + if (check(checkRes, offset)) checkRes + else chunks.lift(chunkIndex + nextChunkOffset) match { + case None => c + nextChunkOffset * 9999 + case Some(next) => + val boundary = chunkStarts(chunkIndex + boundaryOffset) + val currentColumn = (c - chunkStarts(chunkIndex)) % w + + if (isDown) boundary + math.min(currentColumn, next) + else boundary + math.min(currentColumn - next % w, 0) - 1 + } + } + + def moveUp(b: Vector[Char], c: Int, w: Int) = { + b -> moveUpDown(b, c, w, 0, -1, c - w, _ > _, false) + } + + def moveDown(b: Vector[Char], c: Int, w: Int) = { + b -> moveUpDown(b, c, w, 1, 1, c + w, _ <= _, true) + } +} diff --git a/src/dotty/tools/dotc/repl/ammonite/filters/GUILikeFilters.scala b/src/dotty/tools/dotc/repl/ammonite/filters/GUILikeFilters.scala new file mode 100644 index 000000000000..69a9769c6379 --- /dev/null +++ b/src/dotty/tools/dotc/repl/ammonite/filters/GUILikeFilters.scala @@ -0,0 +1,170 @@ +package dotty.tools +package dotc +package repl +package ammonite +package terminal +package filters + +import terminal.FilterTools._ +import terminal.LazyList.~: +import terminal.SpecialKeys._ +import terminal.DelegateFilter +import terminal._ + +/** + * Filters have hook into the various {Ctrl,Shift,Fn,Alt}x{Up,Down,Left,Right} + * combination keys, and make them behave similarly as they would on a normal + * GUI text editor: alt-{left, right} for word movement, hold-down-shift for + * text selection, etc. + */ +object GUILikeFilters { + case class SelectionFilter(indent: Int) extends DelegateFilter { + def identifier = "SelectionFilter" + var mark: Option[Int] = None + + def setMark(c: Int) = { + Debug("setMark\t" + mark + "\t->\t" + c) + if (mark == None) mark = Some(c) + } + + def doIndent( + b: Vector[Char], + c: Int, + rest: LazyList[Int], + slicer: Vector[Char] => Int + ) = { + + val markValue = mark.get + val (chunks, chunkStarts, chunkIndex) = FilterTools.findChunks(b, c) + val min = chunkStarts.lastIndexWhere(_ <= math.min(c, markValue)) + val max = chunkStarts.indexWhere(_ > math.max(c, markValue)) + val splitPoints = chunkStarts.slice(min, max) + val frags = (0 +: splitPoints :+ 99999).sliding(2).zipWithIndex + + var firstOffset = 0 + val broken = + for((Seq(l, r), i) <- frags) yield { + val slice = b.slice(l, r) + if (i == 0) slice + else { + val cut = slicer(slice) + + if (i == 1) firstOffset = cut + + if (cut < 0) slice.drop(-cut) + else Vector.fill(cut)(' ') ++ slice + } + } + val flattened = broken.flatten.toVector + val deeperOffset = flattened.length - b.length + + val (newMark, newC) = + if (mark.get > c) (mark.get + deeperOffset, c + firstOffset) + else (mark.get + firstOffset, c + deeperOffset) + + mark = Some(newMark) + TS(rest, flattened, newC) + } + + def filter = Filter.merge( + + Case(ShiftUp) {(b, c, m) => setMark(c); BasicFilters.moveUp(b, c, m.width)}, + Case(ShiftDown) {(b, c, m) => setMark(c); BasicFilters.moveDown(b, c, m.width)}, + Case(ShiftRight) {(b, c, m) => setMark(c); (b, c + 1)}, + Case(ShiftLeft) {(b, c, m) => setMark(c); (b, c - 1)}, + Case(AltShiftUp) {(b, c, m) => setMark(c); BasicFilters.moveUp(b, c, m.width)}, + Case(AltShiftDown) {(b, c, m) => setMark(c); BasicFilters.moveDown(b, c, m.width)}, + Case(AltShiftRight) {(b, c, m) => setMark(c); wordRight(b, c)}, + Case(AltShiftLeft) {(b, c, m) => setMark(c); wordLeft(b, c)}, + Case(FnShiftRight) {(b, c, m) => setMark(c); BasicFilters.moveEnd(b, c, m.width)}, + Case(FnShiftLeft) {(b, c, m) => setMark(c); BasicFilters.moveStart(b, c, m.width)}, + Filter("fnOtherFilter") { + case TS(27 ~: 91 ~: 90 ~: rest, b, c, _) if mark.isDefined => + doIndent(b, c, rest, + slice => -math.min(slice.iterator.takeWhile(_ == ' ').size, indent) + ) + + case TS(9 ~: rest, b, c, _) if mark.isDefined => // Tab + doIndent(b, c, rest, + slice => indent + ) + + // Intercept every other character. + case TS(char ~: inputs, buffer, cursor, _) if mark.isDefined => + // If it's a special command, just cancel the current selection. + if (char.toChar.isControl && + char != 127 /*backspace*/ && + char != 13 /*enter*/ && + char != 10 /*enter*/) { + mark = None + TS(char ~: inputs, buffer, cursor) + } else { + // If it's a printable character, delete the current + // selection and write the printable character. + val Seq(min, max) = Seq(mark.get, cursor).sorted + mark = None + val newBuffer = buffer.take(min) ++ buffer.drop(max) + val newInputs = + if (char == 127) inputs + else char ~: inputs + TS(newInputs, newBuffer, min) + } + } + ) + } + + object SelectionFilter { + def mangleBuffer( + selectionFilter: SelectionFilter, + string: Ansi.Str, + cursor: Int, + startColor: Ansi.Attr + ) = { + selectionFilter.mark match { + case Some(mark) if mark != cursor => + val Seq(min, max) = Seq(cursor, mark).sorted + val displayOffset = if (cursor < mark) 0 else -1 + val newStr = string.overlay(startColor, min, max) + (newStr, displayOffset) + case _ => (string, 0) + } + } + } + + val fnFilter = Filter.merge( + Case(FnUp)((b, c, m) => (b, c - 9999)), + Case(FnDown)((b, c, m) => (b, c + 9999)), + Case(FnRight)((b, c, m) => BasicFilters.moveEnd(b, c, m.width)), + Case(FnLeft)((b, c, m) => BasicFilters.moveStart(b, c, m.width)) + ) + val altFilter = Filter.merge( + Case(AltUp) {(b, c, m) => BasicFilters.moveUp(b, c, m.width)}, + Case(AltDown) {(b, c, m) => BasicFilters.moveDown(b, c, m.width)}, + Case(AltRight) {(b, c, m) => wordRight(b, c)}, + Case(AltLeft) {(b, c, m) => wordLeft(b, c)} + ) + + val fnAltFilter = Filter.merge( + Case(FnAltUp) {(b, c, m) => (b, c)}, + Case(FnAltDown) {(b, c, m) => (b, c)}, + Case(FnAltRight) {(b, c, m) => (b, c)}, + Case(FnAltLeft) {(b, c, m) => (b, c)} + ) + val fnAltShiftFilter = Filter.merge( + Case(FnAltShiftRight) {(b, c, m) => (b, c)}, + Case(FnAltShiftLeft) {(b, c, m) => (b, c)} + ) + + + def consumeWord(b: Vector[Char], c: Int, delta: Int, offset: Int) = { + var current = c + while(b.isDefinedAt(current) && !b(current).isLetterOrDigit) current += delta + while(b.isDefinedAt(current) && b(current).isLetterOrDigit) current += delta + current + offset + } + + // c -1 to move at least one character! Otherwise you get stuck at the start of + // a word. + def wordLeft(b: Vector[Char], c: Int) = b -> consumeWord(b, c - 1, -1, 1) + def wordRight(b: Vector[Char], c: Int) = b -> consumeWord(b, c, 1, 0) +} diff --git a/src/dotty/tools/dotc/repl/ammonite/filters/HistoryFilter.scala b/src/dotty/tools/dotc/repl/ammonite/filters/HistoryFilter.scala new file mode 100644 index 000000000000..dac1c9d2394c --- /dev/null +++ b/src/dotty/tools/dotc/repl/ammonite/filters/HistoryFilter.scala @@ -0,0 +1,334 @@ +package dotty.tools +package dotc +package repl +package ammonite +package terminal +package filters + +import terminal.FilterTools._ +import terminal.LazyList._ +import terminal._ + +/** + * Provides history navigation up and down, saving the current line, a well + * as history-search functionality (`Ctrl R` in bash) letting you quickly find + * & filter previous commands by entering a sub-string. + */ +class HistoryFilter( + history: () => IndexedSeq[String], + commentStartColor: String, + commentEndColor: String +) extends DelegateFilter { + + + def identifier = "HistoryFilter" + /** + * `-1` means we haven't started looking at history, `n >= 0` means we're + * currently at history command `n` + */ + var historyIndex = -1 + + /** + * The term we're searching for, if any. + * + * - `None` means we're not searching for anything, e.g. we're just + * browsing history + * + * - `Some(term)` where `term` is not empty is what it normally looks + * like when we're searching for something + * + * - `Some(term)` where `term` is empty only really happens when you + * start searching and delete things, or if you `Ctrl-R` on an empty + * prompt + */ + var searchTerm: Option[Vector[Char]] = None + + /** + * Records the last buffer that the filter has observed while it's in + * search/history mode. If the new buffer differs from this, assume that + * some other filter modified the buffer and drop out of search/history + */ + var prevBuffer: Option[Vector[Char]] = None + + /** + * Kicks the HistoryFilter from passive-mode into search-history mode + */ + def startHistory(b: Vector[Char], c: Int): (Vector[Char], Int, String) = { + if (b.nonEmpty) searchTerm = Some(b) + up(Vector(), c) + } + + def searchHistory( + start: Int, + increment: Int, + buffer: Vector[Char], + skipped: Vector[Char] + ) = { + + def nextHistoryIndexFor(v: Vector[Char]) = { + HistoryFilter.findNewHistoryIndex(start, v, history(), increment, skipped) + } + + val (newHistoryIndex, newBuffer, newMsg, newCursor) = searchTerm match { + // We're not searching for anything, just browsing history. + // Pass in Vector.empty so we scroll through all items + case None => + val (i, b, c) = nextHistoryIndexFor(Vector.empty) + (i, b, "", 99999) + + // We're searching for some item with a particular search term + case Some(b) if b.nonEmpty => + val (i, b1, c) = nextHistoryIndexFor(b) + + val msg = + if (i.nonEmpty) "" + else commentStartColor + HistoryFilter.cannotFindSearchMessage + commentEndColor + + (i, b1, msg, c) + + // We're searching for nothing in particular; in this case, + // show a help message instead of an unhelpful, empty buffer + case Some(b) if b.isEmpty => + val msg = commentStartColor + HistoryFilter.emptySearchMessage + commentEndColor + // The cursor in this case always goes to zero + (Some(start), Vector(), msg, 0) + + } + + historyIndex = newHistoryIndex.getOrElse(-1) + + (newBuffer, newCursor, newMsg) + } + + def activeHistory = searchTerm.nonEmpty || historyIndex != -1 + def activeSearch = searchTerm.nonEmpty + + def up(b: Vector[Char], c: Int) = + searchHistory(historyIndex + 1, 1, b, b) + + def down(b: Vector[Char], c: Int) = + searchHistory(historyIndex - 1, -1, b, b) + + def wrap(rest: LazyList[Int], out: (Vector[Char], Int, String)) = + TS(rest, out._1, out._2, out._3) + + def ctrlR(b: Vector[Char], c: Int) = + if (activeSearch) up(b, c) + else { + searchTerm = Some(b) + up(Vector(), c) + } + + def printableChar(char: Char)(b: Vector[Char], c: Int) = { + searchTerm = searchTerm.map(_ :+ char) + searchHistory(historyIndex.max(0), 1, b :+ char, Vector()) + } + + def backspace(b: Vector[Char], c: Int) = { + searchTerm = searchTerm.map(_.dropRight(1)) + searchHistory(historyIndex, 1, b, Vector()) + } + + /** + * Predicate to check if either we're searching for a term or if we're in + * history-browsing mode and some predicate is true. + * + * Very often we want to capture keystrokes in search-mode more aggressively + * than in history-mode, e.g. search-mode drops you out more aggressively + * than history-mode does, and its up/down keys cycle through history more + * aggressively on every keystroke while history-mode only cycles when you + * reach the top/bottom line of the multi-line input. + */ + def searchOrHistoryAnd(cond: Boolean) = + activeSearch || (activeHistory && cond) + + val dropHistoryChars = Set(9, 13, 10) // Tab or Enter + + def endHistory() = { + historyIndex = -1 + searchTerm = None + } + + def filter = Filter.wrap("historyFilterWrap1") { + (ti: TermInfo) => { + prelude.op(ti) match { + case None => + prevBuffer = Some(ti.ts.buffer) + filter0.op(ti) match { + case Some(ts: TermState) => + prevBuffer = Some(ts.buffer) + Some(ts) + case x => x + } + case some => some + } + } + } + + def prelude: Filter = Filter("historyPrelude") { + case TS(inputs, b, c, _) if activeHistory && prevBuffer.exists(_ != b) => + endHistory() + prevBuffer = None + TS(inputs, b, c) + } + + def filter0: Filter = Filter("filter0") { + // Ways to kick off the history/search if you're not already in it + + // `Ctrl-R` + case TS(18 ~: rest, b, c, _) => wrap(rest, ctrlR(b, c)) + + // `Up` from the first line in the input + case TermInfo(TS(p"\u001b[A$rest", b, c, _), w) if firstRow(c, b, w) && !activeHistory => + wrap(rest, startHistory(b, c)) + + // `Ctrl P` + case TermInfo(TS(p"\u0010$rest", b, c, _), w) if firstRow(c, b, w) && !activeHistory => + wrap(rest, startHistory(b, c)) + + // `Page-Up` from first character starts history + case TermInfo(TS(p"\u001b[5~$rest", b, c, _), w) if c == 0 && !activeHistory => + wrap(rest, startHistory(b, c)) + + // Things you can do when you're already in the history search + + // Navigating up and down the history. Each up or down searches for + // the next thing that matches your current searchTerm + // Up + case TermInfo(TS(p"\u001b[A$rest", b, c, _), w) if searchOrHistoryAnd(firstRow(c, b, w)) => + wrap(rest, up(b, c)) + + // Ctrl P + case TermInfo(TS(p"\u0010$rest", b, c, _), w) if searchOrHistoryAnd(firstRow(c, b, w)) => + wrap(rest, up(b, c)) + + // `Page-Up` from first character cycles history up + case TermInfo(TS(p"\u001b[5~$rest", b, c, _), w) if searchOrHistoryAnd(c == 0) => + wrap(rest, up(b, c)) + + // Down + case TermInfo(TS(p"\u001b[B$rest", b, c, _), w) if searchOrHistoryAnd(lastRow(c, b, w)) => + wrap(rest, down(b, c)) + + // `Ctrl N` + + case TermInfo(TS(p"\u000e$rest", b, c, _), w) if searchOrHistoryAnd(lastRow(c, b, w)) => + wrap(rest, down(b, c)) + // `Page-Down` from last character cycles history down + case TermInfo(TS(p"\u001b[6~$rest", b, c, _), w) if searchOrHistoryAnd(c == b.length - 1) => + wrap(rest, down(b, c)) + + + // Intercept Backspace and delete a character in search-mode, preserving it, but + // letting it fall through and dropping you out of history-mode if you try to make + // edits + case TS(127 ~: rest, buffer, cursor, _) if activeSearch => + wrap(rest, backspace(buffer, cursor)) + + // Any other control characters drop you out of search mode, but only the + // set of `dropHistoryChars` drops you out of history mode + case TS(char ~: inputs, buffer, cursor, _) + if char.toChar.isControl && searchOrHistoryAnd(dropHistoryChars(char)) => + val newBuffer = + // If we're back to -1, it means we've wrapped around and are + // displaying the original search term with a wrap-around message + // in the terminal. Drop the message and just preserve the search term + if (historyIndex == -1) searchTerm.get + // If we're searching for an empty string, special-case this and return + // an empty buffer rather than the first history item (which would be + // the default) because that wouldn't make much sense + else if (searchTerm.exists(_.isEmpty)) Vector() + // Otherwise, pick whatever history entry we're at and use that + else history()(historyIndex).toVector + endHistory() + + TS(char ~: inputs, newBuffer, cursor) + + // Intercept every other printable character when search is on and + // enter it into the current search + case TS(char ~: rest, buffer, cursor, _) if activeSearch => + wrap(rest, printableChar(char.toChar)(buffer, cursor)) + + // If you're not in search but are in history, entering any printable + // characters kicks you out of it and preserves the current buffer. This + // makes it harder for you to accidentally lose work due to history-moves + case TS(char ~: rest, buffer, cursor, _) if activeHistory && !char.toChar.isControl => + historyIndex = -1 + TS(char ~: rest, buffer, cursor) + } +} + +object HistoryFilter { + + def mangleBuffer( + historyFilter: HistoryFilter, + buffer: Ansi.Str, + cursor: Int, + startColor: Ansi.Attr + ) = { + if (!historyFilter.activeSearch) buffer + else { + val (searchStart, searchEnd) = + if (historyFilter.searchTerm.get.isEmpty) (cursor, cursor+1) + else { + val start = buffer.plainText.indexOfSlice(historyFilter.searchTerm.get) + + val end = start + (historyFilter.searchTerm.get.length max 1) + (start, end) + } + + val newStr = buffer.overlay(startColor, searchStart, searchEnd) + newStr + } + } + + /** + * @param startIndex The first index to start looking from + * @param searchTerm The term we're searching from; can be empty + * @param history The history we're searching through + * @param indexIncrement Which direction to search, +1 or -1 + * @param skipped Any buffers which we should skip in our search results, + * e.g. because the user has seen them before. + */ + def findNewHistoryIndex( + startIndex: Int, + searchTerm: Vector[Char], + history: IndexedSeq[String], + indexIncrement: Int, + skipped: Vector[Char] + ) = { + /** + * `Some(i)` means we found a reasonable result at history element `i` + * `None` means we couldn't find anything, and should show a not-found + * error to the user + */ + def rec(i: Int): Option[Int] = history.lift(i) match { + // If i < 0, it means the user is pressing `down` too many times, which + // means it doesn't show anything but we shouldn't show an error + case None if i < 0 => Some(-1) + case None => None + case Some(s) if s.contains(searchTerm) && !s.contentEquals(skipped) => + Some(i) + case _ => rec(i + indexIncrement) + } + + val newHistoryIndex = rec(startIndex) + val foundIndex = newHistoryIndex.find(_ != -1) + val newBuffer = foundIndex match { + case None => searchTerm + case Some(i) => history(i).toVector + } + + val newCursor = foundIndex match { + case None => newBuffer.length + case Some(i) => history(i).indexOfSlice(searchTerm) + searchTerm.length + } + + (newHistoryIndex, newBuffer, newCursor) + } + + val emptySearchMessage = + s" ...enter the string to search for, then `up` for more" + val cannotFindSearchMessage = + s" ...can't be found in history; re-starting search" +} diff --git a/src/dotty/tools/dotc/repl/ammonite/filters/ReadlineFilters.scala b/src/dotty/tools/dotc/repl/ammonite/filters/ReadlineFilters.scala new file mode 100644 index 000000000000..eb79f2b04c8e --- /dev/null +++ b/src/dotty/tools/dotc/repl/ammonite/filters/ReadlineFilters.scala @@ -0,0 +1,165 @@ +package dotty.tools +package dotc +package repl +package ammonite +package terminal +package filters + +import terminal.FilterTools._ +import terminal.SpecialKeys._ +import terminal.{DelegateFilter, Filter, Terminal} +/** + * Filters for injection of readline-specific hotkeys, the sort that + * are available in bash, python and most other interactive command-lines + */ +object ReadlineFilters { + // www.bigsmoke.us/readline/shortcuts + // Ctrl-b <- one char + // Ctrl-f -> one char + // Alt-b <- one word + // Alt-f -> one word + // Ctrl-a <- start of line + // Ctrl-e -> end of line + // Ctrl-x-x Toggle start/end + + // Backspace <- delete char + // Del -> delete char + // Ctrl-u <- delete all + // Ctrl-k -> delete all + // Alt-d -> delete word + // Ctrl-w <- delete word + + // Ctrl-u/- Undo + // Ctrl-l clear screen + + // Ctrl-k -> cut all + // Alt-d -> cut word + // Alt-Backspace <- cut word + // Ctrl-y paste last cut + + /** + * Basic readline-style navigation, using all the obscure alphabet hotkeys + * rather than using arrows + */ + lazy val navFilter = Filter.merge( + Case(Ctrl('b'))((b, c, m) => (b, c - 1)), // <- one char + Case(Ctrl('f'))((b, c, m) => (b, c + 1)), // -> one char + Case(Alt + "b")((b, c, m) => GUILikeFilters.wordLeft(b, c)), // <- one word + Case(Alt + "B")((b, c, m) => GUILikeFilters.wordLeft(b, c)), // <- one word + Case(LinuxCtrlLeft)((b, c, m) => GUILikeFilters.wordLeft(b, c)), // <- one word + Case(Alt + "f")((b, c, m) => GUILikeFilters.wordRight(b, c)), // -> one word + Case(Alt + "F")((b, c, m) => GUILikeFilters.wordRight(b, c)), // -> one word + Case(LinuxCtrlRight)((b, c, m) => GUILikeFilters.wordRight(b, c)), // -> one word + Case(Home)((b, c, m) => BasicFilters.moveStart(b, c, m.width)), // <- one line + Case(HomeScreen)((b, c, m) => BasicFilters.moveStart(b, c, m.width)), // <- one line + Case(Ctrl('a'))((b, c, m) => BasicFilters.moveStart(b, c, m.width)), + Case(End)((b, c, m) => BasicFilters.moveEnd(b, c, m.width)), // -> one line + Case(EndScreen)((b, c, m) => BasicFilters.moveEnd(b, c, m.width)), // -> one line + Case(Ctrl('e'))((b, c, m) => BasicFilters.moveEnd(b, c, m.width)), + Case(Alt + "t")((b, c, m) => transposeWord(b, c)), + Case(Alt + "T")((b, c, m) => transposeWord(b, c)), + Case(Ctrl('t'))((b, c, m) => transposeLetter(b, c)) + ) + + def transposeLetter(b: Vector[Char], c: Int) = + // If there's no letter before the cursor to transpose, don't do anything + if (c == 0) (b, c) + else if (c == b.length) (b.dropRight(2) ++ b.takeRight(2).reverse, c) + else (b.patch(c-1, b.slice(c-1, c+1).reverse, 2), c + 1) + + def transposeWord(b: Vector[Char], c: Int) = { + val leftStart0 = GUILikeFilters.consumeWord(b, c - 1, -1, 1) + val leftEnd0 = GUILikeFilters.consumeWord(b, leftStart0, 1, 0) + val rightEnd = GUILikeFilters.consumeWord(b, c, 1, 0) + val rightStart = GUILikeFilters.consumeWord(b, rightEnd - 1, -1, 1) + + // If no word to the left to transpose, do nothing + if (leftStart0 == 0 && rightStart == 0) (b, c) + else { + val (leftStart, leftEnd) = + // If there is no word to the *right* to transpose, + // transpose the two words to the left instead + if (leftEnd0 == b.length && rightEnd == b.length) { + val leftStart = GUILikeFilters.consumeWord(b, leftStart0 - 1, -1, 1) + val leftEnd = GUILikeFilters.consumeWord(b, leftStart, 1, 0) + (leftStart, leftEnd) + }else (leftStart0, leftEnd0) + + val newB = + b.slice(0, leftStart) ++ + b.slice(rightStart, rightEnd) ++ + b.slice(leftEnd, rightStart) ++ + b.slice(leftStart, leftEnd) ++ + b.slice(rightEnd, b.length) + + (newB, rightEnd) + } + } + + /** + * All the cut-pasting logic, though for many people they simply + * use these shortcuts for deleting and don't use paste much at all. + */ + case class CutPasteFilter() extends DelegateFilter { + def identifier = "CutPasteFilter" + var accumulating = false + var currentCut = Vector.empty[Char] + def prepend(b: Vector[Char]) = { + if (accumulating) currentCut = b ++ currentCut + else currentCut = b + accumulating = true + } + def append(b: Vector[Char]) = { + if (accumulating) currentCut = currentCut ++ b + else currentCut = b + accumulating = true + } + def cutCharLeft(b: Vector[Char], c: Int) = { + /* Do not edit current cut. Zsh(zle) & Bash(readline) do not edit the yank ring for Ctrl-h */ + (b patch(from = c - 1, patch = Nil, replaced = 1), c - 1) + } + + def cutAllLeft(b: Vector[Char], c: Int) = { + prepend(b.take(c)) + (b.drop(c), 0) + } + def cutAllRight(b: Vector[Char], c: Int) = { + append(b.drop(c)) + (b.take(c), c) + } + + def cutWordRight(b: Vector[Char], c: Int) = { + val start = GUILikeFilters.consumeWord(b, c, 1, 0) + append(b.slice(c, start)) + (b.take(c) ++ b.drop(start), c) + } + + def cutWordLeft(b: Vector[Char], c: Int) = { + val start = GUILikeFilters.consumeWord(b, c - 1, -1, 1) + prepend(b.slice(start, c)) + (b.take(start) ++ b.drop(c), start) + } + + def paste(b: Vector[Char], c: Int) = { + accumulating = false + (b.take(c) ++ currentCut ++ b.drop(c), c + currentCut.length) + } + + def filter = Filter.merge( + Case(Ctrl('u'))((b, c, m) => cutAllLeft(b, c)), + Case(Ctrl('k'))((b, c, m) => cutAllRight(b, c)), + Case(Alt + "d")((b, c, m) => cutWordRight(b, c)), + Case(Ctrl('w'))((b, c, m) => cutWordLeft(b, c)), + Case(Alt + "\u007f")((b, c, m) => cutWordLeft(b, c)), + // weird hacks to make it run code every time without having to be the one + // handling the input; ideally we'd change Filter to be something + // other than a PartialFunction, but for now this will do. + + // If some command goes through that's not appending/prepending to the + // kill ring, stop appending and allow the next kill to override it + Filter.wrap("ReadLineFilterWrap") {_ => accumulating = false; None}, + Case(Ctrl('h'))((b, c, m) => cutCharLeft(b, c)), + Case(Ctrl('y'))((b, c, m) => paste(b, c)) + ) + } +} diff --git a/src/dotty/tools/dotc/repl/ammonite/filters/UndoFilter.scala b/src/dotty/tools/dotc/repl/ammonite/filters/UndoFilter.scala new file mode 100644 index 000000000000..c265a7a4c0e5 --- /dev/null +++ b/src/dotty/tools/dotc/repl/ammonite/filters/UndoFilter.scala @@ -0,0 +1,157 @@ +package dotty.tools +package dotc +package repl +package ammonite +package terminal +package filters + +import terminal.FilterTools._ +import terminal.LazyList.~: +import terminal._ +import scala.collection.mutable + +/** + * A filter that implements "undo" functionality in the ammonite REPL. It + * shares the same `Ctrl -` hotkey that the bash undo, but shares behavior + * with the undo behavior in desktop text editors: + * + * - Multiple `delete`s in a row get collapsed + * - In addition to edits you can undo cursor movements: undo will bring your + * cursor back to location of previous edits before it undoes them + * - Provides "redo" functionality under `Alt -`/`Esc -`: un-undo the things + * you didn't actually want to undo! + * + * @param maxUndo: the maximum number of undo-frames that are stored. + */ +case class UndoFilter(maxUndo: Int = 25) extends DelegateFilter { + def identifier = "UndoFilter" + /** + * The current stack of states that undo/redo would cycle through. + * + * Not really the appropriate data structure, since when it reaches + * `maxUndo` in length we remove one element from the start whenever we + * append one element to the end, which costs `O(n)`. On the other hand, + * It also costs `O(n)` to maintain the buffer of previous states, and + * so `n` is probably going to be pretty small anyway (tens?) so `O(n)` + * is perfectly fine. + */ + val undoBuffer = mutable.Buffer[(Vector[Char], Int)](Vector[Char]() -> 0) + + /** + * The current position in the undoStack that the terminal is currently in. + */ + var undoIndex = 0 + /** + * An enum representing what the user is "currently" doing. Used to + * collapse sequential actions into one undo step: e.g. 10 plai + * chars typed becomes 1 undo step, or 10 chars deleted becomes one undo + * step, but 4 chars typed followed by 3 chars deleted followed by 3 chars + * typed gets grouped into 3 different undo steps + */ + var state = UndoState.Default + def currentUndo = undoBuffer(undoBuffer.length - undoIndex - 1) + + def undo(b: Vector[Char], c: Int) = { + val msg = + if (undoIndex >= undoBuffer.length - 1) UndoFilter.cannotUndoMsg + else { + undoIndex += 1 + state = UndoState.Default + UndoFilter.undoMsg + } + val (b1, c1) = currentUndo + (b1, c1, msg) + } + + def redo(b: Vector[Char], c: Int) = { + val msg = + if (undoIndex <= 0) UndoFilter.cannotRedoMsg + else { + undoIndex -= 1 + state = UndoState.Default + UndoFilter.redoMsg + } + + currentUndo + val (b1, c1) = currentUndo + (b1, c1, msg) + } + + def wrap(bc: (Vector[Char], Int, Ansi.Str), rest: LazyList[Int]) = { + val (b, c, msg) = bc + TS(rest, b, c, msg) + } + + def pushUndos(b: Vector[Char], c: Int) = { + val (lastB, lastC) = currentUndo + // Since we don't have access to the `typingFilter` in this code, we + // instead attempt to reverse-engineer "what happened" to the buffer by + // comparing the old one with the new. + // + // It turns out that it's not that hard to identify the few cases we care + // about, since they're all result in either 0 or 1 chars being different + // between old and new buffers. + val newState = + // Nothing changed means nothing changed + if (lastC == c && lastB == b) state + // if cursor advanced 1, and buffer grew by 1 at the cursor, we're typing + else if (lastC + 1 == c && lastB == b.patch(c-1, Nil, 1)) UndoState.Typing + // cursor moved left 1, and buffer lost 1 char at that point, we're deleting + else if (lastC - 1 == c && lastB.patch(c, Nil, 1) == b) UndoState.Deleting + // cursor didn't move, and buffer lost 1 char at that point, we're also deleting + else if (lastC == c && lastB.patch(c - 1, Nil, 1) == b) UndoState.Deleting + // cursor moved around but buffer didn't change, we're navigating + else if (lastC != c && lastB == b) UndoState.Navigating + // otherwise, sit in the "Default" state where every change is recorded. + else UndoState.Default + + if (state != newState || newState == UndoState.Default && (lastB, lastC) != (b, c)) { + // If something changes: either we enter a new `UndoState`, or we're in + // the `Default` undo state and the terminal buffer/cursor change, then + // truncate the `undoStack` and add a new tuple to the stack that we can + // build upon. This means that we lose all ability to re-do actions after + // someone starts making edits, which is consistent with most other + // editors + state = newState + undoBuffer.remove(undoBuffer.length - undoIndex, undoIndex) + undoIndex = 0 + + if (undoBuffer.length == maxUndo) undoBuffer.remove(0) + + undoBuffer.append(b -> c) + } else if (undoIndex == 0 && (b, c) != undoBuffer(undoBuffer.length - 1)) { + undoBuffer(undoBuffer.length - 1) = (b, c) + } + + state = newState + } + + def filter = Filter.merge( + Filter.wrap("undoFilterWrapped") { + case TS(q ~: rest, b, c, _) => + pushUndos(b, c) + None + }, + Filter("undoFilter") { + case TS(31 ~: rest, b, c, _) => wrap(undo(b, c), rest) + case TS(27 ~: 114 ~: rest, b, c, _) => wrap(undo(b, c), rest) + case TS(27 ~: 45 ~: rest, b, c, _) => wrap(redo(b, c), rest) + } + ) +} + + +sealed class UndoState(override val toString: String) +object UndoState { + val Default = new UndoState("Default") + val Typing = new UndoState("Typing") + val Deleting = new UndoState("Deleting") + val Navigating = new UndoState("Navigating") +} + +object UndoFilter { + val undoMsg = Ansi.Color.Blue(" ...undoing last action, `Alt -` or `Esc -` to redo") + val cannotUndoMsg = Ansi.Color.Blue(" ...no more actions to undo") + val redoMsg = Ansi.Color.Blue(" ...redoing last action") + val cannotRedoMsg = Ansi.Color.Blue(" ...no more actions to redo") +} diff --git a/test/test/TestREPL.scala b/test/test/TestREPL.scala index d01038c434f6..0fe05794f496 100644 --- a/test/test/TestREPL.scala +++ b/test/test/TestREPL.scala @@ -20,7 +20,7 @@ class TestREPL(script: String) extends REPL { override lazy val config = new REPL.Config { override val output = new NewLinePrintWriter(out) - override def input(implicit ctx: Context) = new InteractiveReader { + override def input(in: Interpreter)(implicit ctx: Context) = new InteractiveReader { val lines = script.lines def readLine(prompt: String): String = { val line = lines.next @@ -44,4 +44,4 @@ class TestREPL(script: String) extends REPL { assert(false) } } -} \ No newline at end of file +} diff --git a/test/test/DottyRepl.scala b/test/test/TypeStealer.scala similarity index 96% rename from test/test/DottyRepl.scala rename to test/test/TypeStealer.scala index 74f6ee2485cb..ae48d9a5b9af 100644 --- a/test/test/DottyRepl.scala +++ b/test/test/TypeStealer.scala @@ -6,7 +6,7 @@ import scala.tools.nsc.Settings * Dotty requires a mangled bootclasspath to start. It means that `console` mode of sbt doesn't work for us. * At least I(Dmitry) wasn't able to make sbt fork in console */ -object DottyRepl { +object TypeStealer { def main(args: Array[String]): Unit = { def repl = new ILoop {} diff --git a/tests/repl/imports.check b/tests/repl/imports.check index 3fa10328348a..3a7e9341efa2 100644 --- a/tests/repl/imports.check +++ b/tests/repl/imports.check @@ -2,9 +2,7 @@ scala> import scala.collection.mutable import scala.collection.mutable scala> val buf = mutable.ListBuffer[Int]() buf: scala.collection.mutable.ListBuffer[Int] = ListBuffer() -scala> object o { - | val xs = List(1, 2, 3) - | } +scala> object o { val xs = List(1, 2, 3) } defined module o scala> import o._ import o._ diff --git a/tests/repl/multilines.check b/tests/repl/multilines.check deleted file mode 100644 index 3bc32707ebd3..000000000000 --- a/tests/repl/multilines.check +++ /dev/null @@ -1,33 +0,0 @@ -scala> val x = """alpha - | - | omega""" -x: String = -alpha - -omega -scala> val y = """abc - | |def - | |ghi - | """.stripMargin -y: String = -abc -def -ghi - -scala> val z = { - | def square(x: Int) = x * x - | val xs = List(1, 2, 3) - | square(xs) - | } -:8: error: type mismatch: - found : scala.collection.immutable.List[Int](xs) - required: Int - square(xs) - ^ -scala> val z = { - | def square(x: Int) = x * x - | val xs = List(1, 2, 3) - | xs.map(square) - | } -z: scala.collection.immutable.List[Int] = List(1, 4, 9) -scala> :quit