diff --git a/compiler/src/dotty/tools/dotc/core/StdNames.scala b/compiler/src/dotty/tools/dotc/core/StdNames.scala index d020b3eb7ffc..d1cbf31b00e6 100644 --- a/compiler/src/dotty/tools/dotc/core/StdNames.scala +++ b/compiler/src/dotty/tools/dotc/core/StdNames.scala @@ -548,6 +548,7 @@ object StdNames { val wait_ : N = "wait" val withFilter: N = "withFilter" val withFilterIfRefutable: N = "withFilterIfRefutable$" + val WorksheetWrapper: N = "WorksheetWrapper" val wrap: N = "wrap" val zero: N = "zero" val zip: N = "zip" diff --git a/compiler/src/dotty/tools/dotc/interactive/Interactive.scala b/compiler/src/dotty/tools/dotc/interactive/Interactive.scala index 16335f3e5f09..74bd3d86c3bc 100644 --- a/compiler/src/dotty/tools/dotc/interactive/Interactive.scala +++ b/compiler/src/dotty/tools/dotc/interactive/Interactive.scala @@ -265,12 +265,12 @@ object Interactive { namedTrees(trees, (include & Include.references) != 0, matchSymbol(_, sym, include)) /** Find named trees with a non-empty position whose name contains `nameSubstring` in `trees`. - * - * @param includeReferences If true, include references and not just definitions */ - def namedTrees(trees: List[SourceTree], includeReferences: Boolean, nameSubstring: String) - (implicit ctx: Context): List[SourceTree] = - namedTrees(trees, includeReferences, _.show.toString.contains(nameSubstring)) + def namedTrees(trees: List[SourceTree], nameSubstring: String) + (implicit ctx: Context): List[SourceTree] = { + val predicate: NameTree => Boolean = _.name.toString.contains(nameSubstring) + namedTrees(trees, includeReferences = false, predicate) + } /** Find named trees with a non-empty position satisfying `treePredicate` in `trees`. * @@ -322,7 +322,7 @@ object Interactive { val includeLinkedClass = (includes & Include.linkedClass) != 0 val predicate: NameTree => Boolean = tree => ( tree.pos.isSourceDerived - && !tree.symbol.isConstructor + && !tree.symbol.isPrimaryConstructor && (includeDeclaration || !Interactive.isDefinition(tree)) && ( Interactive.matchSymbol(tree, symbol, includes) || ( includeDeclaration diff --git a/compiler/src/dotty/tools/dotc/parsing/Parsers.scala b/compiler/src/dotty/tools/dotc/parsing/Parsers.scala index 2a39323c5ee5..2588b9c44691 100644 --- a/compiler/src/dotty/tools/dotc/parsing/Parsers.scala +++ b/compiler/src/dotty/tools/dotc/parsing/Parsers.scala @@ -1404,7 +1404,8 @@ object Parsers { val (impl, missingBody) = template(emptyConstructor) impl.parents match { case parent :: Nil if missingBody => - if (parent.isType) ensureApplied(wrapNew(parent)) else parent + if (parent.isType) ensureApplied(wrapNew(parent)) + else parent.withPos(Position(start, in.lastOffset)) case _ => New(impl.withPos(Position(start, in.lastOffset))) } diff --git a/compiler/src/dotty/tools/dotc/reporting/diagnostic/ErrorMessageID.java b/compiler/src/dotty/tools/dotc/reporting/diagnostic/ErrorMessageID.java index 2e2182abd131..14c8c1ecf7ec 100644 --- a/compiler/src/dotty/tools/dotc/reporting/diagnostic/ErrorMessageID.java +++ b/compiler/src/dotty/tools/dotc/reporting/diagnostic/ErrorMessageID.java @@ -135,7 +135,8 @@ public enum ErrorMessageID { CaseClassCannotExtendEnumID, ValueClassParameterMayNotBeCallByNameID, NotAnExtractorID, - MemberWithSameNameAsStaticID + MemberWithSameNameAsStaticID, + PureExpressionInStatementPositionID ; public int errorNumber() { diff --git a/compiler/src/dotty/tools/dotc/reporting/diagnostic/messages.scala b/compiler/src/dotty/tools/dotc/reporting/diagnostic/messages.scala index 0b324812f517..bd19f7954f43 100644 --- a/compiler/src/dotty/tools/dotc/reporting/diagnostic/messages.scala +++ b/compiler/src/dotty/tools/dotc/reporting/diagnostic/messages.scala @@ -2139,4 +2139,14 @@ object messages { override def kind: String = "Syntax" override def explanation: String = "" } + + case class PureExpressionInStatementPosition(stat: untpd.Tree, exprOwner: Symbol)(implicit ctx: Context) + extends Message(PureExpressionInStatementPositionID) { + + val kind = "Potential Issue" + val msg = "a pure expression does nothing in statement position; you may be omitting necessary parentheses" + val explanation = + hl"""The pure expression `$stat` doesn't have any side effect and its result is not assigned elsewhere. + |It can be removed without changing the semantics of the program. This may indicate an error.""".stripMargin + } } diff --git a/compiler/src/dotty/tools/dotc/typer/Typer.scala b/compiler/src/dotty/tools/dotc/typer/Typer.scala index 861f9733ff62..a2019534f989 100644 --- a/compiler/src/dotty/tools/dotc/typer/Typer.scala +++ b/compiler/src/dotty/tools/dotc/typer/Typer.scala @@ -2013,7 +2013,7 @@ class Typer extends Namer val stat1 = typed(stat)(ctx.exprContext(stat, exprOwner)) if (!ctx.isAfterTyper && isPureExpr(stat1) && !stat1.tpe.isRef(defn.UnitClass) && !isSelfOrSuperConstrCall(stat1)) - ctx.warning(em"a pure expression does nothing in statement position", stat.pos) + ctx.warning(PureExpressionInStatementPosition(stat, exprOwner), stat.pos) buf += stat1 traverse(rest) case nil => diff --git a/compiler/src/dotty/tools/repl/JLineTerminal.scala b/compiler/src/dotty/tools/repl/JLineTerminal.scala index 681f105e4788..5948581e1438 100644 --- a/compiler/src/dotty/tools/repl/JLineTerminal.scala +++ b/compiler/src/dotty/tools/repl/JLineTerminal.scala @@ -13,12 +13,13 @@ import org.jline.reader.impl.history.DefaultHistory import org.jline.terminal.TerminalBuilder import org.jline.utils.AttributedString -final class JLineTerminal extends java.io.Closeable { +final class JLineTerminal(needsTerminal: Boolean) extends java.io.Closeable { // import java.util.logging.{Logger, Level} // Logger.getLogger("org.jline").setLevel(Level.FINEST) - private val terminal = TerminalBuilder.builder() - .dumb(false) // fail early if not able to create a terminal + private val terminal = + TerminalBuilder.builder() + .dumb(!needsTerminal) // fail early if we need a terminal and can't create one .build() private val history = new DefaultHistory diff --git a/compiler/src/dotty/tools/repl/Main.scala b/compiler/src/dotty/tools/repl/Main.scala index 725395dcdb3c..adf8d9a6de91 100644 --- a/compiler/src/dotty/tools/repl/Main.scala +++ b/compiler/src/dotty/tools/repl/Main.scala @@ -3,5 +3,10 @@ package dotty.tools.repl /** Main entry point to the REPL */ object Main { def main(args: Array[String]): Unit = - new ReplDriver(args).runUntilQuit() + new ReplDriver(args).runUntilQuit(needsTerminal = true) +} + +object WorksheetMain { + def main(args: Array[String]): Unit = + new ReplDriver(args).runUntilQuit(needsTerminal = false) } diff --git a/compiler/src/dotty/tools/repl/ReplDriver.scala b/compiler/src/dotty/tools/repl/ReplDriver.scala index 63bd7e9dd4a6..38d6dfdf7287 100644 --- a/compiler/src/dotty/tools/repl/ReplDriver.scala +++ b/compiler/src/dotty/tools/repl/ReplDriver.scala @@ -102,8 +102,8 @@ class ReplDriver(settings: Array[String], * observable outside of the CLI, for this reason, most helper methods are * `protected final` to facilitate testing. */ - final def runUntilQuit(initialState: State = initialState): State = { - val terminal = new JLineTerminal() + final def runUntilQuit(needsTerminal: Boolean, initialState: State = initialState): State = { + val terminal = new JLineTerminal(needsTerminal) /** Blockingly read a line, getting back a parse result */ def readLine(state: State): ParseResult = { @@ -285,7 +285,12 @@ class ReplDriver(settings: Array[String], .foreach { sym => // FIXME syntax highlighting on comment is currently not working // out.println(SyntaxHighlighting.highlight("// defined " + sym.showUser)) - out.println(SyntaxHighlighting.CommentColor + "// defined " + sym.showUser + SyntaxHighlighting.NoColor) + val message = "// defined " + sym.showUser + if (ctx.settings.color.value != "never") { + println(SyntaxHighlighting.CommentColor + message + SyntaxHighlighting.NoColor) + } else { + println(message) + } } diff --git a/compiler/test-resources/repl/errmsgs b/compiler/test-resources/repl/errmsgs index 157e5518b190..aef05a07c103 100644 --- a/compiler/test-resources/repl/errmsgs +++ b/compiler/test-resources/repl/errmsgs @@ -25,9 +25,9 @@ scala> val z: (List[String], List[Int]) = (List(1), List("a")) | scala> val a: Inv[String] = new Inv(new Inv(1)) 1 | val a: Inv[String] = new Inv(new Inv(1)) - | ^^^^^^ - | found: Inv[Int] - | required: String + | ^^^^^^^^^^ + | found: Inv[Int] + | required: String | scala> val b: Inv[String] = new Inv(1) 1 | val b: Inv[String] = new Inv(1) diff --git a/language-server/src/dotty/tools/languageserver/DottyLanguageServer.scala b/language-server/src/dotty/tools/languageserver/DottyLanguageServer.scala index 29524d5e58bf..63812f48ade4 100644 --- a/language-server/src/dotty/tools/languageserver/DottyLanguageServer.scala +++ b/language-server/src/dotty/tools/languageserver/DottyLanguageServer.scala @@ -4,7 +4,7 @@ package languageserver import java.net.URI import java.io._ import java.nio.file._ -import java.util.concurrent.CompletableFuture +import java.util.concurrent.{CompletableFuture, ConcurrentHashMap} import java.util.function.Function import com.fasterxml.jackson.databind.ObjectMapper @@ -21,7 +21,7 @@ import ast.{Trees, tpd} import core._, core.Decorators.{sourcePos => _, _} import Comments._, Contexts._, Flags._, Names._, NameOps._, Symbols._, SymDenotations._, Trees._, Types._ import classpath.ClassPathEntries -import reporting._, reporting.diagnostic.MessageContainer +import reporting._, reporting.diagnostic.{Message, MessageContainer, messages} import typer.Typer import util._ import interactive._, interactive.InteractiveDriver._ @@ -29,6 +29,7 @@ import Interactive.Include import config.Printers.interactiv import languageserver.config.ProjectConfig +import languageserver.worksheet.{Worksheet, WorksheetClient, WorksheetService} import lsp4j.services._ @@ -41,7 +42,7 @@ import lsp4j.services._ * - This implementation is based on the LSP4J library: https://github.com/eclipse/lsp4j */ class DottyLanguageServer extends LanguageServer - with LanguageClientAware with TextDocumentService with WorkspaceService { thisServer => + with TextDocumentService with WorkspaceService with WorksheetService { thisServer => import ast.tpd._ import DottyLanguageServer._ @@ -53,7 +54,9 @@ class DottyLanguageServer extends LanguageServer private[this] var rootUri: String = _ - private[this] var client: LanguageClient = _ + + private[this] var myClient: WorksheetClient = _ + def client: WorksheetClient = myClient private[this] var myDrivers: mutable.Map[ProjectConfig, InteractiveDriver] = _ @@ -113,13 +116,13 @@ class DottyLanguageServer extends LanguageServer drivers(config) case None => val config = drivers.keys.head - println(s"No configuration contains $uri as a source file, arbitrarily choosing ${config.id}") + // println(s"No configuration contains $uri as a source file, arbitrarily choosing ${config.id}") drivers(config) } } - override def connect(client: LanguageClient): Unit = { - this.client = client + def connect(client: WorksheetClient): Unit = { + myClient = client } override def exit(): Unit = { @@ -130,7 +133,7 @@ class DottyLanguageServer extends LanguageServer CompletableFuture.completedFuture(new Object) } - private[this] def computeAsync[R](fun: CancelChecker => R): CompletableFuture[R] = + def computeAsync[R](fun: CancelChecker => R): CompletableFuture[R] = CompletableFutures.computeAsync { cancelToken => // We do not support any concurrent use of the compiler currently. thisServer.synchronized { @@ -179,30 +182,46 @@ class DottyLanguageServer extends LanguageServer val document = params.getTextDocument val uri = new URI(document.getUri) val driver = driverFor(uri) + val worksheetMode = isWorksheet(uri) + + val (text, positionMapper) = + if (worksheetMode) (wrapWorksheet(document.getText), Some(toUnwrappedPosition _)) + else (document.getText, None) - val text = document.getText val diags = driver.run(uri, text) client.publishDiagnostics(new PublishDiagnosticsParams( document.getUri, - diags.flatMap(diagnostic).asJava)) + diags.flatMap(diagnostic(_, positionMapper)(driver.currentCtx)).asJava)) } - override def didChange(params: DidChangeTextDocumentParams): Unit = thisServer.synchronized { - checkMemory() + override def didChange(params: DidChangeTextDocumentParams): Unit = { val document = params.getTextDocument val uri = new URI(document.getUri) - val driver = driverFor(uri) + val worksheetMode = isWorksheet(uri) + + if (worksheetMode) { + Option(worksheets.get(uri)).foreach(_.cancel(true)) + } - val change = params.getContentChanges.get(0) - assert(change.getRange == null, "TextDocumentSyncKind.Incremental support is not implemented") + thisServer.synchronized { + checkMemory() - val text = change.getText - val diags = driver.run(uri, text) + val driver = driverFor(uri) - client.publishDiagnostics(new PublishDiagnosticsParams( - document.getUri, - diags.flatMap(diagnostic).asJava)) + val change = params.getContentChanges.get(0) + assert(change.getRange == null, "TextDocumentSyncKind.Incremental support is not implemented") + + val (text, positionMapper) = + if (worksheetMode) (wrapWorksheet(change.getText), Some(toUnwrappedPosition _)) + else (change.getText, None) + + val diags = driver.run(uri, text) + + client.publishDiagnostics(new PublishDiagnosticsParams( + document.getUri, + diags.flatMap(diagnostic(_, positionMapper)(driver.currentCtx)).asJava)) + } } override def didClose(params: DidCloseTextDocumentParams): Unit = thisServer.synchronized { @@ -218,9 +237,9 @@ class DottyLanguageServer extends LanguageServer override def didChangeWatchedFiles(params: DidChangeWatchedFilesParams): Unit = /*thisServer.synchronized*/ {} - override def didSave(params: DidSaveTextDocumentParams): Unit = + override def didSave(params: DidSaveTextDocumentParams): Unit = { /*thisServer.synchronized*/ {} - + } // FIXME: share code with messages.NotAMember override def completion(params: CompletionParams) = computeAsync { cancelToken => @@ -288,7 +307,7 @@ class DottyLanguageServer extends LanguageServer (Nil, Include.overriding) } val defs = Interactive.namedTrees(trees, include, sym) - defs.flatMap(d => location(d.namePos)).asJava + defs.flatMap(d => location(d.namePos, positionMapperFor(d.source))).asJava } } @@ -311,7 +330,7 @@ class DottyLanguageServer extends LanguageServer Include.references | Include.overriding | (if (includeDeclaration) Include.definitions else 0) val refs = Interactive.findTreesMatching(trees, includes, sym) - refs.flatMap(ref => location(ref.namePos)).asJava + refs.flatMap(ref => location(ref.namePos, positionMapperFor(ref.source))).asJava } } @@ -334,7 +353,7 @@ class DottyLanguageServer extends LanguageServer val changes = refs.groupBy(ref => toUri(ref.source).toString) .mapValues(refs => refs.flatMap(ref => - range(ref.namePos).map(nameRange => new TextEdit(nameRange, newName))).asJava) + range(ref.namePos, positionMapperFor(ref.source)).map(nameRange => new TextEdit(nameRange, newName))).asJava) new WorkspaceEdit(changes.asJava) } @@ -353,8 +372,8 @@ class DottyLanguageServer extends LanguageServer else { val refs = Interactive.namedTrees(uriTrees, Include.references | Include.overriding, sym) (for { - ref <- refs - nameRange <- range(ref.namePos) + ref <- refs if !ref.tree.symbol.isPrimaryConstructor + nameRange <- range(ref.namePos, positionMapperFor(ref.source)) } yield new DocumentHighlight(nameRange, DocumentHighlightKind.Read)).asJava } } @@ -387,8 +406,8 @@ class DottyLanguageServer extends LanguageServer val defs = Interactive.namedTrees(uriTrees, includeReferences = false, _ => true) (for { - d <- defs - info <- symbolInfo(d.tree.symbol, d.namePos) + d <- defs if !isWorksheetWrapper(d) + info <- symbolInfo(d.tree.symbol, d.namePos, positionMapperFor(d.source)) } yield JEither.forLeft(info)).asJava } @@ -399,8 +418,8 @@ class DottyLanguageServer extends LanguageServer implicit val ctx = driver.currentCtx val trees = driver.allTrees - val defs = Interactive.namedTrees(trees, includeReferences = false, nameSubstring = query) - defs.flatMap(d => symbolInfo(d.tree.symbol, d.namePos)) + val defs = Interactive.namedTrees(trees, nameSubstring = query) + defs.flatMap(d => symbolInfo(d.tree.symbol, d.namePos, positionMapperFor(d.source))) }.asJava } @@ -425,30 +444,39 @@ object DottyLanguageServer { /** Convert an lsp4j.Position to a SourcePosition */ def sourcePosition(driver: InteractiveDriver, uri: URI, pos: lsp4j.Position): SourcePosition = { + val actualPosition = + if (isWorksheet(uri)) toWrappedPosition(pos) + else pos val source = driver.openedFiles(uri) if (source.exists) { - val p = Positions.Position(source.lineToOffset(pos.getLine) + pos.getCharacter) + val p = Positions.Position(source.lineToOffset(actualPosition.getLine) + actualPosition.getCharacter) new SourcePosition(source, p) } else NoSourcePosition } /** Convert a SourcePosition to an lsp4j.Range */ - def range(p: SourcePosition): Option[lsp4j.Range] = - if (p.exists) + def range(p: SourcePosition, positionMapper: Option[SourcePosition => SourcePosition] = None): Option[lsp4j.Range] = + if (p.exists) { + val mappedPosition = positionMapper.map(_(p)).getOrElse(p) Some(new lsp4j.Range( - new lsp4j.Position(p.startLine, p.startColumn), - new lsp4j.Position(p.endLine, p.endColumn) + new lsp4j.Position(mappedPosition.startLine, mappedPosition.startColumn), + new lsp4j.Position(mappedPosition.endLine, mappedPosition.endColumn) )) - else + } else None /** Convert a SourcePosition to an lsp4.Location */ - def location(p: SourcePosition): Option[lsp4j.Location] = - range(p).map(r => new lsp4j.Location(toUri(p.source).toString, r)) + def location(p: SourcePosition, positionMapper: Option[SourcePosition => SourcePosition] = None): Option[lsp4j.Location] = + range(p, positionMapper).map(r => new lsp4j.Location(toUri(p.source).toString, r)) - /** Convert a MessageContainer to an lsp4j.Diagnostic */ - def diagnostic(mc: MessageContainer): Option[lsp4j.Diagnostic] = + /** + * Convert a MessageContainer to an lsp4j.Diagnostic. The positions are transformed vy + * `positionMapper`. + */ + def diagnostic(mc: MessageContainer, + positionMapper: Option[SourcePosition => SourcePosition] = None + )(implicit ctx: Context): Option[lsp4j.Diagnostic] = if (!mc.pos.exists) None // diagnostics without positions are not supported: https://github.com/Microsoft/language-server-protocol/issues/249 else { @@ -466,12 +494,114 @@ object DottyLanguageServer { } } - val code = mc.contained().errorId.errorNumber.toString - range(mc.pos).map(r => - new lsp4j.Diagnostic( - r, mc.message, severity(mc.level), /*source =*/ "", code)) + val message = mc.contained() + if (displayMessage(message, mc.pos.source)) { + val code = message.errorId.errorNumber.toString + range(mc.pos, positionMapper).map(r => + new lsp4j.Diagnostic( + r, mc.message, severity(mc.level), /*source =*/ "", code)) + } else { + None + } } + /** + * Check whether `message` should be displayed in the IDE. + * + * Currently we only filter out the warning about pure expressions in statement position when they + * are immediate children of the worksheet wrapper. + * + * @param message The message to filter. + * @param sourceFile The sourcefile from which `message` originates. + * @return true if the message should be displayed in the IDE, false otherwise. + */ + private def displayMessage(message: Message, sourceFile: SourceFile)(implicit ctx: Context): Boolean = { + if (isWorksheet(sourceFile)) { + message match { + case messages.PureExpressionInStatementPosition(_, exprOwner) => + val ownerSym = if (exprOwner.isLocalDummy) exprOwner.owner else exprOwner + !isWorksheetWrapper(ownerSym) + case _ => + true + } + } else { + true + } + } + + /** Does this URI represent a worksheet? */ + private def isWorksheet(uri: URI): Boolean = + uri.toString.endsWith(".sc") + + /** Does this sourcefile represent a worksheet? */ + private def isWorksheet(sourcefile: SourceFile): Boolean = + sourcefile.file.extension == "sc" + + /** Wrap the source of a worksheet inside an `object`. */ + private def wrapWorksheet(source: String): String = + s"""object ${StdNames.nme.WorksheetWrapper} { + |$source + |}""".stripMargin + + /** + * Map `position` in a wrapped worksheet to the same position in the unwrapped source. + * + * Because worksheet are wrapped in an `object`, the positions in the source are one line + * above from what the compiler sees. + * + * @see wrapWorksheet + * @param position The position as seen by the compiler (after wrapping) + * @return The position in the actual source file (before wrapping). + */ + private def toUnwrappedPosition(position: SourcePosition): SourcePosition = { + new SourcePosition(position.source, position.pos, position.outer) { + override def startLine: Int = position.startLine - 1 + override def endLine: Int = position.endLine - 1 + } + } + + /** + * Map `position` in an unwrapped worksheet to the same position in the wrapped source. + * + * Because worksheet are wrapped in an `object`, the positions in the source are one line + * above from what the compiler sees. + * + * @see wrapWorksheet + * @param position The position as seen by VSCode (before wrapping) + * @return The position as seen by the compiler (after wrapping) + */ + private def toWrappedPosition(position: lsp4j.Position): lsp4j.Position = { + new lsp4j.Position(position.getLine + 1, position.getCharacter) + } + + /** + * Returns the position mapper necessary to unwrap positions for `sourcefile`. If `sourcefile` is + * not a worksheet, no mapper is necessary. Otherwise, return `toUnwrappedPosition`. + */ + private def positionMapperFor(sourcefile: SourceFile): Option[SourcePosition => SourcePosition] = { + if (isWorksheet(sourcefile)) Some(toUnwrappedPosition _) + else None + } + + /** + * Is `sourceTree` the wrapper object that we put around worksheet sources? + * + * @see wrapWorksheet + */ + def isWorksheetWrapper(sourceTree: SourceTree)(implicit ctx: Context): Boolean = { + isWorksheet(sourceTree.source) && isWorksheetWrapper(sourceTree.tree.symbol) + } + + /** + * Is this symbol the wrapper object that we put around worksheet sources? + * + * @see wrapWorksheet + */ + def isWorksheetWrapper(symbol: Symbol)(implicit ctx: Context): Boolean = { + symbol.name == StdNames.nme.WorksheetWrapper.moduleClassName && + symbol.owner == ctx.definitions.EmptyPackageClass + } + /** Create an lsp4j.CompletionItem from a Symbol */ def completionItem(sym: Symbol)(implicit ctx: Context): lsp4j.CompletionItem = { def completionItemKind(sym: Symbol)(implicit ctx: Context): lsp4j.CompletionItemKind = { @@ -517,7 +647,7 @@ object DottyLanguageServer { } /** Create an lsp4j.SymbolInfo from a Symbol and a SourcePosition */ - def symbolInfo(sym: Symbol, pos: SourcePosition)(implicit ctx: Context): Option[lsp4j.SymbolInformation] = { + def symbolInfo(sym: Symbol, pos: SourcePosition, positionMapper: Option[SourcePosition => SourcePosition])(implicit ctx: Context): Option[lsp4j.SymbolInformation] = { def symbolKind(sym: Symbol)(implicit ctx: Context): lsp4j.SymbolKind = { import lsp4j.{SymbolKind => SK} @@ -542,6 +672,6 @@ object DottyLanguageServer { else null - location(pos).map(l => new lsp4j.SymbolInformation(name, symbolKind(sym), l, containerName)) + location(pos, positionMapper).map(l => new lsp4j.SymbolInformation(name, symbolKind(sym), l, containerName)) } } diff --git a/language-server/src/dotty/tools/languageserver/Main.scala b/language-server/src/dotty/tools/languageserver/Main.scala index fbdf9e4efdbb..5223f0d2c63b 100644 --- a/language-server/src/dotty/tools/languageserver/Main.scala +++ b/language-server/src/dotty/tools/languageserver/Main.scala @@ -10,6 +10,7 @@ import java.nio.channels._ import org.eclipse.lsp4j._ import org.eclipse.lsp4j.services._ import org.eclipse.lsp4j.launch._ +import org.eclipse.lsp4j.jsonrpc.Launcher /** Run the Dotty Language Server. * @@ -65,9 +66,16 @@ object Main { val server = new DottyLanguageServer println("Starting server") - // For debugging JSON messages: - // val launcher = LSPLauncher.createServerLauncher(server, in, out, false, new java.io.PrintWriter(System.err, true)) - val launcher = LSPLauncher.createServerLauncher(server, in, out) + val launcher = + new Launcher.Builder[worksheet.WorksheetClient]() + .setLocalService(server) + .setRemoteInterface(classOf[worksheet.WorksheetClient]) + .setInput(in) + .setOutput(out) + // For debugging JSON messages: + // .traceMessages(new java.io.PrintWriter(System.err, true)) + .create(); + val client = launcher.getRemoteProxy() server.connect(client) launcher.startListening() diff --git a/language-server/src/dotty/tools/languageserver/worksheet/CancellationThread.scala b/language-server/src/dotty/tools/languageserver/worksheet/CancellationThread.scala new file mode 100644 index 000000000000..f4dbf500e810 --- /dev/null +++ b/language-server/src/dotty/tools/languageserver/worksheet/CancellationThread.scala @@ -0,0 +1,29 @@ +package dotty.tools.languageserver.worksheet + +import org.eclipse.lsp4j.jsonrpc.CancelChecker + +import java.util.concurrent.CancellationException + +/** + * Regularly check whether execution has been cancelled, kill REPL if it is. + */ +private class CancellationThread(@volatile private[this] var cancelChecker: CancelChecker, + evaluator: Evaluator) extends Thread { + private final val checkCancelledDelayMs = 50 + + override def run(): Unit = { + try { + while (evaluator.isAlive() && !Thread.interrupted()) { + cancelChecker.checkCanceled() + Thread.sleep(checkCancelledDelayMs) + } + } catch { + case _: CancellationException => evaluator.exit() + case _: InterruptedException => evaluator.exit() + } + } + + def setCancelChecker(cancelChecker: CancelChecker): Unit = { + this.cancelChecker = cancelChecker + } +} diff --git a/language-server/src/dotty/tools/languageserver/worksheet/Evaluator.scala b/language-server/src/dotty/tools/languageserver/worksheet/Evaluator.scala new file mode 100644 index 000000000000..c8c346b7c695 --- /dev/null +++ b/language-server/src/dotty/tools/languageserver/worksheet/Evaluator.scala @@ -0,0 +1,112 @@ +package dotty.tools.languageserver.worksheet + +import dotty.tools.dotc.core.Contexts.Context + +import java.io.{File, PrintStream} + +import org.eclipse.lsp4j.jsonrpc.CancelChecker + +private object Evaluator { + + private val javaExec: Option[String] = { + val bin = new File(scala.util.Properties.javaHome, "bin") + val java = new File(bin, if (scala.util.Properties.isWin) "java.exe" else "java") + + if (java.exists()) Some(java.getAbsolutePath()) + else None + } + + /** + * The most recent Evaluator that was used. It can be reused if the user classpath hasn't changed + * between two calls. + */ + private[this] var previousEvaluator: Option[(String, Evaluator)] = None + + /** + * Get a (possibly reused) Evaluator and set cancel checker. + * + * @param cancelChecker The token that indicates whether evaluation has been cancelled. + * @return A JVM running the REPL. + */ + def get(cancelChecker: CancelChecker)(implicit ctx: Context): Option[Evaluator] = { + val classpath = ctx.settings.classpath.value + previousEvaluator match { + case Some(cp, evaluator) if evaluator.isAlive() && cp == classpath => + evaluator.reset(cancelChecker) + Some(evaluator) + case _ => + previousEvaluator.foreach(_._2.exit()) + val newEvaluator = javaExec.map(new Evaluator(_, ctx.settings.classpath.value, cancelChecker)) + previousEvaluator = newEvaluator.map(jvm => (classpath, jvm)) + newEvaluator + } + } +} + +/** + * Represents a JVM running the REPL, ready for evaluation. + * + * @param javaExec The path to the `java` executable. + * @param userClasspath The REPL classpath + * @param cancelChecker The token that indicates whether evaluation has been cancelled. + */ +private class Evaluator private (javaExec: String, + userClasspath: String, + cancelChecker: CancelChecker) { + private val process = + new ProcessBuilder( + javaExec, + "-classpath", scala.util.Properties.javaClassPath, + dotty.tools.repl.WorksheetMain.getClass.getName.stripSuffix("$"), + "-classpath", userClasspath, + "-color:never") + .redirectErrorStream(true) + .start() + + // The stream that we use to send commands to the REPL + private val processInput = new PrintStream(process.getOutputStream()) + + // Messages coming out of the REPL + private val processOutput = new ReplReader(process.getInputStream()) + processOutput.start() + + // The thread that monitors cancellation + private val cancellationThread = new CancellationThread(cancelChecker, this) + cancellationThread.start() + + // Wait for the REPL to be ready + processOutput.next() + + /** Is the process that runs the REPL still alive? */ + def isAlive(): Boolean = process.isAlive() + + /** + * Submit `command` to the REPL, wait for the result. + * + * @param command The command to evaluate. + * @return The result from the REPL. + */ + def eval(command: String): Option[String] = { + processInput.println(command) + processInput.flush() + processOutput.next().map(_.trim) + } + + /** + * Reset the REPL to its initial state, update the cancel checker. + */ + def reset(cancelChecker: CancelChecker): Unit = { + cancellationThread.setCancelChecker(cancelChecker) + eval(":reset") + } + + /** Terminate this JVM. */ + def exit(): Unit = { + processOutput.interrupt() + process.destroyForcibly() + Evaluator.previousEvaluator = None + cancellationThread.interrupt() + } + +} + diff --git a/language-server/src/dotty/tools/languageserver/worksheet/ReplReader.scala b/language-server/src/dotty/tools/languageserver/worksheet/ReplReader.scala new file mode 100644 index 000000000000..bd50f9c4faf8 --- /dev/null +++ b/language-server/src/dotty/tools/languageserver/worksheet/ReplReader.scala @@ -0,0 +1,47 @@ +package dotty.tools.languageserver.worksheet + +import java.io.{InputStream, InputStreamReader} + +/** + * Reads the output from the REPL and makes it available via `next()`. + * + * @param stream The stream of messages coming out of the REPL. + */ +private class ReplReader(stream: InputStream) extends Thread { + private val in = new InputStreamReader(stream) + + private[this] var output: Option[String] = None + private[this] var closed: Boolean = false + + override def run(): Unit = synchronized { + val prompt = "scala> " + val buffer = new StringBuilder + val chars = new Array[Char](256) + var read = 0 + + while (!Thread.interrupted() && { read = in.read(chars); read >= 0 }) { + buffer.appendAll(chars, 0, read) + if (buffer.endsWith(prompt)) { + output = Some(buffer.toString.stripSuffix(prompt)) + buffer.clear() + notify() + wait() + } + } + closed = true + notify() + } + + /** Block until the next message is ready. */ + def next(): Option[String] = synchronized { + + while (!closed && output.isEmpty) { + wait() + } + + val result = output + notify() + output = None + result + } +} diff --git a/language-server/src/dotty/tools/languageserver/worksheet/Worksheet.scala b/language-server/src/dotty/tools/languageserver/worksheet/Worksheet.scala new file mode 100644 index 000000000000..2f6e7aa7cd3f --- /dev/null +++ b/language-server/src/dotty/tools/languageserver/worksheet/Worksheet.scala @@ -0,0 +1,72 @@ +package dotty.tools.languageserver.worksheet + +import dotty.tools.dotc.ast.tpd.{DefTree, Template, Tree, TypeDef} +import dotty.tools.dotc.core.Contexts.Context +import dotty.tools.dotc.interactive.SourceTree +import dotty.tools.dotc.util.Positions.Position +import dotty.tools.dotc.util.SourceFile + +import dotty.tools.dotc.core.Flags.Synthetic + +import org.eclipse.lsp4j.jsonrpc.CancelChecker + +import java.util.concurrent.CancellationException + +object Worksheet { + + /** + * Evaluate `tree` as a worksheet using the REPL. + * + * @param tree The top level object wrapping the worksheet. + * @param sendMessage A mean of communicating the results of evaluation back. + * @param cancelChecker A token to check whether execution should be cancelled. + */ + def evaluate(tree: SourceTree, + sendMessage: (Int, String) => Unit, + cancelChecker: CancelChecker)( + implicit ctx: Context): Unit = synchronized { + + Evaluator.get(cancelChecker) match { + case None => + sendMessage(1, "Couldn't start JVM.") + case Some(evaluator) => + tree.tree match { + case td @ TypeDef(_, template: Template) => + val executed = collection.mutable.Set.empty[(Int, Int)] + + template.body.foreach { + case statement: DefTree if statement.symbol.is(Synthetic) => + () + + case statement if evaluator.isAlive() && executed.add(bounds(statement.pos)) => + try { + cancelChecker.checkCanceled() + val (line, result) = execute(evaluator, statement, tree.source) + if (result.nonEmpty) sendMessage(line, result) + } catch { case _: CancellationException => () } + + case _ => + () + } + } + } + } + + /** + * Extract `tree` from the source and evaluate it in the REPL. + * + * @param evaluator The JVM that runs the REPL. + * @param tree The compiled tree to evaluate. + * @param sourcefile The sourcefile of the worksheet. + * @return The line in the sourcefile that corresponds to `tree`, and the result. + */ + private def execute(evaluator: Evaluator, tree: Tree, sourcefile: SourceFile): (Int, String) = { + val source = sourcefile.content.slice(tree.pos.start, tree.pos.end).mkString + val line = sourcefile.offsetToLine(tree.pos.end) + (line, evaluator.eval(source).getOrElse("")) + } + + private def bounds(pos: Position): (Int, Int) = (pos.start, pos.end) + +} + diff --git a/language-server/src/dotty/tools/languageserver/worksheet/WorksheetClient.scala b/language-server/src/dotty/tools/languageserver/worksheet/WorksheetClient.scala new file mode 100644 index 000000000000..d0ee71670162 --- /dev/null +++ b/language-server/src/dotty/tools/languageserver/worksheet/WorksheetClient.scala @@ -0,0 +1,15 @@ +package dotty.tools.languageserver.worksheet + +import org.eclipse.lsp4j.services.LanguageClient +import org.eclipse.lsp4j.jsonrpc.services.JsonNotification + +/** + * A `LanguageClient` that supports the `worksheet/publishOutput` notification. + * + * @see dotty.tools.languageserver.worksheet.WorksheetExecOutput + */ +trait WorksheetClient extends LanguageClient { + @JsonNotification("worksheet/publishOutput") + def publishOutput(output: WorksheetExecOutput): Unit +} + diff --git a/language-server/src/dotty/tools/languageserver/worksheet/WorksheetMessages.scala b/language-server/src/dotty/tools/languageserver/worksheet/WorksheetMessages.scala new file mode 100644 index 000000000000..6ddb72903573 --- /dev/null +++ b/language-server/src/dotty/tools/languageserver/worksheet/WorksheetMessages.scala @@ -0,0 +1,27 @@ +package dotty.tools.languageserver.worksheet + +import org.eclipse.lsp4j.VersionedTextDocumentIdentifier + +/** The parameter for the `worksheet/exec` request. */ +case class WorksheetExecParams(textDocument: VersionedTextDocumentIdentifier) { + // Used for deserialization + // see https://github.com/lampepfl/dotty/pull/5102#discussion_r222055355 + def this() = this(null) +} + +/** The response to a `worksheet/exec` request. */ +case class WorksheetExecResponse(success: Boolean) { + // Used for deserialization + // see https://github.com/lampepfl/dotty/pull/5102#discussion_r222055355 + def this() = this(false) +} + +/** + * A notification that tells the client that a line of a worksheet + * produced the specified output. + */ +case class WorksheetExecOutput(textDocument: VersionedTextDocumentIdentifier, line: Int, content: String) { + // Used for deserialization + // see https://github.com/lampepfl/dotty/pull/5102#discussion_r222055355 + def this() = this(null, 0, null) +} diff --git a/language-server/src/dotty/tools/languageserver/worksheet/WorksheetService.scala b/language-server/src/dotty/tools/languageserver/worksheet/WorksheetService.scala new file mode 100644 index 000000000000..2436d834784c --- /dev/null +++ b/language-server/src/dotty/tools/languageserver/worksheet/WorksheetService.scala @@ -0,0 +1,57 @@ +package dotty.tools.languageserver.worksheet + +import dotty.tools.dotc.core.Contexts.Context +import dotty.tools.dotc.interactive.InteractiveDriver +import dotty.tools.languageserver.DottyLanguageServer + +import org.eclipse.lsp4j.jsonrpc._//{CancelChecker, CompletableFutures} +import org.eclipse.lsp4j.jsonrpc.services._//{JsonSegment, JsonRequest} + +import java.net.URI +import java.util.concurrent.{CompletableFuture, ConcurrentHashMap} + +@JsonSegment("worksheet") +trait WorksheetService { thisServer: DottyLanguageServer => + + val worksheets: ConcurrentHashMap[URI, CompletableFuture[_]] = new ConcurrentHashMap() + + @JsonRequest + def exec(params: WorksheetExecParams): CompletableFuture[WorksheetExecResponse] = thisServer.synchronized { + val uri = new URI(params.textDocument.getUri) + val future = + computeAsync { cancelChecker => + try { + val driver = driverFor(uri) + val sendMessage = (line: Int, msg: String) => client.publishOutput(WorksheetExecOutput(params.textDocument, line, msg)) + evaluateWorksheet(driver, uri, sendMessage, cancelChecker)(driver.currentCtx) + WorksheetExecResponse(success = true) + } catch { + case _: Throwable => + WorksheetExecResponse(success = false) + } finally { + worksheets.remove(uri) + } + } + worksheets.put(uri, future) + future + } + + /** + * Evaluate the worksheet at `uri`. + * + * @param driver The driver for the project that contains the worksheet. + * @param uri The URI of the worksheet. + * @param sendMessage A mean of communicating the results of evaluation back. + * @param cancelChecker Token to check whether evaluation was cancelled + */ + private def evaluateWorksheet(driver: InteractiveDriver, + uri: URI, + sendMessage: (Int, String) => Unit, + cancelChecker: CancelChecker)( + implicit ctx: Context): Unit = { + val trees = driver.openedTrees(uri) + trees.headOption.foreach { tree => + Worksheet.evaluate(tree, sendMessage, cancelChecker) + } + } +} diff --git a/language-server/test/dotty/tools/languageserver/DefinitionTest.scala b/language-server/test/dotty/tools/languageserver/DefinitionTest.scala index 363f27c7d951..4520e9bbb9cc 100644 --- a/language-server/test/dotty/tools/languageserver/DefinitionTest.scala +++ b/language-server/test/dotty/tools/languageserver/DefinitionTest.scala @@ -55,12 +55,6 @@ class DefinitionTest { } @Test def goToDefNamedArgOverload: Unit = { - val m9 = new CodeMarker("m9") - val m10 = new CodeMarker("m10") - val m11 = new CodeMarker("m11") - val m12 = new CodeMarker("m12") - val m13 = new CodeMarker("m13") - val m14 = new CodeMarker("m14") code"""object Foo { def foo(${m1}x${m2}: String): String = ${m3}x${m4} @@ -88,10 +82,6 @@ class DefinitionTest { } @Test def goToConstructorNamedArgOverload: Unit = { - val m9 = new CodeMarker("m9") - val m10 = new CodeMarker("m10") - val m11 = new CodeMarker("m11") - val m12 = new CodeMarker("m12") withSources( code"""class Foo(${m1}x${m2}: String) { @@ -110,8 +100,6 @@ class DefinitionTest { } @Test def goToParamCopyMethod: Unit = { - val m9 = new CodeMarker("m9") - val m10 = new CodeMarker("m10") withSources( code"""case class Foo(${m1}x${m2}: Int, ${m3}y${m4}: String)""", diff --git a/language-server/test/dotty/tools/languageserver/HighlightTest.scala b/language-server/test/dotty/tools/languageserver/HighlightTest.scala index a863197fdf2e..3bf0957be1b8 100644 --- a/language-server/test/dotty/tools/languageserver/HighlightTest.scala +++ b/language-server/test/dotty/tools/languageserver/HighlightTest.scala @@ -19,4 +19,10 @@ class HighlightTest { .highlight(xRef.range, (xDef.range, DocumentHighlightKind.Read), (xRef.range, DocumentHighlightKind.Read)) } + @Test def highlightClass(): Unit = { + code"""class ${m1}Foo${m2} { new ${m3}Foo${m4} }""".withSource + .highlight(m1 to m2, (m1 to m2, DocumentHighlightKind.Read), (m3 to m4, DocumentHighlightKind.Read)) + .highlight(m3 to m4, (m1 to m2, DocumentHighlightKind.Read), (m3 to m4, DocumentHighlightKind.Read)) + } + } diff --git a/language-server/test/dotty/tools/languageserver/WorksheetTest.scala b/language-server/test/dotty/tools/languageserver/WorksheetTest.scala new file mode 100644 index 000000000000..5b2dc2644e62 --- /dev/null +++ b/language-server/test/dotty/tools/languageserver/WorksheetTest.scala @@ -0,0 +1,218 @@ +package dotty.tools.languageserver + +import org.junit.Test +import org.eclipse.lsp4j.{CompletionItemKind, DocumentHighlightKind, SymbolKind} + +import dotty.tools.dotc.core.StdNames.nme.WorksheetWrapper +import dotty.tools.dotc.core.NameOps.NameDecorator +import dotty.tools.languageserver.util.Code._ +import dotty.tools.languageserver.util.embedded.CodeMarker + +import java.lang.System.{lineSeparator => nl} + +class WorksheetTest { + + @Test def evaluateExpression: Unit = { + ws"${m1}2 + 2".withSource + .evaluate(m1, "1:val res0: Int = 4") + } + + @Test def evaluateSimpleVal: Unit = { + ws"${m1}val foo = 123".withSource + .evaluate(m1, "1:val foo: Int = 123") + } + + @Test def usePreviousDefinition: Unit = { + ws"""${m1}val foo = 123 + val bar = foo + 1""".withSource + .evaluate(m1, "1:val foo: Int = 123", + "2:val bar: Int = 124") + } + + @Test def defineObject: Unit = { + ws"""${m1}def foo(x: Int) = x + 1 + foo(1)""".withSource + .evaluate(m1, "1:def foo(x: Int): Int", + "2:val res0: Int = 2") + } + + @Test def defineCaseClass: Unit = { + ws"""${m1} case class Foo(x: Int) + Foo(1)""".withSource + .evaluate(m1, "1:// defined case class Foo", + "2:val res0: Foo = Foo(1)") + } + + @Test def defineClass: Unit = { + ws"""${m1}class Foo(x: Int) { + override def toString: String = "Foo" + } + new Foo(1)""".withSource + .evaluate(m1, "3:// defined class Foo", + "4:val res0: Foo = Foo") + } + + @Test def defineAnonymousClass0: Unit = { + ws"""${m1}new { + override def toString: String = "Foo" + }""".withSource + .evaluate(m1, "3:val res0: Object = Foo") + } + + @Test def defineAnonymousClass1: Unit = { + ws"""${m1}class Foo + trait Bar + new Foo with Bar { + override def toString: String = "Foo" + }""".withSource + .evaluate(m1, "1:// defined class Foo", + "2:// defined trait Bar", + "5:val res0: Foo & Bar = Foo") + } + + @Test def produceMultilineOutput: Unit = { + ws"""${m1}1 to 3 foreach println""".withSource + .evaluate(m1, s"1:1${nl}2${nl}3") + } + + @Test def patternMatching0: Unit = { + ws"""${m1}1 + 2 match { + case x if x % 2 == 0 => "even" + case _ => "odd" + }""".withSource + .evaluate(m1, "4:val res0: String = odd") + } + + @Test def patternMatching1: Unit = { + ws"""${m1}val (foo, bar) = (1, 2)""".withSource + .evaluate(m1, s"1:val foo: Int = 1${nl}val bar: Int = 2") + } + + @Test def evaluationException: Unit = { + ws"""${m1}val foo = 1 / 0 + val bar = 2""".withSource + .evaluateNonStrict(m1, "1:java.lang.ArithmeticException: / by zero", + "2:val bar: Int = 2") + } + + @Test def worksheetCompletion(): Unit = { + ws"""class Foo { def bar = 123 } + val x = new Foo + x.b${m1}""".withSource + .completion(m1, Set(("bar", CompletionItemKind.Method, "=> Int"))) + } + + @Test def worksheetGoToDefinition(): Unit = { + + withSources( + code"""class ${m11}Baz${m12}""", + ws"""class ${m1}Foo${m2} { def ${m3}bar${m4} = new ${m5}Baz${m6} } + val x = new ${m7}Foo${m8} + x.${m9}bar${m10}""" + ).definition(m1 to m2, List(m1 to m2)) + .definition(m3 to m4, List(m3 to m4)) + .definition(m5 to m6, List(m11 to m12)) + .definition(m7 to m8, List(m1 to m2)) + .definition(m9 to m10, List(m3 to m4)) + .definition(m11 to m12, List(m11 to m12)) + } + + @Test def worksheetReferences(): Unit = { + + withSources( + code"""class ${m11}Baz${m12}""", + ws"""class ${m1}Foo${m2} { def ${m3}bar${m4} = new ${m9}Baz${m10} } + val x = new ${m5}Foo${m6} + x.${m7}bar${m8}""" + ).references(m1 to m2, List(m5 to m6)) + .references(m3 to m4, List(m7 to m8)) + .references(m11 to m12, List(m9 to m10)) + } + + @Test def worksheetRename(): Unit = { + + def sources = + withSources( + code"""class ${m9}Baz${m10}""", + ws"""class ${m1}Foo${m2}(baz: ${m3}Baz${m4}) + val x = new ${m5}Foo${m6}(new ${m7}Baz${m8})""" + ) + + def testRenameFooFrom(m: CodeMarker) = + sources.rename(m, "Bar", Set(m1 to m2, m5 to m6)) + + def testRenameBazFrom(m: CodeMarker) = + sources.rename(m, "Bar", Set(m3 to m4, m7 to m8, m9 to m10)) + + testRenameFooFrom(m1) + testRenameBazFrom(m3) + testRenameFooFrom(m5) + testRenameBazFrom(m7) + testRenameBazFrom(m9) + } + + @Test def worksheetHighlight(): Unit = { + ws"""class ${m1}Foo${m2} { def ${m3}bar${m4} = 123 } + val x = new ${m5}Foo${m6} + x.${m7}bar${m8}""".withSource + .highlight(m1 to m2, (m1 to m2, DocumentHighlightKind.Read), (m5 to m6, DocumentHighlightKind.Read)) + .highlight(m3 to m4, (m3 to m4, DocumentHighlightKind.Read), (m7 to m8, DocumentHighlightKind.Read)) + .highlight(m5 to m6, (m1 to m2, DocumentHighlightKind.Read), (m5 to m6, DocumentHighlightKind.Read)) + .highlight(m7 to m8, (m3 to m4, DocumentHighlightKind.Read), (m7 to m8, DocumentHighlightKind.Read)) + } + + def hoverContent(typeInfo: String, comment: String): Option[String] = + Some(s"""```scala + |$typeInfo + |$comment + |```""".stripMargin) + @Test def worksheetHover(): Unit = { + ws"""/** A class */ class ${m1}Foo${m2} { /** A method */ def ${m3}bar${m4} = 123 } + val x = new ${m5}Foo${m6} + x.${m7}bar${m8}""".withSource + .hover(m1 to m2, hoverContent(s"${WorksheetWrapper}.Foo", "/** A class */")) + .hover(m3 to m4, hoverContent("Int", "/** A method */")) + .hover(m5 to m6, hoverContent(s"${WorksheetWrapper}.Foo", "/** A class */")) + .hover(m7 to m8, hoverContent("Int", "/** A method */")) + } + + @Test def worksheetDocumentSymbol(): Unit = { + ws"""class ${m1}Foo${m2} { + def ${m3}bar${m4} = 123 + }""".withSource + .documentSymbol(m1, (m1 to m2).symInfo("Foo", SymbolKind.Class, WorksheetWrapper.moduleClassName.toString), + (m3 to m4).symInfo("bar", SymbolKind.Method, "Foo")) + } + + @Test def worksheetSymbol(): Unit = { + withSources( + ws"""class ${m1}Foo${m2} { + def ${m3}bar${m4} = 123 + }""", + code"""class ${m5}Baz${m6}""" + ).symbol("Foo", (m1 to m2).symInfo("Foo", SymbolKind.Class, WorksheetWrapper.moduleClassName.toString)) + .symbol("bar", (m3 to m4).symInfo("bar", SymbolKind.Method, "Foo")) + .symbol("Baz", (m5 to m6).symInfo("Baz", SymbolKind.Class)) + } + + @Test def worksheetCancel(): Unit = { + ws"""${m1}val foo = 1 + val bar = 2 + while (true) {} + val baz = 3""".withSource + .cancelEvaluation(m1, afterMs = 5000) + } + + @Test def systemExit(): Unit = { + ws"""${m1}println("Hello, world!") + System.exit(0) + println("Goodbye!")""".withSource + .evaluate(m1, "1:Hello, world!") + } + + @Test def outputOnStdErr(): Unit = { + ws"""${m1}System.err.println("Oh no")""".withSource + .evaluate(m1, "1:Oh no") + } + +} diff --git a/language-server/test/dotty/tools/languageserver/util/Code.scala b/language-server/test/dotty/tools/languageserver/util/Code.scala index dc43e43c57d8..aca2279beb8c 100644 --- a/language-server/test/dotty/tools/languageserver/util/Code.scala +++ b/language-server/test/dotty/tools/languageserver/util/Code.scala @@ -20,6 +20,12 @@ object Code { val m6 = new CodeMarker("m6") val m7 = new CodeMarker("m7") val m8 = new CodeMarker("m8") + val m9 = new CodeMarker("m9") + val m10 = new CodeMarker("m10") + val m11 = new CodeMarker("m11") + val m12 = new CodeMarker("m12") + val m13 = new CodeMarker("m13") + val m14 = new CodeMarker("m14") implicit class CodeHelper(val sc: StringContext) extends AnyVal { @@ -35,7 +41,22 @@ object Code { * and `m3` and `m4` enclose the identifier `Hello`. These positions can then be used to ask to * perform actions such as finding all references, etc. */ - def code(args: Embedded*): SourceWithPositions = { + def code(args: Embedded*): ScalaSourceWithPositions = { + val (text, positions) = textAndPositions(args: _*) + ScalaSourceWithPositions(text, positions) + } + + /** + * An interpolator similar to `code`, but used for defining a worksheet. + * + * @see code + */ + def ws(args: Embedded*): WorksheetWithPositions = { + val (text, positions) = textAndPositions(args: _*) + WorksheetWithPositions(text, positions) + } + + private def textAndPositions(args: Embedded*): (String, List[(CodeMarker, Int, Int)]) = { val pi = sc.parts.iterator val ai = args.iterator @@ -70,22 +91,40 @@ object Code { if (pi.hasNext) stringBuilder.append(pi.next()) - SourceWithPositions(stringBuilder.result(), positions.result()) + (stringBuilder.result(), positions.result()) } } /** A new `CodeTester` working with `sources` in the workspace. */ def withSources(sources: SourceWithPositions*): CodeTester = new CodeTester(sources.toList, Nil) + sealed trait SourceWithPositions { + + /** The code contained within the virtual source file. */ + def text: String + + /** The positions of the markers that have been set. */ + def positions: List[(CodeMarker, Int, Int)] + + /** A new `CodeTester` with only this source in the workspace. */ + def withSource: CodeTester = new CodeTester(this :: Nil, Nil) + + } + /** - * A virtual source file where several markers have been set. + * A virtual Scala source file where several markers have been set. * * @param text The code contained within the virtual source file. * @param positions The positions of the markers that have been set. */ - case class SourceWithPositions(text: String, positions: List[(CodeMarker, Int, Int)]) { - /** A new `CodeTester` with only this source in the workspace. */ - def withSource: CodeTester = new CodeTester(this :: Nil, Nil) - } + case class ScalaSourceWithPositions(text: String, positions: List[(CodeMarker, Int, Int)]) extends SourceWithPositions + + /** + * A virtual worksheet where several markers have been set. + * + * @param text The code contained within the virtual source file. + * @param positions The positions of the markers that have been set. + */ + case class WorksheetWithPositions(text: String, positions: List[(CodeMarker, Int, Int)]) extends SourceWithPositions } diff --git a/language-server/test/dotty/tools/languageserver/util/CodeTester.scala b/language-server/test/dotty/tools/languageserver/util/CodeTester.scala index 6e6d7cdeefa9..42f5ed4fcc12 100644 --- a/language-server/test/dotty/tools/languageserver/util/CodeTester.scala +++ b/language-server/test/dotty/tools/languageserver/util/CodeTester.scala @@ -1,6 +1,6 @@ package dotty.tools.languageserver.util -import dotty.tools.languageserver.util.Code.SourceWithPositions +import dotty.tools.languageserver.util.Code._ import dotty.tools.languageserver.util.actions._ import dotty.tools.languageserver.util.embedded.CodeMarker import dotty.tools.languageserver.util.server.{TestFile, TestServer} @@ -16,8 +16,9 @@ class CodeTester(sources: List[SourceWithPositions], actions: List[Action]) { private val testServer = new TestServer(TestFile.testDir) - private val files = sources.zipWithIndex.map { case (code, i) => - testServer.openCode(code.text, s"Source$i.scala") + private val files = sources.zipWithIndex.map { + case (ScalaSourceWithPositions(text, _), i) => testServer.openCode(text, s"Source$i.scala") + case (WorksheetWithPositions(text, _), i) => testServer.openCode(text, s"Worksheet$i.sc") } private val positions: PositionContext = getPositions(files) @@ -116,9 +117,45 @@ class CodeTester(sources: List[SourceWithPositions], actions: List[Action]) { def symbol(query: String, symbols: SymInfo*): this.type = doAction(new CodeSymbol(query, symbols)) + /** + * Triggers evaluation of the worksheet specified by `marker`, verifies that the results of + * evaluation match `expected. + * + * @param marker A marker a identifies the worksheet to evaluate. + * @param expected The expected output. + * + * @see dotty.tools.languageserver.util.actions.WorksheetEvaluate + */ + def evaluate(marker: CodeMarker, expected: String*): this.type = + doAction(new WorksheetEvaluate(marker, expected, strict = true)) + + /** + * Triggers evaluation of the worksheet specified by `marker`, verifies that each line of output + * starts with `expected`. + * + * @param marker A marker a identifies the worksheet to evaluate. + * @param expected The expected starts of output. + * + * @see dotty.tools.languageserver.util.actions.WorksheetEvaluate + */ + def evaluateNonStrict(marker: CodeMarker, expected: String*): this.type = + doAction(new WorksheetEvaluate(marker, expected, strict = false)) + + /** + * Triggers evaluation of the worksheet specified by `marker`, then verifies that execution can be + * cancelled after `afterMs` milliseconds. + * + * @param marker A marker that identifier the worksheet to evaluate. + * @param afterMs The delay in milliseconds before cancelling execution. + * + * @see dotty.tools.languageserver.util.actions.WorksheetCancel + */ + def cancelEvaluation(marker: CodeMarker, afterMs: Long): this.type = + doAction(new WorksheetCancel(marker, afterMs)) + private def doAction(action: Action): this.type = { try { - action.execute()(testServer, positions) + action.execute()(testServer, testServer.client, positions) } catch { case ex: AssertionError => val sourcesStr = sources.zip(files).map{ case (source, file) => "// " + file.file + "\n" + source.text}.mkString("\n") diff --git a/language-server/test/dotty/tools/languageserver/util/actions/Action.scala b/language-server/test/dotty/tools/languageserver/util/actions/Action.scala index 06d0bed35a53..f4654c763ca7 100644 --- a/language-server/test/dotty/tools/languageserver/util/actions/Action.scala +++ b/language-server/test/dotty/tools/languageserver/util/actions/Action.scala @@ -2,7 +2,7 @@ package dotty.tools.languageserver.util.actions import dotty.tools.languageserver.DottyLanguageServer import dotty.tools.languageserver.util.PositionContext -import dotty.tools.languageserver.util.server.TestServer +import dotty.tools.languageserver.util.server.{TestClient, TestServer} import PositionContext._ @@ -11,7 +11,7 @@ import PositionContext._ * definition, etc.) */ trait Action { - type Exec[T] = implicit (TestServer, PositionContext) => T + type Exec[T] = implicit (TestServer, TestClient, PositionContext) => T /** Execute the action. */ def execute(): Exec[Unit] @@ -22,4 +22,7 @@ trait Action { /** The server that this action targets. */ def server: Exec[DottyLanguageServer] = implicitly[TestServer].server + /** The client that executes this action. */ + def client: Exec[TestClient] = implicitly[TestClient] + } diff --git a/language-server/test/dotty/tools/languageserver/util/actions/WorksheetAction.scala b/language-server/test/dotty/tools/languageserver/util/actions/WorksheetAction.scala new file mode 100644 index 000000000000..a60be99b7e0d --- /dev/null +++ b/language-server/test/dotty/tools/languageserver/util/actions/WorksheetAction.scala @@ -0,0 +1,24 @@ +package dotty.tools.languageserver.util.actions + +import dotty.tools.languageserver.worksheet.{WorksheetExecOutput, WorksheetExecParams, WorksheetExecResponse} +import dotty.tools.languageserver.util.embedded.CodeMarker + +import java.net.URI +import java.util.concurrent.CompletableFuture + +import org.eclipse.lsp4j.VersionedTextDocumentIdentifier + +abstract class WorksheetAction extends Action { + + /** Triggers the evaluation of the worksheet. */ + def triggerEvaluation(marker: CodeMarker): Exec[CompletableFuture[WorksheetExecResponse]] = { + server.exec(WorksheetExecParams(marker.toVersionedTextDocumentIdentifier)) + } + + /** The output of the worksheet that contains `marker`. */ + def worksheetOutput(marker: CodeMarker): Exec[List[WorksheetExecOutput]] = { + val textDocument = marker.toVersionedTextDocumentIdentifier + client.worksheetOutput.get.filter(_.textDocument.getUri == textDocument.getUri) + } + +} diff --git a/language-server/test/dotty/tools/languageserver/util/actions/WorksheetCancel.scala b/language-server/test/dotty/tools/languageserver/util/actions/WorksheetCancel.scala new file mode 100644 index 000000000000..f6374e72ac99 --- /dev/null +++ b/language-server/test/dotty/tools/languageserver/util/actions/WorksheetCancel.scala @@ -0,0 +1,24 @@ +package dotty.tools.languageserver.util.actions + +import dotty.tools.languageserver.util.PositionContext +import dotty.tools.languageserver.util.embedded.CodeMarker + +import org.junit.Assert.assertTrue + +import java.util.concurrent.TimeUnit + +class WorksheetCancel(marker: CodeMarker, afterMs: Long) extends WorksheetAction { + + override def execute(): Exec[Unit] = { + val futureResult = triggerEvaluation(marker) + Thread.sleep(afterMs) + val cancelled = futureResult.cancel(true) + + assertTrue(cancelled) + + client.worksheetOutput.clear() + } + + override def show: PositionContext.PosCtx[String] = + s"WorksheetCancel(${marker.file}, ${afterMs})" +} diff --git a/language-server/test/dotty/tools/languageserver/util/actions/WorksheetEvaluate.scala b/language-server/test/dotty/tools/languageserver/util/actions/WorksheetEvaluate.scala new file mode 100644 index 000000000000..0c3af2403292 --- /dev/null +++ b/language-server/test/dotty/tools/languageserver/util/actions/WorksheetEvaluate.scala @@ -0,0 +1,30 @@ +package dotty.tools.languageserver.util.actions + +import dotty.tools.languageserver.util.PositionContext +import dotty.tools.languageserver.util.embedded.CodeMarker + +import java.util.concurrent.TimeUnit + +import org.junit.Assert.{assertEquals, assertTrue, fail} + +class WorksheetEvaluate(marker: CodeMarker, expected: Seq[String], strict: Boolean) extends WorksheetAction { + + override def execute(): Exec[Unit] = { + val result = triggerEvaluation(marker).get(30, TimeUnit.SECONDS) + assertTrue(result.success) + + val logs = worksheetOutput(marker).map(out => s"${out.line}:${out.content}") + + if (strict) { + assertEquals(expected, logs) + } else { + expected.zip(logs).foreach { + case (expected, message) => assertTrue(s"'$message' didn't start with '$expected'", message.startsWith(expected)) + } + } + client.worksheetOutput.clear() + } + + override def show: PositionContext.PosCtx[String] = + s"WorksheetEvaluate(${marker.file}, ${expected})" +} diff --git a/language-server/test/dotty/tools/languageserver/util/embedded/CodeMarker.scala b/language-server/test/dotty/tools/languageserver/util/embedded/CodeMarker.scala index 25d8984567b5..8c198ec12900 100644 --- a/language-server/test/dotty/tools/languageserver/util/embedded/CodeMarker.scala +++ b/language-server/test/dotty/tools/languageserver/util/embedded/CodeMarker.scala @@ -40,6 +40,9 @@ class CodeMarker(val name: String) extends Embedded { def toTextDocumentIdentifier: PosCtx[TextDocumentIdentifier] = new TextDocumentIdentifier(file.uri) + def toVersionedTextDocumentIdentifier: PosCtx[VersionedTextDocumentIdentifier] = + new VersionedTextDocumentIdentifier(file.uri, 0) + def toReferenceParams(withDecl: Boolean): PosCtx[ReferenceParams] = { val rp = new ReferenceParams(new ReferenceContext(withDecl)) rp.setTextDocument(toTextDocumentIdentifier) diff --git a/language-server/test/dotty/tools/languageserver/util/server/TestClient.scala b/language-server/test/dotty/tools/languageserver/util/server/TestClient.scala index c900aadf05f0..b4aa5b815870 100644 --- a/language-server/test/dotty/tools/languageserver/util/server/TestClient.scala +++ b/language-server/test/dotty/tools/languageserver/util/server/TestClient.scala @@ -1,35 +1,52 @@ package dotty.tools.languageserver.util.server +import dotty.tools.languageserver.worksheet.{WorksheetExecOutput, WorksheetClient} + import java.util.concurrent.CompletableFuture import org.eclipse.lsp4j._ import org.eclipse.lsp4j.services._ -class TestClient extends LanguageClient { +import scala.collection.mutable.Buffer + +class TestClient extends WorksheetClient { + + class Log[T] { + private[this] val log = Buffer.empty[T] - private val log = new StringBuilder + def +=(elem: T): this.type = { log += elem; this } + def get: List[T] = log.toList + def clear(): Unit = log.clear() + } - def getLog: String = log.result() + val log = new Log[MessageParams] + val diagnostics = new Log[PublishDiagnosticsParams] + val telemetry = new Log[Any] + val worksheetOutput = new Log[WorksheetExecOutput] override def logMessage(message: MessageParams) = { - log.append(message.toString) + log += message } override def showMessage(messageParams: MessageParams) = { - log.append(messageParams.toString) + log += messageParams } override def telemetryEvent(obj: scala.Any) = { - log.append(obj.toString) + telemetry += obj } override def showMessageRequest(requestParams: ShowMessageRequestParams) = { - log.append(requestParams.toString) + log += requestParams new CompletableFuture[MessageActionItem] } - override def publishDiagnostics(diagnostics: PublishDiagnosticsParams) = { - log.append(diagnostics.toString) + override def publishDiagnostics(diagnosticsParams: PublishDiagnosticsParams) = { + diagnostics += diagnosticsParams + } + + override def publishOutput(output: WorksheetExecOutput) = { + worksheetOutput += output } } diff --git a/language-server/test/dotty/tools/languageserver/util/server/TestServer.scala b/language-server/test/dotty/tools/languageserver/util/server/TestServer.scala index 3c439de6044a..6859b9e2d777 100644 --- a/language-server/test/dotty/tools/languageserver/util/server/TestServer.scala +++ b/language-server/test/dotty/tools/languageserver/util/server/TestServer.scala @@ -11,6 +11,8 @@ import org.eclipse.lsp4j.{ DidOpenTextDocumentParams, InitializeParams, Initiali class TestServer(testFolder: Path) { val server = new DottyLanguageServer + var client: TestClient = _ + init() private[this] def init(): InitializeResult = { @@ -39,7 +41,7 @@ class TestServer(testFolder: Path) { close() } - val client = new TestClient + client = new TestClient server.connect(client) val initParams = new InitializeParams() diff --git a/sbt-bridge/src/xsbt/ConsoleInterface.scala b/sbt-bridge/src/xsbt/ConsoleInterface.scala index 34004528fea4..ba860e84d0ab 100644 --- a/sbt-bridge/src/xsbt/ConsoleInterface.scala +++ b/sbt-bridge/src/xsbt/ConsoleInterface.scala @@ -41,7 +41,7 @@ class ConsoleInterface { val s1 = driver.run(initialCommands)(s0) // TODO handle failure during initialisation - val s2 = driver.runUntilQuit(s1) + val s2 = driver.runUntilQuit(needsTerminal = true, s1) driver.run(cleanupCommands)(s2) } } diff --git a/sbt-dotty/src/dotty/tools/sbtplugin/DottyIDEPlugin.scala b/sbt-dotty/src/dotty/tools/sbtplugin/DottyIDEPlugin.scala index d423b07af9a4..4d5dd8348fd8 100644 --- a/sbt-dotty/src/dotty/tools/sbtplugin/DottyIDEPlugin.scala +++ b/sbt-dotty/src/dotty/tools/sbtplugin/DottyIDEPlugin.scala @@ -270,7 +270,15 @@ object DottyIDEPlugin extends AutoPlugin { } private def projectConfigTask(config: Configuration): Initialize[Task[Option[ProjectConfig]]] = Def.taskDyn { - if ((sources in config).value.isEmpty) Def.task { None } + val depClasspath = Attributed.data((dependencyClasspath in config).value) + + // Try to detect if this is a real Scala project or not. This is pretty + // fragile because sbt simply does not keep track of this information. We + // could check if at least one source file ends with ".scala" but that + // doesn't work for empty projects. + val isScalaProject = depClasspath.exists(_.getAbsolutePath.contains("dotty-library")) && depClasspath.exists(_.getAbsolutePath.contains("scala-library")) + + if (!isScalaProject) Def.task { None } else Def.task { // Not needed to generate the config, but this guarantees that the // generated config is usable by an IDE without any extra compilation @@ -281,7 +289,6 @@ object DottyIDEPlugin extends AutoPlugin { val compilerVersion = (scalaVersion in config).value val compilerArguments = (scalacOptions in config).value val sourceDirectories = (unmanagedSourceDirectories in config).value ++ (managedSourceDirectories in config).value - val depClasspath = Attributed.data((dependencyClasspath in config).value) val classDir = (classDirectory in config).value Some(new ProjectConfig( diff --git a/vscode-dotty/package.json b/vscode-dotty/package.json index 0be74047cae7..682335ff5bbe 100644 --- a/vscode-dotty/package.json +++ b/vscode-dotty/package.json @@ -27,23 +27,37 @@ "onLanguage:scala", "workspaceContains:.dotty-ide.json" ], - "languages": [ - { - "id": "scala", - "extensions": [ - ".scala" - ], - "aliases": [ - "Scala" - ] - } - ], "contributes": { + "languages": [ + { + "id": "scala", + "extensions": [ + ".scala", + ".sc" + ], + "aliases": [ + "Scala" + ] + } + ], + "commands": [ + { + "command": "worksheet.evaluate", + "title": "Run worksheet" + }, + { + "command": "worksheet.cancel", + "title": "Cancel worksheet evaluation" + } + ], "configurationDefaults": { "[scala]": { "editor.tabSize": 2, "editor.insertSpaces": true } + }, + "files.associations": { + "*.sc": "scala" } }, "scripts": { diff --git a/vscode-dotty/src/extension.ts b/vscode-dotty/src/extension.ts index 75e2accbbf64..e9c25ad5ec2c 100644 --- a/vscode-dotty/src/extension.ts +++ b/vscode-dotty/src/extension.ts @@ -12,8 +12,11 @@ import { LanguageClient, LanguageClientOptions, RevealOutputChannelOn, ServerOptions } from 'vscode-languageclient'; import { enableOldServerWorkaround } from './compat' +import * as worksheet from './worksheet' + let extensionContext: ExtensionContext let outputChannel: vscode.OutputChannel +export let client: LanguageClient export function activate(context: ExtensionContext) { extensionContext = context @@ -27,11 +30,23 @@ export function activate(context: ExtensionContext) { const languageServerDefaultConfigFile = path.join(extensionContext.extensionPath, './out/default-dotty-ide-config') const coursierPath = path.join(extensionContext.extensionPath, './out/coursier'); + vscode.workspace.onWillSaveTextDocument(worksheet.prepareWorksheet) + vscode.workspace.onDidSaveTextDocument(document => { + if (worksheet.isWorksheet(document)) { + worksheet.evaluateWorksheet(document) + } + }) + vscode.workspace.onDidCloseTextDocument(document => { + if (worksheet.isWorksheet(document)) { + worksheet.removeWorksheet(document) + } + }) + if (process.env['DLS_DEV_MODE']) { const portFile = `${vscode.workspace.rootPath}/.dotty-ide-dev-port` fs.readFile(portFile, (err, port) => { if (err) { - outputChannel.append(`Unable to parse ${portFile}`) + outputChannel.appendLine(`Unable to parse ${portFile}`) throw err } @@ -111,11 +126,15 @@ function fetchWithCoursier(coursierPath: string, artifact: string, extra: string coursierProc.stdout.on('data', (data: Buffer) => { classPath += data.toString().trim() }) + coursierProc.stderr.on('data', (data: Buffer) => { + let msg = data.toString().trim() + outputChannel.appendLine(msg) + }) coursierProc.on('close', (code: number) => { if (code != 0) { let msg = `Couldn't fetch '${ artifact }' (exit code ${ code }).` - outputChannel.append(msg) + outputChannel.appendLine(msg) throw new Error(msg) } }) @@ -133,6 +152,7 @@ function configureIDE(sbtClasspath: string, languageServerScalaVersion: string, // eventually run `configureIDE`. const sbtPromise = cpp.spawn("java", [ + "-Dsbt.log.noformat=true", "-classpath", sbtClasspath, "xsbt.boot.Boot", `--addPluginSbtFile=${dottyPluginSbtFile}`, @@ -141,10 +161,23 @@ function configureIDE(sbtClasspath: string, languageServerScalaVersion: string, ]) const sbtProc = sbtPromise.childProcess + // Close stdin, otherwise in case of error sbt will block waiting for the + // user input to reload or exit the build. + sbtProc.stdin.end() + + sbtProc.stdout.on('data', (data: Buffer) => { + let msg = data.toString().trim() + outputChannel.appendLine(msg) + }) + sbtProc.stderr.on('data', (data: Buffer) => { + let msg = data.toString().trim() + outputChannel.appendLine(msg) + }) + sbtProc.on('close', (code: number) => { if (code != 0) { const msg = "Configuring the IDE failed." - outputChannel.append(msg) + outputChannel.appendLine(msg) throw new Error(msg) } }) @@ -156,8 +189,10 @@ function configureIDE(sbtClasspath: string, languageServerScalaVersion: string, function run(serverOptions: ServerOptions, isOldServer: boolean) { const clientOptions: LanguageClientOptions = { documentSelector: [ - { language: 'scala', scheme: 'file', pattern: '**/*.scala' }, - { language: 'scala', scheme: 'untitled', pattern: '**/*.scala' } + { scheme: 'file', pattern: '**/*.sc' }, + { scheme: 'untitled', pattern: '**/*.sc' }, + { scheme: 'file', pattern: '**/*.scala' }, + { scheme: 'untitled', pattern: '**/*.scala' } ], synchronize: { configurationSection: 'dotty' @@ -166,10 +201,20 @@ function run(serverOptions: ServerOptions, isOldServer: boolean) { revealOutputChannelOn: RevealOutputChannelOn.Never } - const client = new LanguageClient("dotty", "Dotty", serverOptions, clientOptions) + client = new LanguageClient("dotty", "Dotty", serverOptions, clientOptions) if (isOldServer) enableOldServerWorkaround(client) + client.onReady().then(() => { + client.onNotification("worksheet/publishOutput", (params) => { + worksheet.handleMessage(params) + }) + }) + + vscode.commands.registerCommand(worksheet.worksheetEvaluateKey, () => { + worksheet.evaluateWorksheetCommand() + }) + // Push the disposable to the context's subscriptions so that the // client can be deactivated on extension deactivation extensionContext.subscriptions.push(client.start()); diff --git a/vscode-dotty/src/worksheet.ts b/vscode-dotty/src/worksheet.ts new file mode 100644 index 000000000000..d6b972cebbdc --- /dev/null +++ b/vscode-dotty/src/worksheet.ts @@ -0,0 +1,335 @@ +import * as vscode from 'vscode' +import { client } from './extension' +import { VersionedTextDocumentIdentifier } from 'vscode-languageserver-protocol' + +/** A worksheet managed by vscode */ +class Worksheet { + + private constructor(document: vscode.TextDocument) { + this.document = document + } + + /** The text document that this worksheet represents. */ + readonly document: vscode.TextDocument + + /** All decorations that have been added so far */ + decorationTypes: vscode.TextEditorDecorationType[] = [] + + /** The number of blank lines that have been inserted to fit the output so far. */ + insertedLines: number = 0 + + /** The lines that contain decorations */ + decoratedLines: Set = new Set() + + /** The minimum margin to add so that the decoration is shown after all text. */ + margin: number = 0 + + /** Whether this worksheet has finished evaluating. */ + finished: boolean = false + + /** Remove all decorations and resets this worksheet. */ + reset() { + this.decorationTypes.forEach(decoration => decoration.dispose()) + this.insertedLines = 0 + this.decoratedLines.clear() + this.margin = longestLine(this.document) + 5 + this.finished = false + } + + /** All the worksheets */ + private static worksheets: Map = new Map() + + /** + * If `document` is a worksheet, create a new worksheet for it, or return the existing one. */ + static getOrNewWorksheet(document: vscode.TextDocument): Worksheet | undefined { + if (!isWorksheet(document)) return + else { + const existing = Worksheet.worksheets.get(document) + if (existing) { + return existing + } else { + const newWorksheet = new Worksheet(document) + Worksheet.worksheets.set(document, newWorksheet) + return newWorksheet + } + } + } + + /** If it exists, remove the worksheet representing `document`. */ + static delete(document: vscode.TextDocument) { + Worksheet.worksheets.delete(document) + } +} + +/** The parameter for the `worksheet/exec` request. */ +class WorksheetExecParams { + constructor(textDocument: vscode.TextDocument) { + this.textDocument = VersionedTextDocumentIdentifier.create(textDocument.uri.toString(), textDocument.version) + } + + readonly textDocument: VersionedTextDocumentIdentifier +} + +/** The parameter for the `worksheet/publishOutput` notification. */ +class WorksheetOutput { + constructor(textDocument: VersionedTextDocumentIdentifier, line: number, content: string) { + this.textDocument = textDocument + this.line = line + this.content = content + } + + readonly textDocument: VersionedTextDocumentIdentifier + readonly line: number + readonly content: string +} + +/** + * The command key for evaluating a worksheet. Exposed to users as + * `Run worksheet`. + */ +export const worksheetEvaluateKey = "worksheet.evaluate" + +/** Remove the worksheet corresponding to the given document. */ +export function removeWorksheet(document: vscode.TextDocument) { + Worksheet.delete(document) +} + +/** Is this document a worksheet? */ +export function isWorksheet(document: vscode.TextDocument): boolean { + return document.fileName.endsWith(".sc") +} + +/** + * The VSCode command executed when the user select `Run worksheet`. + * + * We check whether the buffer is dirty, and if it is, we save it. Evaluation will then be + * triggered by file save. + * If the buffer is clean, we do the necessary preparation for worksheet (compute margin, + * remove blank lines, etc.) and check if the buffer has been changed by that. If it is, we save + * and the evaluation will be triggered by file save. + * If the buffer is still clean, call `evaluateWorksheet`. + */ +export function evaluateWorksheetCommand() { + const editor = vscode.window.activeTextEditor + if (editor) { + const document = editor.document + + if (document.isDirty) document.save() // This will trigger evaluation + else { + const worksheet = Worksheet.getOrNewWorksheet(document) + if (worksheet) { + _prepareWorksheet(worksheet).then(_ => { + if (document.isDirty) document.save() // This will trigger evaluation + else evaluateWorksheet(document) + }) + } + } + } +} + +/** + * Evaluate the worksheet in `document`, display a progress bar during evaluation. + */ +export function evaluateWorksheet(document: vscode.TextDocument): Thenable<{}> { + + const worksheet = Worksheet.getOrNewWorksheet(document) + if (worksheet) { + return vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: "Evaluating worksheet", + cancellable: true + }, (_, token) => { + return client.sendRequest("worksheet/exec", new WorksheetExecParams(worksheet.document), token) + }) + } else { + return Promise.reject() + } +} + +/** + * If the document that will be saved is a worksheet, resets the "worksheet state" + * (margin and number of inserted lines), and removes redundant blank lines that + * have been inserted by a previous evaluation. + * + * The file save operation is blocked until the worksheet is ready to be evaluated. + * + * @param event `TextDocumentWillSaveEvent`. + */ +export function prepareWorksheet(event: vscode.TextDocumentWillSaveEvent) { + const worksheet = Worksheet.getOrNewWorksheet(event.document) + if (worksheet) { + const setup = _prepareWorksheet(worksheet) + event.waitUntil(setup) + } +} + +function _prepareWorksheet(worksheet: Worksheet) { + return removeRedundantBlankLines(worksheet).then(_ => worksheet.reset()) +} + +/** + * Handle the result of evaluating part of a worksheet. + * This is called when we receive a `window/logMessage`. + * + * @param message The result of evaluating part of a worksheet. + */ +export function handleMessage(output: WorksheetOutput) { + + const editor = vscode.window.visibleTextEditors.find(e => { + let uri = e.document.uri.toString() + return uri == output.textDocument.uri + }) + + if (editor) { + const worksheet = Worksheet.getOrNewWorksheet(editor.document) + + if (worksheet) { + worksheetDisplayResult(output.line - 1, output.content, worksheet, editor) + } + } +} + +/** + * Create a new `TextEditorDecorationType` showing `text`. The decoration + * will appear `margin` characters after the end of the line. + * + * @param margin The margin in characters between the end of the line + * and the decoration. + * @param text The text of the decoration. + * @return a new `TextEditorDecorationType`. + */ +function worksheetCreateDecoration(margin: number, text: string) { + return vscode.window.createTextEditorDecorationType({ + isWholeLine: true, + after: { + contentText: text, + margin: `0px 0px 0px ${margin}ch`, + fontStyle: "italic", + color: "light gray", + } + }) +} + +/** + * Finds the length in characters of the longest line of `document`. + * + * @param document The document to inspect. + * @return The length in characters of the longest line. + */ +function longestLine(document: vscode.TextDocument) { + let maxLength = 0 + const lineCount = document.lineCount + for (let i = 0; i < lineCount; ++i) { + let length = document.lineAt(i).text.length + maxLength = Math.max(maxLength, length) + } + + return maxLength +} + +/** + * Remove the repeated blank lines in the source. + * + * Evaluating a worksheet can insert new lines in the worksheet so that the + * output of a line fits below the line. Before evaluation, we remove blank + * lines in the worksheet to keep its length under control. + * + * @param worksheet The worksheet where blank lines must be removed. + * @return A `Thenable` removing the blank lines upon completion. + */ +function removeRedundantBlankLines(worksheet: Worksheet) { + + function hasDecoration(line: number): boolean { + return worksheet.decoratedLines.has(line) + } + + const document = worksheet.document + const lineCount = document.lineCount + let rangesToRemove: vscode.Range[] = [] + let rangeStart = 0 + let rangeEnd = 0 + let inRange = true + + function addRange() { + inRange = false + if (rangeStart < rangeEnd) { + rangesToRemove.push(new vscode.Range(rangeStart, 0, rangeEnd, 0)) + } + return + } + + for (let i = 0; i < lineCount; ++i) { + const isEmpty = document.lineAt(i).isEmptyOrWhitespace && hasDecoration(i) + if (inRange) { + if (isEmpty) rangeEnd += 1 + else addRange() + } else { + if (isEmpty) { + rangeStart = i + rangeEnd = i + 1 + inRange = true + } + } + } + + if (inRange) { + rangeEnd = lineCount + addRange() + } + + return rangesToRemove.reverse().reduce((chain: Thenable, range) => { + return chain.then(_ => { + const edit = new vscode.WorkspaceEdit() + edit.delete(document.uri, range) + return vscode.workspace.applyEdit(edit) + }) + }, Promise.resolve(true)) +} + +/** + * Parse and display the result of evaluating part of a worksheet. + * + * @see worksheetCreateDecoration + * + * @param lineNumber The number of the line in the source that produced the result. + * @param evalResult The evaluation result. + * @param worksheet The worksheet that receives the result. + * @param editor The editor where to display the result. + * @return A `Thenable` that will insert necessary lines to fit the output + * and display the decorations upon completion. + */ +function worksheetDisplayResult(lineNumber: number, evalResult: string, worksheet: Worksheet, editor: vscode.TextEditor) { + + const resultLines = evalResult.trim().split(/\r\n|\r|\n/g) + const margin = worksheet.margin + + // The line where the next decoration should be put. + // It's the number of the line that produced the output, plus the number + // of lines that we've inserted so far. + let actualLine = lineNumber + worksheet.insertedLines + + // If the output has more than one line, we need to insert blank lines + // below the line that produced the output to fit the output. + const addNewLinesEdit = new vscode.WorkspaceEdit() + if (resultLines.length > 1) { + const linesToInsert = resultLines.length - 1 + const editPos = new vscode.Position(actualLine + 1, 0) // add after the line + addNewLinesEdit.insert(editor.document.uri, editPos, "\n".repeat(linesToInsert)) + worksheet.insertedLines += linesToInsert + } + + return vscode.workspace.applyEdit(addNewLinesEdit).then(_ => { + for (let line of resultLines) { + const decorationPosition = new vscode.Position(actualLine, 0) + const decorationMargin = margin - editor.document.lineAt(actualLine).text.length + const decorationType = worksheetCreateDecoration(decorationMargin, line) + worksheet.decorationTypes.push(decorationType) + worksheet.decoratedLines.add(actualLine) + + const decoration = { range: new vscode.Range(decorationPosition, decorationPosition), hoverMessage: line } + editor.setDecorations(decorationType, [decoration]) + actualLine += 1 + } + }) +} +