Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve tab completion and code assist in REPL #9656

Merged
merged 8 commits into from Aug 9, 2021
64 changes: 25 additions & 39 deletions src/interactive/scala/tools/nsc/interactive/Global.scala
Expand Up @@ -1197,54 +1197,36 @@ class Global(settings: Settings, _reporter: Reporter, projectName: String = "")
override def positionDelta = 0
override def forImport: Boolean = false
}
private val CamelRegex = "([A-Z][^A-Z]*)".r
private def camelComponents(s: String, allowSnake: Boolean): List[String] = {
if (allowSnake && s.forall(c => c.isUpper || c == '_')) s.split('_').toList.filterNot(_.isEmpty)
else CamelRegex.findAllIn("X" + s).toList match { case head :: tail => head.drop(1) :: tail; case Nil => Nil }
}
def camelMatch(entered: Name): Name => Boolean = {
val enteredS = entered.toString
val enteredLowercaseSet = enteredS.toLowerCase().toSet
val allowSnake = !enteredS.contains('_')

{
candidate: Name =>
def candidateChunks = camelComponents(candidate.dropLocal.toString, allowSnake)
// Loosely based on IntelliJ's autocompletion: the user can just write everything in
// lowercase, as we'll let `isl` match `GenIndexedSeqLike` or `isLovely`.
def lenientMatch(entered: String, candidate: List[String], matchCount: Int): Boolean = {
candidate match {
case Nil => entered.isEmpty && matchCount > 0
case head :: tail =>
val enteredAlternatives = Set(entered, entered.capitalize)
val n = head.toIterable.lazyZip(entered).count {case (c, e) => c == e || (c.isUpper && c == e.toUpper)}
head.take(n).inits.exists(init =>
enteredAlternatives.exists(entered =>
lenientMatch(entered.stripPrefix(init), tail, matchCount + (if (init.isEmpty) 0 else 1))
)
)
}
}
val containsAllEnteredChars = {
// Trying to rule out some candidates quickly before the more expensive `lenientMatch`
val candidateLowercaseSet = candidate.toString.toLowerCase().toSet
enteredLowercaseSet.diff(candidateLowercaseSet).isEmpty
}
containsAllEnteredChars && lenientMatch(enteredS, candidateChunks, 0)
}
}
}

final def completionsAt(pos: Position): CompletionResult = {
val focus1: Tree = typedTreeAt(pos)
def typeCompletions(tree: Tree, qual: Tree, nameStart: Int, name: Name): CompletionResult = {
val qualPos = qual.pos
val allTypeMembers = typeMembers(qualPos).last
val saved = tree.tpe
// Force `typeMembers` to complete via the prefix, not the type of the Select itself.
tree.setType(ErrorType)
val allTypeMembers = try {
typeMembers(qualPos).last
} finally {
tree.setType(saved)
}
val positionDelta: Int = pos.start - nameStart
val subName: Name = name.newName(new String(pos.source.content, nameStart, pos.start - nameStart)).encodedName
CompletionResult.TypeMembers(positionDelta, qual, tree, allTypeMembers, subName)
}
focus1 match {
case Apply(Select(qual, name), _) if qual.hasAttachment[InterpolatedString.type] =>
// This special case makes CompletionTest.incompleteStringInterpolation work.
// In incomplete code, the parser treats `foo""` as a nested string interpolation, even
// though it is likely that the user wanted to complete `fooBar` before adding the closing brace.
// val fooBar = 42; s"abc ${foo<TAB>"
//
// TODO: We could also complete the selection here to expand `ra<TAB>"..."` to `raw"..."`.
val allMembers = scopeMembers(pos)
val positionDelta: Int = pos.start - focus1.pos.start
val subName = name.subName(0, positionDelta)
CompletionResult.ScopeMembers(positionDelta, allMembers, subName, forImport = false)
case imp@Import(i @ Ident(name), head :: Nil) if head.name == nme.ERROR =>
val allMembers = scopeMembers(pos)
val nameStart = i.pos.start
Expand All @@ -1259,9 +1241,13 @@ class Global(settings: Settings, _reporter: Reporter, projectName: String = "")
}
case sel@Select(qual, name) =>
val qualPos = qual.pos
def fallback = qualPos.end + 2
val effectiveQualEnd = if (qualPos.isRange) qualPos.end else qualPos.point - 1
def fallback = {
effectiveQualEnd + 2
}
val source = pos.source
val nameStart: Int = (focus1.pos.end - 1 to qualPos.end by -1).find(p =>

val nameStart: Int = (focus1.pos.end - 1 to effectiveQualEnd by -1).find(p =>
source.identifier(source.position(p)).exists(_.length == 0)
).map(_ + 1).getOrElse(fallback)
typeCompletions(sel, qual, nameStart, name)
Expand Down
3 changes: 2 additions & 1 deletion src/reflect/scala/reflect/internal/Positions.scala
Expand Up @@ -345,7 +345,8 @@ trait Positions extends api.Positions { self: SymbolTable =>
if (t.pos includes pos) {
if (isEligible(t)) last = t
super.traverse(t)
} else t match {
}
t match {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this was a latent bug in the presentation compiler. It became apparent in the REPL completion tests after I (mostly) removed the hack of inserting __DUMMY__ at the cursor in this PR.

In erroneous code, the range position of the TypeDef of an annotated parameter can contain the annotation itself. So we should always try traversing into .symbol.annotations.map(_.original).

case mdef: MemberDef =>
val annTrees = mdef.mods.annotations match {
case Nil if mdef.symbol != null =>
Expand Down
40 changes: 22 additions & 18 deletions src/reflect/scala/reflect/internal/Printers.scala
Expand Up @@ -781,26 +781,30 @@ trait Printers extends api.Printers { self: SymbolTable =>
print("class ", printedName(name))
printTypeParams(tparams)

val build.SyntacticClassDef(_, _, _, ctorMods, vparamss, earlyDefs, parents, selfType, body) = cl: @unchecked

// constructor's modifier
if (ctorMods.hasFlag(AccessFlags) || ctorMods.hasAccessBoundary) {
print(" ")
printModifiers(ctorMods, primaryCtorParam = false)
}
cl match {
case build.SyntacticClassDef(_, _, _, ctorMods, vparamss, earlyDefs, parents, selfType, body) =>
// constructor's modifier
if (ctorMods.hasFlag(AccessFlags) || ctorMods.hasAccessBoundary) {
print(" ")
printModifiers(ctorMods, primaryCtorParam = false)
}

def printConstrParams(ts: List[ValDef]): Unit = {
parenthesize() {
printImplicitInParamsList(ts)
printSeq(ts)(printVParam(_, primaryCtorParam = true))(print(", "))
}
}
// constructor's params processing (don't print single empty constructor param list)
vparamss match {
case Nil | List(Nil) if !mods.isCase && !ctorMods.hasFlag(AccessFlags) =>
case _ => vparamss foreach printConstrParams
def printConstrParams(ts: List[ValDef]): Unit = {
parenthesize() {
printImplicitInParamsList(ts)
printSeq(ts)(printVParam(_, primaryCtorParam = true))(print(", "))
}
}
// constructor's params processing (don't print single empty constructor param list)
vparamss match {
case Nil | List(Nil) if !mods.isCase && !ctorMods.hasFlag(AccessFlags) =>
case _ => vparamss foreach printConstrParams
}
parents
case _ =>
// Can get here with erroneous code, like `{@deprecatedName `
Nil
}
parents
}

// get trees without default classes and traits (when they are last)
Expand Down
115 changes: 90 additions & 25 deletions src/repl-frontend/scala/tools/nsc/interpreter/jline/Reader.scala
Expand Up @@ -14,15 +14,17 @@ package scala.tools.nsc.interpreter
package jline

import org.jline.builtins.InputRC
import org.jline.keymap.KeyMap
import org.jline.reader.Parser.ParseContext
import org.jline.reader._
import org.jline.reader.impl.{DefaultParser, LineReaderImpl}
import org.jline.reader.impl.{CompletionMatcherImpl, DefaultParser, LineReaderImpl}
import org.jline.terminal.Terminal

import java.io.{ByteArrayInputStream, File}
import java.net.{MalformedURLException, URL}
import java.util.{List => JList}
import scala.io.Source
import scala.reflect.internal.Chars
import scala.tools.nsc.interpreter.shell.{Accumulator, ShellConfig}
import scala.util.Using
import scala.util.control.NonFatal
Expand Down Expand Up @@ -122,17 +124,68 @@ object Reader {
.variable(SECONDARY_PROMPT_PATTERN, config.encolor(config.continueText)) // Continue prompt
.variable(WORDCHARS, LineReaderImpl.DEFAULT_WORDCHARS.filterNot("*?.[]~=/&;!#%^(){}<>".toSet))
.option(Option.DISABLE_EVENT_EXPANSION, true) // Otherwise `scala> println(raw"\n".toList)` gives `List(n)` !!
.option(Option.COMPLETE_MATCHER_CAMELCASE, true)
.option(Option.COMPLETE_MATCHER_TYPO, true)
}
object customCompletionMatcher extends CompletionMatcherImpl {
override def compile(options: java.util.Map[LineReader.Option, java.lang.Boolean], prefix: Boolean, line: CompletingParsedLine, caseInsensitive: Boolean, errors: Int, originalGroupName: String): Unit = {
val errorsReduced = line.wordCursor() match {
case 0 | 1 | 2 | 3 => 0 // disable JLine's levenshtein-distance based typo matcher for short strings
case 4 | 5 => math.max(errors, 1)
case _ => errors
}
super.compile(options, prefix, line, caseInsensitive, errorsReduced, originalGroupName)
}

override def matches(candidates: JList[Candidate]): JList[Candidate] = {
val matching = super.matches(candidates)
matching
}
}

builder.completionMatcher(customCompletionMatcher)

val reader = builder.build()
try inputrcFileContents.foreach(f => InputRC.configure(reader, new ByteArrayInputStream(f))) catch {
case NonFatal(_) =>
} //ignore

val keyMap = reader.getKeyMaps.get("main")

object ScalaShowType {
val Name = "scala-show-type"
private var lastInvokeLocation: Option[(String, Int)] = None
def apply(): Boolean = {
val nextInvokeLocation = Some((reader.getBuffer.toString, reader.getBuffer.cursor()))
val cursor = reader.getBuffer.cursor()
val text = reader.getBuffer.toString
val result = completer.complete(text, cursor, filter = true)
if (lastInvokeLocation == nextInvokeLocation) {
show(Naming.unmangle(result.typedTree))
lastInvokeLocation = None
} else {
show(result.typeAtCursor)
lastInvokeLocation = nextInvokeLocation
}
true
}
def show(text: String): Unit = if (text != "") {
reader.callWidget(LineReader.CLEAR)
reader.getTerminal.writer.println()
reader.getTerminal.writer.println(text)
reader.callWidget(LineReader.REDRAW_LINE)
reader.callWidget(LineReader.REDISPLAY)
reader.getTerminal.flush()
}
}
reader.getWidgets().put(ScalaShowType.Name, () => ScalaShowType())

locally {
import LineReader._
// VIINS, VICMD, EMACS
val keymap = if (config.viMode) VIINS else EMACS
reader.getKeyMaps.put(MAIN, reader.getKeyMaps.get(keymap));
keyMap.bind(new Reference(ScalaShowType.Name), KeyMap.alt(KeyMap.ctrl('t')))
}
def secure(p: java.nio.file.Path): Unit = {
try scala.reflect.internal.util.OwnerOnlyChmod.chmodFileOrCreateEmpty(p)
Expand Down Expand Up @@ -201,6 +254,12 @@ object Reader {
val (wordCursor, wordIndex) = current match {
case Some(t) if t.isIdentifier =>
(cursor - t.start, tokens.indexOf(t))
case Some(t) =>
val isIdentifierStartKeyword = (t.start until t.end).forall(i => Chars.isIdentifierPart(line.charAt(i)))
if (isIdentifierStartKeyword)
(cursor - t.start, tokens.indexOf(t))
else
(0, -1)
case _ =>
(0, -1)
}
Expand Down Expand Up @@ -259,45 +318,51 @@ object Reader {
class Completion(delegate: shell.Completion) extends shell.Completion with Completer {
require(delegate != null)
// REPL Completion
def complete(buffer: String, cursor: Int): shell.CompletionResult = delegate.complete(buffer, cursor)
def complete(buffer: String, cursor: Int, filter: Boolean): shell.CompletionResult = delegate.complete(buffer, cursor, filter)

// JLine Completer
def complete(lineReader: LineReader, parsedLine: ParsedLine, newCandidates: JList[Candidate]): Unit = {
def candidateForResult(line: String, cc: CompletionCandidate): Candidate = {
val value = if (line.startsWith(":")) ":" + cc.defString else cc.defString
val displayed = cc.defString + (cc.arity match {
def candidateForResult(cc: CompletionCandidate, deprecated: Boolean, universal: Boolean): Candidate = {
val value = cc.name
val displayed = cc.name + (cc.arity match {
case CompletionCandidate.Nullary => ""
case CompletionCandidate.Nilary => "()"
case _ => "("
})
val group = null // results may be grouped
val descr = // displayed alongside
if (cc.isDeprecated) "deprecated"
else if (cc.isUniversal) "universal"
if (deprecated) "deprecated"
else if (universal) "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.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 (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 _ =>
for (cc <- result.candidates)
newCandidates.add(candidateForResult(result.line, cc))
val result = complete(parsedLine.line, parsedLine.cursor, filter = false)
for (group <- result.candidates.groupBy(_.name)) {
// scala/bug#12238
// Currently, only when all methods are Deprecated should they be displayed `Deprecated` to users. Only handle result of PresentationCompilation#toCandidates.
// We don't handle result of PresentationCompilation#defStringCandidates, because we need to show the deprecated here.
val allDeprecated = group._2.forall(_.isDeprecated)
val allUniversal = group._2.forall(_.isUniversal)
group._2.foreach(cc => newCandidates.add(candidateForResult(cc, allDeprecated, allUniversal)))
}

val parsedLineWord = parsedLine.word()
result.candidates.filter(_.name == parsedLineWord) match {
case Nil =>
case exacts =>
val declStrings = exacts.map(_.declString()).filterNot(_ == "")
if (declStrings.nonEmpty) {
lineReader.callWidget(LineReader.CLEAR)
lineReader.getTerminal.writer.println()
for (declString <- declStrings)
lineReader.getTerminal.writer.println(declString)
lineReader.callWidget(LineReader.REDRAW_LINE)
lineReader.callWidget(LineReader.REDISPLAY)
lineReader.getTerminal.flush()
}
}
}
}
Expand Down
Expand Up @@ -14,22 +14,23 @@ package scala.tools.nsc.interpreter
package shell

trait Completion {
def complete(buffer: String, cursor: Int): CompletionResult
final def complete(buffer: String, cursor: Int): CompletionResult = complete(buffer, cursor, filter = true)
def complete(buffer: String, cursor: Int, filter: Boolean): CompletionResult
}
object NoCompletion extends Completion {
def complete(buffer: String, cursor: Int) = NoCompletions
def complete(buffer: String, cursor: Int, filter: Boolean) = NoCompletions
}

case class CompletionResult(line: String, cursor: Int, candidates: List[CompletionCandidate]) {
case class CompletionResult(line: String, cursor: Int, candidates: List[CompletionCandidate], typeAtCursor: String = "", typedTree: String = "") {
final def orElse(other: => CompletionResult): CompletionResult =
if (candidates.nonEmpty) this else other
}
object CompletionResult {
val empty: CompletionResult = NoCompletions
}
object NoCompletions extends CompletionResult("", -1, Nil)
object NoCompletions extends CompletionResult("", -1, Nil, "", "")

case class MultiCompletion(underlying: Completion*) extends Completion {
override def complete(buffer: String, cursor: Int) =
underlying.foldLeft(CompletionResult.empty)((r, c) => r.orElse(c.complete(buffer, cursor)))
override def complete(buffer: String, cursor: Int, filter: Boolean) =
underlying.foldLeft(CompletionResult.empty)((r,c) => r.orElse(c.complete(buffer, cursor, filter)))
}