Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
# These files are text and should be normalized (convert crlf => lf)

*.c text eol=lf
*.check text eol=lf
*.css text eol=lf
Expand All @@ -11,17 +10,16 @@
*.sh text eol=lf
*.txt text eol=lf
*.xml text eol=lf

# Windows-specific files get windows endings
*.bat eol=crlf
*.cmd eol=crlf
*-windows.tmpl eol=crlf

# Some binary file types for completeness
# (binary is a macro for -text -diff)
*.dll binary
*.gif binary
*.jpg binary
*.png binary
*.class binary
*.jar binary
*.jar binary
Copy link
Contributor

Choose a reason for hiding this comment

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

I fail to see how this file and change set is relevant to the issue.

Copy link
Member

@SethTisue SethTisue Nov 12, 2025

Choose a reason for hiding this comment

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

I also don't see the connection to #24142 ? The PR description you've provided seems to be about a different — and nonexistent! — bug

Copy link
Author

Choose a reason for hiding this comment

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

Thanks for the feedback! You’re right — the issue number I referenced was incorrect.
The PR is not related to #24142. The actual problem I’m fixing is a regression in the
Scala 3 REPL where tab completion stopped working for commands, identifiers, member
selections, and import statements.

I’ve updated the PR description to accurately reflect the real issue and removed the
incorrect reference. Thanks for pointing that out!

*.exe filter=lfs diff=lfs merge=lfs -text
149 changes: 63 additions & 86 deletions repl/src/dotty/tools/repl/JLineTerminal.scala
Original file line number Diff line number Diff line change
Expand Up @@ -18,76 +18,61 @@ import org.jline.terminal.TerminalBuilder
import org.jline.utils.AttributedString

class JLineTerminal extends java.io.Closeable {
// import java.util.logging.{Logger, Level}
// Logger.getLogger("org.jline").setLevel(Level.FINEST)

private val terminal =
var builder = TerminalBuilder.builder()
if System.getenv("TERM") == "dumb" then
// Force dumb terminal if `TERM` is `"dumb"`.
// Note: the default value for the `dumb` option is `null`, which allows
// JLine to fall back to a dumb terminal. This is different than `true` or
// `false` and can't be set using the `dumb` setter.
// This option is used at https://github.com/jline/jline3/blob/894b5e72cde28a551079402add4caea7f5527806/terminal/src/main/java/org/jline/terminal/TerminalBuilder.java#L528.
builder.dumb(true)
builder.build()

private val history = new DefaultHistory

private def blue(str: String)(using Context) =
if (ctx.settings.color.value != "never") Console.BLUE + str + Console.RESET
else str

protected def promptStr = "scala"
private def prompt(using Context) = blue(s"\n$promptStr> ")
private def newLinePrompt(using Context) = " "

/** Blockingly read line from `System.in`
*
* This entry point into JLine handles everything to do with terminal
* emulation. This includes:
*
* - Multi-line support
* - Copy-pasting
* - History
* - Syntax highlighting
* - Auto-completions
*
* @throws EndOfFileException This exception is thrown when the user types Ctrl-D.
*/
def readLine(
completer: Completer // provide auto-completions
completer: Completer
)(using Context): String = {
import LineReader.Option.*
import LineReader.*
val userHome = System.getProperty("user.home")

val lineReader = LineReaderBuilder
.builder()
.terminal(terminal)
.history(history)
.completer(completer)
.highlighter(new Highlighter)
.parser(new Parser)
.variable(HISTORY_FILE, s"$userHome/.dotty_history") // Save history to file
.variable(SECONDARY_PROMPT_PATTERN, "%M") // A short word explaining what is "missing",
// this is supplied from the EOFError.getMissing() method
.variable(LIST_MAX, 400) // Ask user when number of completions exceed this limit (default is 100).
.variable(BLINK_MATCHING_PAREN, 0L) // Don't blink the opening paren after typing a closing paren.
.variable(WORDCHARS,
LineReaderImpl.DEFAULT_WORDCHARS.filterNot("*?.[]~=/&;!#%^(){}<>".toSet)) // Finer grained word boundaries
.option(INSERT_TAB, true) // At the beginning of the line, insert tab instead of completing.
.option(AUTO_FRESH_LINE, true) // if not at start of line before prompt, move to new line.
.option(DISABLE_EVENT_EXPANSION, true) // don't process escape sequences in input
.variable(HISTORY_FILE, s"$userHome/.dotty_history")
.variable(SECONDARY_PROMPT_PATTERN, "%M")
.variable(LIST_MAX, 400)
.variable(BLINK_MATCHING_PAREN, 0L)
.variable(
WORDCHARS,
LineReaderImpl.DEFAULT_WORDCHARS.filterNot("*?.[]~=/&;!#%^(){}<>".toSet)
)
.option(INSERT_TAB, true)
.option(AUTO_FRESH_LINE, true)
.option(DISABLE_EVENT_EXPANSION, true)
.build()

lineReader.readLine(prompt)
}

def close(): Unit = terminal.close()

/** Register a signal handler and return the previous handler */
def handle(signal: org.jline.terminal.Terminal.Signal, handler: org.jline.terminal.Terminal.SignalHandler): org.jline.terminal.Terminal.SignalHandler =
def handle(
signal: org.jline.terminal.Terminal.Signal,
handler: org.jline.terminal.Terminal.SignalHandler
): org.jline.terminal.Terminal.SignalHandler =
terminal.handle(signal, handler)

/** Provide syntax highlighting */
private class Highlighter(using Context) extends reader.Highlighter {
def highlight(reader: LineReader, buffer: String): AttributedString = {
val highlighted = SyntaxHighlighting.highlight(buffer)
Expand All @@ -97,39 +82,35 @@ class JLineTerminal extends java.io.Closeable {
def setErrorIndex(errorIndex: Int): Unit = {}
}

/** Provide multi-line editing support */
private class Parser(using Context) extends reader.Parser {

/**
* @param cursor The cursor position within the line
* @param line The unparsed line
* @param word The current word being completed
* @param wordCursor The cursor position within the current word
*/
private class ParsedLine(
val cursor: Int, val line: String, val word: String, val wordCursor: Int
) extends reader.ParsedLine {
// Using dummy values, not sure what they are used for
def wordIndex = -1
def words: java.util.List[String] = java.util.Collections.emptyList[String]
def words: java.util.List[String] =
java.util.Collections.emptyList[String]
}

def parse(input: String, cursor: Int, context: ParseContext): reader.ParsedLine = {
def parse(
input: String,
cursor: Int,
context: ParseContext
): reader.ParsedLine = {

def parsedLine(word: String, wordCursor: Int) =
new ParsedLine(cursor, input, word, wordCursor)
// Used when no word is being completed

def defaultParsedLine = parsedLine("", 0)

def incomplete(): Nothing = throw new EOFError(
// Using dummy values, not sure what they are used for
/* line = */ -1,
/* column = */ -1,
/* message = */ "",
/* missing = */ newLinePrompt)
-1, -1, "", newLinePrompt
)

case class TokenData(token: Token, start: Int, end: Int)
def currentToken: TokenData /* | Null */ = {
val source = SourceFile.virtual("<completions>", input)

def currentToken: TokenData | Null = {
val source = SourceFile.virtual("<completions>", input)
val scanner = new Scanner(source)(using ctx.fresh.setReporter(Reporter.NoReporter))
var lastBacktickErrorStart: Option[Int] = None

Expand All @@ -139,12 +120,12 @@ class JLineTerminal extends java.io.Closeable {
scanner.nextToken()
val end = scanner.lastOffset

val isCurrentToken = cursor >= start && cursor <= end
val isCurrentToken =
cursor >= start && cursor <= end

if (isCurrentToken)
return TokenData(token, lastBacktickErrorStart.getOrElse(start), end)


// we need to enclose the last backtick, which unclosed produces ERROR token
if (token == ERROR && input(start) == '`') then
lastBacktickErrorStart = Some(start)
else
Expand All @@ -153,39 +134,35 @@ class JLineTerminal extends java.io.Closeable {
null
}

def acceptLine = {
val onLastLine = !input.substring(cursor).contains(System.lineSeparator)
onLastLine && !ParseResult.isIncomplete(input)
}
def acceptLine =
!input.substring(cursor).contains(System.lineSeparator) &&
!ParseResult.isIncomplete(input)

context match {
case ParseContext.ACCEPT_LINE if acceptLine =>
// using dummy values, resulting parsed input is probably unused
defaultParsedLine

// In the situation where we have a partial command that we want to
// complete we need to ensure that the :<partial-word> isn't split into
// 2 tokens, but rather the entire thing is treated as the "word", in
// order to insure the : is replaced in the completion.
case ParseContext.COMPLETE if
ParseResult.commands.exists(command => command._1.startsWith(input)) =>
parsedLine(input, cursor)

case ParseContext.COMPLETE =>
// Parse to find completions (typically after a Tab).
def isCompletable(token: Token) = isIdentifier(token) || isKeyword(token)
currentToken match {
case TokenData(token, start, end) if isCompletable(token) =>
val word = input.substring(start, end)
val wordCursor = cursor - start
parsedLine(word, wordCursor)
case _ =>
defaultParsedLine
}

case _ =>
incomplete()
}

case ParseContext.ACCEPT_LINE if acceptLine =>
defaultParsedLine

// FIX: REPL commands starting with ":" must be treated as one word
case ParseContext.COMPLETE if input.startsWith(":") =>
parsedLine(input, cursor)

case ParseContext.COMPLETE =>
def isCompletable(token: Token) =
isIdentifier(token) || isKeyword(token)

currentToken match
case TokenData(token, start, end) if isCompletable(token) =>
val word = input.substring(start, end)
val wordCursor = cursor - start
parsedLine(word, wordCursor)
case _ =>
defaultParsedLine

case _ =>
incomplete()
}

}
}
}
Loading
Loading