Skip to content

Commit

Permalink
Merge pull request #8905 from SethTisue/jline3-improved-completion
Browse files Browse the repository at this point in the history
REPL: JLine 3: improve tab completion behavior
  • Loading branch information
SethTisue committed Apr 22, 2020
2 parents 8de6e34 + ee2b5ae commit 7d29ccc
Show file tree
Hide file tree
Showing 12 changed files with 135 additions and 182 deletions.
2 changes: 1 addition & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -732,7 +732,7 @@ lazy val replFrontend = configureAsSubproject(Project("repl-frontend", file(".")
.settings(
libraryDependencies ++= jlineDeps,
name := "scala-repl-frontend",
scalacOptions in Compile += "-Xlint:-deprecation,-inaccessible,-nonlocal-return,-valpattern,-doc-detached,_",
scalacOptions in Compile += "-Xlint:-inaccessible,-nonlocal-return,-valpattern,-doc-detached,_",
)
.settings(
connectInput in run := true,
Expand Down
29 changes: 17 additions & 12 deletions src/repl-frontend/scala/tools/nsc/interpreter/jline/Reader.scala
Original file line number Diff line number Diff line change
Expand Up @@ -220,38 +220,43 @@ class Completion(delegate: shell.Completion) extends shell.Completion with Compl
// REPL Completion
def complete(buffer: String, cursor: Int): shell.CompletionResult = delegate.complete(buffer, cursor)

def reset(): Unit = delegate.reset()

// JLine Completer
def complete(lineReader: LineReader, parsedLine: ParsedLine, newCandidates: JList[Candidate]): Unit = {
def candidateForResult(s: String): Candidate = {
val value = s
val displayed = s
def candidateForResult(cc: CompletionCandidate): Candidate = {
val value = cc.defString
val displayed = cc.defString + (cc.arity match {
case CompletionCandidate.Nullary => ""
case CompletionCandidate.Nilary => "()"
case _ => "("
})
val group = null // results may be grouped
val descr = null // "Scala member", displayed alongside `displayed`
val descr = // displayed alongside
if (cc.isDeprecated) "deprecated"
else if (cc.isUniversal) "universal"
else null
val suffix = null // such as slash after directory name
val key = null // same key implies mergeable result
val complete = false // more to complete?
new Candidate(value, displayed, group, descr, suffix, key, complete)
}
val result = complete(parsedLine.line, parsedLine.cursor)
result.candidates match {
result.candidates.map(_.defString) match {
// the presence of the empty string here is a signal that the symbol
// is already complete and so instead of completing, we want to show
// the user the method signature. there are various JLine 3 features
// one might use to do this instead; sticking to basics for now
case "" :: defStrings if defStrings.nonEmpty =>
// specifics here are cargo-culted from Ammonite
lineReader.getTerminal.writer.println()
for (s <- defStrings)
lineReader.getTerminal.writer.println(s)
for (cc <- result.candidates.tail)
lineReader.getTerminal.writer.println(cc.defString)
lineReader.callWidget(LineReader.REDRAW_LINE)
lineReader.callWidget(LineReader.REDISPLAY)
lineReader.getTerminal.flush()
// normal completion
case cs =>
for (s <- result.candidates)
newCandidates.add(candidateForResult(s))
case _ =>
for (cc <- result.candidates)
newCandidates.add(candidateForResult(cc))
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,17 @@
* additional information regarding copyright ownership.
*/

package scala.tools.nsc.interpreter.shell
package scala.tools.nsc.interpreter
package shell

trait Completion {
def reset(): Unit

def complete(buffer: String, cursor: Int): CompletionResult
}
object NoCompletion extends Completion {
def reset() = ()
def complete(buffer: String, cursor: Int) = NoCompletions
}

case class CompletionResult(cursor: Int, candidates: List[String]) {
case class CompletionResult(cursor: Int, candidates: List[CompletionCandidate]) {
final def orElse(other: => CompletionResult): CompletionResult =
if (candidates.nonEmpty) this else other
}
Expand All @@ -32,7 +30,6 @@ object CompletionResult {
object NoCompletions extends CompletionResult(-1, Nil)

case class MultiCompletion(underlying: Completion*) extends Completion {
override def reset() = underlying.foreach(_.reset())
override def complete(buffer: String, cursor: Int) =
underlying.foldLeft(CompletionResult.empty)((r,c) => r.orElse(c.complete(buffer, cursor)))
}
28 changes: 14 additions & 14 deletions src/repl-frontend/scala/tools/nsc/interpreter/shell/ILoop.scala
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@

// Copyright 2005-2017 LAMP/EPFL and Lightbend, Inc.

package scala.tools.nsc.interpreter.shell
package scala.tools.nsc.interpreter
package shell

import java.io.{BufferedReader, PrintWriter}
import java.nio.file.Files
Expand Down Expand Up @@ -219,12 +220,13 @@ class ILoop(config: ShellConfig, inOverride: BufferedReader = null,

// complete filename
val fileCompletion: Completion = new Completion {
def reset(): Unit = ()
val emptyWord = """(\s+)$""".r.unanchored
val directorily = """(\S*/)$""".r.unanchored
val trailingWord = """(\S+)$""".r.unanchored
def listed(i: Int, dir: Option[Path]) =
dir.filter(_.isDirectory).map(d => CompletionResult(i, d.toDirectory.list.map(_.name).toList)).getOrElse(NoCompletions)
dir.filter(_.isDirectory)
.map(d => CompletionResult(i, d.toDirectory.list.map(x => CompletionCandidate(x.name)).toList))
.getOrElse(NoCompletions)
def listedIn(dir: Directory, name: String) = dir.list.filter(_.name.startsWith(name)).map(_.name).toList
def complete(buffer: String, cursor: Int): CompletionResult =
buffer.substring(0, cursor) match {
Expand All @@ -237,21 +239,21 @@ class ILoop(config: ShellConfig, inOverride: BufferedReader = null,
else if (f.isDirectory) (cursor - s.length, List(s"${f.toAbsolute.path}/"))
else if (f.parent.exists) (cursor - f.name.length, listedIn(f.parent.toDirectory, f.name))
else (-1, Nil)
if (maybes.isEmpty) NoCompletions else CompletionResult(i, maybes)
if (maybes.isEmpty) NoCompletions else CompletionResult(i, maybes.map(CompletionCandidate(_)))
case _ => NoCompletions
}
}

// complete settings name
val settingsCompletion: Completion = new Completion {
def reset(): Unit = ()
val trailingWord = """(\S+)$""".r.unanchored
def complete(buffer: String, cursor: Int): CompletionResult = {
buffer.substring(0, cursor) match {
case trailingWord(s) =>
val maybes = intp.visibleSettings.filter(_.name.startsWith(s)).map(_.name)
.filterNot(cond(_) { case "-"|"-X"|"-Y" => true }).sorted
if (maybes.isEmpty) NoCompletions else CompletionResult(cursor - s.length, maybes)
if (maybes.isEmpty) NoCompletions
else CompletionResult(cursor - s.length, maybes.map(CompletionCandidate(_)))
case _ => NoCompletions
}
}
Expand Down Expand Up @@ -539,7 +541,6 @@ class ILoop(config: ShellConfig, inOverride: BufferedReader = null,
MultiCompletion(shellCompletion, rc)
}
val shellCompletion = new Completion {
override def reset() = ()
override def complete(buffer: String, cursor: Int) =
if (buffer.startsWith(":")) colonCompletion(buffer, cursor).complete(buffer, cursor)
else NoCompletions
Expand All @@ -549,14 +550,17 @@ class ILoop(config: ShellConfig, inOverride: BufferedReader = null,
// it's also used by ReplTest
def completionsCommand(what: String): Result = {
val completions = in.completion.complete(what, what.length)
if (completions.candidates.nonEmpty) {
val candidates = completions.candidates.filterNot(_.isUniversal)
// condition here is a bit weird because of the weird hack we have where
// the first candidate having an empty defString means it's not really
// completion, but showing the method signature instead
if (candidates.headOption.exists(_.defString.nonEmpty)) {
val prefix =
if (completions == NoCompletions) ""
else what.substring(0, completions.cursor)
// hvesalai (emacs sbt-mode maintainer) says it's important to echo only once and not per-line
echo(
completions.candidates
.map(c => s"[completions] $prefix$c")
candidates.map(c => s"[completions] $prefix${c.defString}")
.mkString("\n")
)
}
Expand Down Expand Up @@ -878,7 +882,6 @@ class ILoop(config: ShellConfig, inOverride: BufferedReader = null,
echo("You typed two blank lines. Starting a new command.")
None
case Incomplete =>
in.completion.reset()
in.readLine(paste.ContinuePrompt) match {
case null =>
// partial input with no input forthcoming,
Expand All @@ -891,9 +894,6 @@ class ILoop(config: ShellConfig, inOverride: BufferedReader = null,
}
}

// signal completion that non-completion input has been received
in.completion.reset()

start match {
case "" | lineComment() => None // empty or line comment, do nothing
case paste() =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
* additional information regarding copyright ownership.
*/

package scala.tools.nsc.interpreter.shell
package scala.tools.nsc.interpreter
package shell

import java.io.{PrintWriter => JPrintWriter}

Expand Down Expand Up @@ -136,16 +137,16 @@ trait LoopCommands {
case cmd :: Nil =>
val completion = if (cmd.isInstanceOf[NullaryCmd] || cursor < line.length) cmd.name else cmd.name + " "
new Completion {
def reset(): Unit = ()
def complete(buffer: String, cursor: Int) = CompletionResult(cursor = 1, List(completion))
def complete(buffer: String, cursor: Int) =
CompletionResult(cursor = 1, List(CompletionCandidate(completion)))
}
case cmd :: rest =>
new Completion {
def reset(): Unit = ()
def complete(buffer: String, cursor: Int) = CompletionResult(cursor = 1, cmds.map(_.name))
def complete(buffer: String, cursor: Int) =
CompletionResult(cursor = 1, cmds.map(cmd => CompletionCandidate(cmd.name)))
}
}
case _ => NoCompletion
case _ => NoCompletion
}

class NullaryCmd(name: String, help: String, detailedHelp: Option[String],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
* additional information regarding copyright ownership.
*/

package scala.tools.nsc.interpreter.shell
package scala.tools.nsc.interpreter
package shell

import scala.util.control.NonFatal
import scala.tools.nsc.interpreter.Repl
Expand All @@ -19,7 +20,6 @@ import scala.tools.nsc.interpreter.Naming
/** Completion for the REPL.
*/
class ReplCompletion(intp: Repl, val accumulator: Accumulator = new Accumulator) extends Completion {
import ReplCompletion._

def complete(buffer: String, cursor: Int): CompletionResult = {
// special case for:
Expand All @@ -35,25 +35,12 @@ class ReplCompletion(intp: Repl, val accumulator: Accumulator = new Accumulator)
codeCompletion(bufferWithMultiLine, cursor1)
}

private var lastRequest = NoRequest
private var tabCount = 0

def reset(): Unit = { tabCount = 0 ; lastRequest = NoRequest }

// A convenience for testing
def complete(before: String, after: String = ""): CompletionResult = complete(before + after, before.length)

private def codeCompletion(buf: String, cursor: Int): CompletionResult = {
require(cursor >= 0 && cursor <= buf.length)

val request = Request(buf, cursor)
if (request == lastRequest)
tabCount += 1
else {
tabCount = 0
lastRequest = request
}

// secret handshakes
val slashPrint = """.*// *print *""".r
val slashPrintRaw = """.*// *printRaw *""".r
Expand All @@ -63,10 +50,17 @@ class ReplCompletion(intp: Repl, val accumulator: Accumulator = new Accumulator)
case Left(_) => NoCompletions
case Right(result) => try {
buf match {
case slashPrint() if cursor == buf.length => CompletionResult(cursor, "" :: Naming.unmangle(result.print) :: Nil)
case slashPrintRaw() if cursor == buf.length => CompletionResult(cursor, "" :: result.print :: Nil)
case slashTypeAt(start, end) if cursor == buf.length => CompletionResult(cursor, "" :: result.typeAt(start.toInt, end.toInt) :: Nil)
case _ => val (c, r) = result.candidates(tabCount); CompletionResult(c, r)
case slashPrint() if cursor == buf.length =>
CompletionResult(cursor, CompletionCandidate.fromStrings("" :: Naming.unmangle(result.print) :: Nil))
case slashPrintRaw() if cursor == buf.length =>
CompletionResult(cursor, CompletionCandidate.fromStrings("" :: result.print :: Nil))
case slashTypeAt(start, end) if cursor == buf.length =>
CompletionResult(cursor, CompletionCandidate.fromStrings("" :: result.typeAt(start.toInt, end.toInt) :: Nil))
case _ =>
// under JLine 3, we no longer use the tabCount concept, so tabCount is always 1
// which always gives us all completions
val (c, r) = result.completionCandidates(tabCount = 1)
CompletionResult(c, r)
}
} finally result.cleanup()
}
Expand All @@ -77,8 +71,3 @@ class ReplCompletion(intp: Repl, val accumulator: Accumulator = new Accumulator)
}
}
}

object ReplCompletion {
private case class Request(line: String, cursor: Int)
private val NoRequest = Request("", -1)
}
24 changes: 23 additions & 1 deletion src/repl/scala/tools/nsc/interpreter/Interface.scala
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,29 @@ trait PresentationCompilationResult {

def typeAt(start: Int, end: Int): String

def candidates(tabCount: Int): (Int, List[String])
@deprecated("`completionCandidates` returns richer information (CompletionCandidates, not just strings)", since = "2.13.2")
def candidates(tabCount: Int): (Int, List[String]) =
completionCandidates(tabCount) match {
case (cursor, cands) =>
(cursor, cands.map(_.defString))
}

def completionCandidates(tabCount: Int = -1): (Int, List[CompletionCandidate])
}

case class CompletionCandidate(
defString: String,
arity: CompletionCandidate.Arity = CompletionCandidate.Nullary,
isDeprecated: Boolean = false,
isUniversal: Boolean = false)
object CompletionCandidate {
sealed trait Arity
case object Nullary extends Arity
case object Nilary extends Arity
case object Other extends Arity
// purely for convenience
def fromStrings(defStrings: List[String]): List[CompletionCandidate] =
defStrings.map(CompletionCandidate(_))
}

case class TokenData(token: Int, start: Int, end: Int, isIdentifier: Boolean)

0 comments on commit 7d29ccc

Please sign in to comment.