From 63d3a3507705e214d05d62cd8b35edc2c580c31f Mon Sep 17 00:00:00 2001 From: rochala Date: Fri, 12 May 2023 16:13:51 +0200 Subject: [PATCH 01/25] compiler util: collect comments during Scanner phase and store it in compilationUnit --- .../dotty/tools/dotc/CompilationUnit.scala | 4 ++++ .../src/dotty/tools/dotc/core/Comments.scala | 5 +++++ .../tools/dotc/parsing/ParserPhase.scala | 1 + .../dotty/tools/dotc/parsing/Scanners.scala | 22 +++++++++---------- .../dotc/printing/SyntaxHighlighting.scala | 4 ++-- 5 files changed, 23 insertions(+), 13 deletions(-) diff --git a/compiler/src/dotty/tools/dotc/CompilationUnit.scala b/compiler/src/dotty/tools/dotc/CompilationUnit.scala index 8415646eb16c..c121fbaf7c00 100644 --- a/compiler/src/dotty/tools/dotc/CompilationUnit.scala +++ b/compiler/src/dotty/tools/dotc/CompilationUnit.scala @@ -5,6 +5,7 @@ import core._ import Contexts._ import SymDenotations.ClassDenotation import Symbols._ +import Comments.Comment import util.{FreshNameCreator, SourceFile, NoSource} import util.Spans.Span import ast.{tpd, untpd} @@ -69,6 +70,9 @@ class CompilationUnit protected (val source: SourceFile) { /** Can this compilation unit be suspended */ def isSuspendable: Boolean = true + /** List of all comments present in this compilation unit */ + var comments: List[Comment] = Nil + /** Suspends the compilation unit by thowing a SuspendException * and recording the suspended compilation unit */ diff --git a/compiler/src/dotty/tools/dotc/core/Comments.scala b/compiler/src/dotty/tools/dotc/core/Comments.scala index 1b20b75ad8ac..e84bbcaf36cf 100644 --- a/compiler/src/dotty/tools/dotc/core/Comments.scala +++ b/compiler/src/dotty/tools/dotc/core/Comments.scala @@ -62,6 +62,7 @@ object Comments { expanded.map(removeSections(_, "@usecase", "@define")) val isDocComment: Boolean = Comment.isDocComment(raw) + val isHeaderComment: Boolean = Comment.isHeaderComment(raw) /** * Expands this comment by giving its content to `f`, and then parsing the `@usecase` sections. @@ -78,8 +79,12 @@ object Comments { } object Comment { + val usingDirectives: List[String] = List("// using", "//> using") + val ammoniteHeaders: List[String] = List("// scala", "// ammonite") def isDocComment(comment: String): Boolean = comment.startsWith("/**") + def isHeaderComment(comment: String): Boolean = + (usingDirectives ++ ammoniteHeaders).exists(comment.startsWith) def apply(span: Span, raw: String): Comment = Comment(span, raw, None, Nil, Map.empty) diff --git a/compiler/src/dotty/tools/dotc/parsing/ParserPhase.scala b/compiler/src/dotty/tools/dotc/parsing/ParserPhase.scala index a67bca34cae2..7caff4996b85 100644 --- a/compiler/src/dotty/tools/dotc/parsing/ParserPhase.scala +++ b/compiler/src/dotty/tools/dotc/parsing/ParserPhase.scala @@ -30,6 +30,7 @@ class Parser extends Phase { val p = new Parsers.Parser(unit.source) // p.in.debugTokenStream = true val tree = p.parse() + ctx.compilationUnit.comments = p.in.comments if (p.firstXmlPos.exists && !firstXmlPos.exists) firstXmlPos = p.firstXmlPos tree diff --git a/compiler/src/dotty/tools/dotc/parsing/Scanners.scala b/compiler/src/dotty/tools/dotc/parsing/Scanners.scala index fac73bfb4992..0339fc0531f4 100644 --- a/compiler/src/dotty/tools/dotc/parsing/Scanners.scala +++ b/compiler/src/dotty/tools/dotc/parsing/Scanners.scala @@ -227,11 +227,11 @@ object Scanners { */ private var docstringMap: SortedMap[Int, Comment] = SortedMap.empty - /* A Buffer for comment positions */ - private val commentPosBuf = new mutable.ListBuffer[Span] + /* A Buffer for comments */ + private val commentBuf = new mutable.ListBuffer[Comment] - /** Return a list of all the comment positions */ - def commentSpans: List[Span] = commentPosBuf.toList + /** Return a list of all the comments */ + def comments: List[Comment] = commentBuf.toList private def addComment(comment: Comment): Unit = { val lookahead = lookaheadReader() @@ -246,7 +246,7 @@ object Scanners { def getDocComment(pos: Int): Option[Comment] = docstringMap.get(pos) /** A buffer for comments */ - private val commentBuf = CharBuffer(initialCharBufferSize) + private val currentCommentBuf = CharBuffer(initialCharBufferSize) def toToken(identifier: SimpleName): Token = def handleMigration(keyword: Token): Token = @@ -523,7 +523,7 @@ object Scanners { * * The following tokens can start an indentation region: * - * : = => <- if then else while do try catch + * : = => <- if then else while do try catch * finally for yield match throw return with * * Inserting an INDENT starts a new indentation region with the indentation of the current @@ -1019,7 +1019,7 @@ object Scanners { private def skipComment(): Boolean = { def appendToComment(ch: Char) = - if (keepComments) commentBuf.append(ch) + if (keepComments) currentCommentBuf.append(ch) def nextChar() = { appendToComment(ch) Scanner.this.nextChar() @@ -1047,9 +1047,9 @@ object Scanners { def finishComment(): Boolean = { if (keepComments) { val pos = Span(start, charOffset - 1, start) - val comment = Comment(pos, commentBuf.toString) - commentBuf.clear() - commentPosBuf += pos + val comment = Comment(pos, currentCommentBuf.toString) + currentCommentBuf.clear() + commentBuf += comment if (comment.isDocComment) addComment(comment) @@ -1065,7 +1065,7 @@ object Scanners { else if (ch == '*') { nextChar(); skipComment(); finishComment() } else { // This was not a comment, remove the `/` from the buffer - commentBuf.clear() + currentCommentBuf.clear() false } } diff --git a/compiler/src/dotty/tools/dotc/printing/SyntaxHighlighting.scala b/compiler/src/dotty/tools/dotc/printing/SyntaxHighlighting.scala index 53e6b9472f5e..7030776dd06c 100644 --- a/compiler/src/dotty/tools/dotc/printing/SyntaxHighlighting.scala +++ b/compiler/src/dotty/tools/dotc/printing/SyntaxHighlighting.scala @@ -83,8 +83,8 @@ object SyntaxHighlighting { } } - for (span <- scanner.commentSpans) - highlightPosition(span, CommentColor) + for (comment <- scanner.comments) + highlightPosition(comment.span, CommentColor) object TreeHighlighter extends untpd.UntypedTreeTraverser { import untpd._ From fde037acc66a356c9940eb6371ee50f4ea4c9fd5 Mon Sep 17 00:00:00 2001 From: rochala Date: Fri, 12 May 2023 16:42:39 +0200 Subject: [PATCH 02/25] metals initial version: 41e96ee33f82 copied into dotty --- .../internal/mtags/MtagsEnrichments.scala | 273 +++++ .../scala/meta/internal/pc/AutoImports.scala | 386 +++++++ .../internal/pc/AutoImportsProvider.scala | 103 ++ .../meta/internal/pc/CompilerInterfaces.scala | 16 + .../internal/pc/CompilerSearchVisitor.scala | 92 ++ .../internal/pc/CompletionItemResolver.scala | 88 ++ .../pc/ConvertToNamedArgumentsProvider.scala | 86 ++ .../internal/pc/ExtractMethodProvider.scala | 171 ++++ .../meta/internal/pc/HoverProvider.scala | 217 ++++ .../meta/internal/pc/IndexedContext.scala | 220 ++++ .../internal/pc/InferredTypeProvider.scala | 328 ++++++ .../scala/meta/internal/pc/MetalsDriver.scala | 56 + .../meta/internal/pc/MetalsInteractive.scala | 341 +++++++ .../main/scala/meta/internal/pc/Params.scala | 23 + .../scala/meta/internal/pc/PcCollector.scala | 574 +++++++++++ .../internal/pc/PcDefinitionProvider.scala | 172 ++++ .../pc/PcDocumentHighlightProvider.scala | 33 + .../pc/PcInlineValueProviderImpl.scala | 201 ++++ .../meta/internal/pc/PcRenameProvider.scala | 53 + .../pc/PcSemanticTokensProvider.scala | 150 +++ .../internal/pc/Scala3CompilerAccess.scala | 38 + .../internal/pc/Scala3CompilerWrapper.scala | 25 + .../pc/ScalaPresentationCompiler.scala | 394 +++++++ .../internal/pc/SelectionRangeProvider.scala | 155 +++ .../meta/internal/pc/SemanticdbSymbols.scala | 143 +++ .../pc/SemanticdbTextDocumentProvider.scala | 62 ++ .../internal/pc/SignatureHelpProvider.scala | 193 ++++ .../scala/meta/internal/pc/TastyUtils.scala | 73 ++ .../pc/WorksheetSemanticdbProvider.scala | 22 + .../completions/AmmoniteFileCompletions.scala | 104 ++ .../completions/AmmoniteIvyCompletions.scala | 46 + .../pc/completions/CompletionPos.scala | 138 +++ .../pc/completions/CompletionProvider.scala | 278 +++++ .../pc/completions/CompletionSuffix.scala | 48 + .../pc/completions/CompletionValue.scala | 261 +++++ .../internal/pc/completions/Completions.scala | 957 ++++++++++++++++++ .../pc/completions/FilenameCompletions.scala | 33 + .../completions/InterpolatorCompletions.scala | 316 ++++++ .../pc/completions/KeywordsCompletions.scala | 163 +++ .../pc/completions/MatchCaseCompletions.scala | 635 ++++++++++++ .../pc/completions/NamedArgCompletions.scala | 219 ++++ .../pc/completions/OverrideCompletions.scala | 583 +++++++++++ .../pc/completions/ScalaCliCompletions.scala | 39 + .../pc/completions/ScaladocCompletions.scala | 130 +++ .../internal/pc/printer/DotcPrinter.scala | 112 ++ .../internal/pc/printer/MetalsPrinter.scala | 448 ++++++++ .../internal/pc/printer/ShortenedNames.scala | 228 +++++ 47 files changed, 9426 insertions(+) create mode 100644 presentation-compiler/src/main/scala/meta/internal/mtags/MtagsEnrichments.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/AutoImports.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/AutoImportsProvider.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/CompilerInterfaces.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/CompilerSearchVisitor.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/CompletionItemResolver.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/ConvertToNamedArgumentsProvider.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/ExtractMethodProvider.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/HoverProvider.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/IndexedContext.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/InferredTypeProvider.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/MetalsDriver.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/MetalsInteractive.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/Params.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/PcCollector.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/PcDefinitionProvider.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/PcDocumentHighlightProvider.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/PcInlineValueProviderImpl.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/PcRenameProvider.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/PcSemanticTokensProvider.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/Scala3CompilerAccess.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/Scala3CompilerWrapper.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/ScalaPresentationCompiler.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/SelectionRangeProvider.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/SemanticdbSymbols.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/SemanticdbTextDocumentProvider.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/SignatureHelpProvider.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/TastyUtils.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/WorksheetSemanticdbProvider.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/completions/AmmoniteFileCompletions.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/completions/AmmoniteIvyCompletions.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/completions/CompletionPos.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/completions/CompletionProvider.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/completions/CompletionSuffix.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/completions/CompletionValue.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/completions/Completions.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/completions/FilenameCompletions.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/completions/InterpolatorCompletions.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/completions/KeywordsCompletions.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/completions/MatchCaseCompletions.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/completions/NamedArgCompletions.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/completions/OverrideCompletions.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/completions/ScalaCliCompletions.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/completions/ScaladocCompletions.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/printer/DotcPrinter.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/printer/MetalsPrinter.scala create mode 100644 presentation-compiler/src/main/scala/meta/internal/pc/printer/ShortenedNames.scala diff --git a/presentation-compiler/src/main/scala/meta/internal/mtags/MtagsEnrichments.scala b/presentation-compiler/src/main/scala/meta/internal/mtags/MtagsEnrichments.scala new file mode 100644 index 000000000000..9a44e3dc1e71 --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/mtags/MtagsEnrichments.scala @@ -0,0 +1,273 @@ +package scala.meta.internal.mtags + +import scala.annotation.tailrec +import scala.util.control.NonFatal + +import scala.meta.internal.jdk.CollectionConverters.* +import scala.meta.internal.pc.MetalsInteractive +import scala.meta.internal.pc.SemanticdbSymbols +import scala.meta.pc.OffsetParams +import scala.meta.pc.RangeParams +import scala.meta.pc.SymbolDocumentation +import scala.meta.pc.SymbolSearch + +import dotty.tools.dotc.ast.tpd.* +import dotty.tools.dotc.core.Contexts.* +import dotty.tools.dotc.core.Denotations.* +import dotty.tools.dotc.core.Flags.* +import dotty.tools.dotc.core.NameOps.* +import dotty.tools.dotc.core.Names.* +import dotty.tools.dotc.core.StdNames.* +import dotty.tools.dotc.core.SymDenotations.NoDenotation +import dotty.tools.dotc.core.Symbols.* +import dotty.tools.dotc.core.Types.AppliedType +import dotty.tools.dotc.core.Types.Type +import dotty.tools.dotc.interactive.Interactive +import dotty.tools.dotc.interactive.InteractiveDriver +import dotty.tools.dotc.util.SourcePosition +import dotty.tools.dotc.util.Spans +import dotty.tools.dotc.util.Spans.Span +import org.eclipse.{lsp4j as l} + +object MtagsEnrichments extends ScalametaCommonEnrichments: + + extension (driver: InteractiveDriver) + + def sourcePosition( + params: OffsetParams + ): SourcePosition = + val uri = params.uri + val source = driver.openedFiles(uri) + val span = params match + case p: RangeParams if p.offset != p.endOffset => + p.trimWhitespaceInRange.fold { + Spans.Span(p.offset, p.endOffset) + } { + case trimmed: RangeParams => + Spans.Span(trimmed.offset, trimmed.endOffset) + case offset => + Spans.Span(p.offset, p.offset) + } + case _ => Spans.Span(params.offset) + + new SourcePosition(source, span) + end sourcePosition + + def localContext(params: OffsetParams): Context = + if driver.currentCtx.run.units.isEmpty then + throw new RuntimeException( + "No source files were passed to the Scala 3 presentation compiler" + ) + val unit = driver.currentCtx.run.units.head + val pos = driver.sourcePosition(params) + val newctx = driver.currentCtx.fresh.setCompilationUnit(unit) + val tpdPath = + Interactive.pathTo(newctx.compilationUnit.tpdTree, pos.span)(using + newctx + ) + MetalsInteractive.contextOfPath(tpdPath)(using newctx) + end localContext + + end extension + + extension (pos: SourcePosition) + def offsetToPos(offset: Int): l.Position = + // dotty's `SourceFile.column` method treats tabs incorrectly. + // If a line starts with tabs, they just don't count as symbols, resulting in a wrong editRange. + // see: https://github.com/scalameta/metals/pull/3702 + val lineStartOffest = pos.source.startOfLine(offset) + val line = pos.source.offsetToLine(lineStartOffest) + val column = offset - lineStartOffest + new l.Position(line, column) + + def toLsp: l.Range = + new l.Range( + offsetToPos(pos.start), + offsetToPos(pos.end), + ) + + def withEnd(end: Int): SourcePosition = + pos.withSpan(pos.span.withEnd(end)) + + def withStart(end: Int): SourcePosition = + pos.withSpan(pos.span.withStart(end)) + + def focusAt(point: Int): SourcePosition = + pos.withSpan(pos.span.withPoint(point).focus) + + def toLocation: Option[l.Location] = + for + uri <- InteractiveDriver.toUriOption(pos.source) + range <- if pos.exists then Some(pos.toLsp) else None + yield new l.Location(uri.toString, range) + + def encloses(other: SourcePosition): Boolean = + pos.start <= other.start && pos.end >= other.end + + def encloses(other: RangeParams): Boolean = + pos.start <= other.offset() && pos.end >= other.endOffset() + end extension + + extension (pos: RangeParams) + def encloses(other: SourcePosition): Boolean = + pos.offset() <= other.start && pos.endOffset() >= other.end + + extension (sym: Symbol)(using Context) + def fullNameBackticked: String = fullNameBackticked(Set.empty) + + def fullNameBackticked(exclusions: Set[String]): String = + @tailrec + def loop(acc: List[String], sym: Symbol): List[String] = + if sym == NoSymbol || sym.isRoot || sym.isEmptyPackage then acc + else if sym.isPackageObject then loop(acc, sym.owner) + else + val v = this.nameBackticked(sym)(exclusions) + loop(v :: acc, sym.owner) + loop(Nil, sym).mkString(".") + + def decodedName: String = sym.name.decoded + + def companion: Symbol = + if sym.is(Module) then sym.companionClass else sym.companionModule + + def nameBackticked: String = nameBackticked(Set.empty) + + def nameBackticked(exclusions: Set[String]): String = + KeywordWrapper.Scala3.backtickWrap(sym.decodedName, exclusions) + + def withUpdatedTpe(tpe: Type): Symbol = + val upd = sym.copy(info = tpe) + val paramsWithFlags = + sym.paramSymss + .zip(upd.paramSymss) + .map((l1, l2) => + l1.zip(l2) + .map((s1, s2) => + s2.flags = s1.flags + s2 + ) + ) + upd.rawParamss = paramsWithFlags + upd + end withUpdatedTpe + + // Returns true if this symbol is locally defined from an old version of the source file. + def isStale: Boolean = + sym.sourcePos.span.exists && { + val source = ctx.source + if source ne sym.source then + !source.content.startsWith( + sym.decodedName.toString(), + sym.sourcePos.span.point, + ) + else false + } + end extension + + extension (name: Name)(using Context) + def decoded: String = name.stripModuleClassSuffix.show + + extension (s: String) + def backticked: String = + KeywordWrapper.Scala3.backtickWrap(s) + + def stripBackticks: String = s.stripPrefix("`").stripSuffix("`") + + extension (search: SymbolSearch) + def symbolDocumentation(symbol: Symbol)(using + Context + ): Option[SymbolDocumentation] = + def toSemanticdbSymbol(symbol: Symbol) = + SemanticdbSymbols.symbolName( + if !symbol.is(JavaDefined) && symbol.isPrimaryConstructor then + symbol.owner + else symbol + ) + val sym = toSemanticdbSymbol(symbol) + val documentation = search.documentation( + sym, + () => symbol.allOverriddenSymbols.map(toSemanticdbSymbol).toList.asJava, + ) + if documentation.isPresent then Some(documentation.get()) + else None + end symbolDocumentation + end extension + + extension (tree: Tree) + def qual: Tree = + tree match + case Apply(q, _) => q.qual + case TypeApply(q, _) => q.qual + case AppliedTypeTree(q, _) => q.qual + case Select(q, _) => q + case _ => tree + + def seenFrom(sym: Symbol)(using Context): (Type, Symbol) = + try + val pre = tree.qual + val denot = sym.denot.asSeenFrom(pre.tpe.widenTermRefExpr) + (denot.info, sym.withUpdatedTpe(denot.info)) + catch case NonFatal(e) => (sym.info, sym) + end extension + + extension (imp: Import) + def selector(span: Span)(using Context): Option[Symbol] = + for sel <- imp.selectors.find(_.span.contains(span)) + yield imp.expr.symbol.info.member(sel.name).symbol + + extension (denot: Denotation) + def allSymbols: List[Symbol] = + denot match + case MultiDenotation(denot1, denot2) => + List( + denot1.allSymbols, + denot2.allSymbols, + ).flatten + case NoDenotation => Nil + case _ => + List(denot.symbol) + + extension (path: List[Tree]) + def expandRangeToEnclosingApply( + pos: SourcePosition + )(using Context): List[Tree] = + def tryTail(enclosing: List[Tree]): Option[List[Tree]] = + enclosing match + case Nil => None + case head :: tail => + head match + case t: GenericApply + if t.fun.srcPos.span.contains( + pos.span + ) && !t.tpe.isErroneous => + tryTail(tail).orElse(Some(enclosing)) + case in: Inlined => + tryTail(tail).orElse(Some(enclosing)) + case New(_) => + tail match + case Nil => None + case Select(_, _) :: next => + tryTail(next) + case _ => + None + case sel @ Select(qual, nme.apply) if qual.span == sel.nameSpan => + tryTail(tail).orElse(Some(enclosing)) + case _ => + None + path match + case head :: tail => + tryTail(tail).getOrElse(path) + case _ => + List(EmptyTree) + end expandRangeToEnclosingApply + end extension + + extension (tpe: Type) + def metalsDealias(using Context): Type = + tpe.dealias match + case app @ AppliedType(tycon, params) => + // we dealias applied type params by hand, because `dealias` doesn't do it + AppliedType(tycon, params.map(_.metalsDealias)) + case dealised => dealised + +end MtagsEnrichments diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/AutoImports.scala b/presentation-compiler/src/main/scala/meta/internal/pc/AutoImports.scala new file mode 100644 index 000000000000..563388c31611 --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/AutoImports.scala @@ -0,0 +1,386 @@ +package scala.meta.internal.pc + +import scala.annotation.tailrec +import scala.jdk.CollectionConverters.* + +import scala.meta.internal.mtags.KeywordWrapper +import scala.meta.internal.mtags.MtagsEnrichments.* +import scala.meta.internal.pc.printer.ShortenedNames.ShortName +import scala.meta.pc.PresentationCompilerConfig + +import dotty.tools.dotc.ast.tpd.* +import dotty.tools.dotc.core.Contexts.* +import dotty.tools.dotc.core.Flags.* +import dotty.tools.dotc.core.Names.* +import dotty.tools.dotc.core.Symbols.* +import dotty.tools.dotc.util.SourcePosition +import dotty.tools.dotc.util.Spans +import org.eclipse.{lsp4j as l} + +object AutoImports extends AutoImportsBackticks: + + object AutoImport: + def renameConfigMap(config: PresentationCompilerConfig)(using + Context + ): Map[Symbol, String] = + config.symbolPrefixes.asScala.flatMap { (from, to) => + val pkg = SemanticdbSymbols.inverseSemanticdbSymbol(from) + val rename = to.stripSuffix(".").stripSuffix("#") + List(pkg, pkg.map(_.moduleClass)).flatten + .filter(_ != NoSymbol) + .map((_, rename)) + }.toMap + end AutoImport + + sealed trait SymbolIdent: + def value: String + + object SymbolIdent: + case class Direct(value: String) extends SymbolIdent + case class Select(qual: SymbolIdent, name: String) extends SymbolIdent: + def value: String = s"${qual.value}.$name" + + def direct(name: String): SymbolIdent = Direct(name) + + def fullIdent(symbol: Symbol)(using Context): SymbolIdent = + val symbols = symbol.ownersIterator.toList + .takeWhile(_ != ctx.definitions.RootClass) + .reverse + + symbols match + case head :: tail => + tail.foldLeft(direct(head.nameBackticked))((acc, next) => + Select(acc, next.nameBackticked) + ) + case Nil => + SymbolIdent.direct("") + + end SymbolIdent + + sealed trait ImportSel: + def sym: Symbol + + object ImportSel: + final case class Direct(sym: Symbol) extends ImportSel + final case class Rename(sym: Symbol, rename: String) extends ImportSel + + case class SymbolImport( + sym: Symbol, + ident: SymbolIdent, + importSel: Option[ImportSel], + ): + + def name: String = ident.value + + object SymbolImport: + + def simple(sym: Symbol)(using Context): SymbolImport = + SymbolImport(sym, SymbolIdent.direct(sym.nameBackticked), None) + + /** + * Returns AutoImportsGenerator + * + * @param pos A source position where the autoImport is invoked + * @param text Source text of the file + * @param tree A typed tree of the file + * @param indexedContext A context of the position where the autoImport is invoked + * @param config A presentation compiler config, this is used for renames + */ + def generator( + pos: SourcePosition, + text: String, + tree: Tree, + indexedContext: IndexedContext, + config: PresentationCompilerConfig, + ): AutoImportsGenerator = + + import indexedContext.ctx + + val importPos = autoImportPosition(pos, text, tree) + val renameConfig: Map[Symbol, String] = AutoImport.renameConfigMap(config) + + val renames = + (sym: Symbol) => + indexedContext + .rename(sym) + .orElse(renameConfig.get(sym)) + + new AutoImportsGenerator( + pos, + importPos, + indexedContext, + renames, + ) + end generator + + case class AutoImportEdits( + nameEdit: Option[l.TextEdit], + importEdit: Option[l.TextEdit], + ): + + def edits: List[l.TextEdit] = List(nameEdit, importEdit).flatten + + object AutoImportEdits: + + def apply(name: l.TextEdit, imp: l.TextEdit): AutoImportEdits = + AutoImportEdits(Some(name), Some(imp)) + def importOnly(edit: l.TextEdit): AutoImportEdits = + AutoImportEdits(None, Some(edit)) + def nameOnly(edit: l.TextEdit): AutoImportEdits = + AutoImportEdits(Some(edit), None) + + /** + * AutoImportsGenerator generates TextEdits of auto-imports + * for the given symbols. + * + * @param pos A source position where the autoImport is invoked + * @param importPosition A position to insert new imports + * @param indexedContext A context of the position where the autoImport is invoked + * @param renames A function that returns the name of the given symbol which is renamed on import statement. + */ + class AutoImportsGenerator( + val pos: SourcePosition, + importPosition: AutoImportPosition, + indexedContext: IndexedContext, + renames: Symbol => Option[String], + ): + + import indexedContext.ctx + + def forSymbol(symbol: Symbol): Option[List[l.TextEdit]] = + editsForSymbol(symbol).map(_.edits) + + /** + * Construct auto imports for the given ShortName, + * if the shortName has different name with it's symbol name, + * generate renamed import. For example, + * `ShortName("ju", )` => `import java.{util => ju}`. + */ + def forShortName(shortName: ShortName): Option[List[l.TextEdit]] = + if shortName.isRename then + renderImports( + List(ImportSel.Rename(shortName.symbol, shortName.name.show)) + ).map(List(_)) + else forSymbol(shortName.symbol) + + /** + * @param symbol A missing symbol to auto-import + */ + def editsForSymbol(symbol: Symbol): Option[AutoImportEdits] = + val symbolImport = inferSymbolImport(symbol) + val nameEdit = symbolImport.ident match + case SymbolIdent.Direct(_) => None + case other => + Some(new l.TextEdit(pos.toLsp, other.value)) + + val importEdit = + symbolImport.importSel.flatMap(sel => renderImports(List(sel))) + if nameEdit.isDefined || importEdit.isDefined then + Some(AutoImportEdits(nameEdit, importEdit)) + else None + end editsForSymbol + + def inferSymbolImport(symbol: Symbol): SymbolImport = + indexedContext.lookupSym(symbol) match + case IndexedContext.Result.Missing => + // in java enum and enum case both have same flags + val enumOwner = symbol.owner.companion + def isJavaEnumCase: Boolean = + symbol.isAllOf(EnumVal) && enumOwner.is(Enum) + + val (name, sel) = + // For enums import owner instead of all members + if symbol.isAllOf(EnumCase) || isJavaEnumCase + then + val ownerImport = inferSymbolImport(enumOwner) + ( + SymbolIdent.Select( + ownerImport.ident, + symbol.nameBacktickedImport, + ), + ownerImport.importSel, + ) + else + ( + SymbolIdent.direct(symbol.nameBackticked), + Some(ImportSel.Direct(symbol)), + ) + end val + + SymbolImport( + symbol, + name, + sel, + ) + case IndexedContext.Result.Conflict => + val owner = symbol.owner + renames(owner) match + case Some(rename) => + val importSel = + if rename != owner.showName then + Some(ImportSel.Rename(owner, rename)).filter(_ => + !indexedContext.hasRename(owner, rename) + ) + else + Some(ImportSel.Direct(owner)).filter(_ => + !indexedContext.lookupSym(owner).exists + ) + + SymbolImport( + symbol, + SymbolIdent.Select( + SymbolIdent.direct(rename), + symbol.nameBacktickedImport, + ), + importSel, + ) + case None => + SymbolImport( + symbol, + SymbolIdent.direct(symbol.fullNameBackticked), + None, + ) + end match + case IndexedContext.Result.InScope => + val direct = renames(symbol).getOrElse(symbol.nameBackticked) + SymbolImport(symbol, SymbolIdent.direct(direct), None) + end match + end inferSymbolImport + + def renderImports( + imports: List[ImportSel] + )(using Context): Option[l.TextEdit] = + if imports.nonEmpty then + val indent0 = " " * importPosition.indent + val editPos = pos.withSpan(Spans.Span(importPosition.offset)).toLsp + + // for worksheets, we need to remove 2 whitespaces, because it ends up being wrapped in an object + // see WorksheetProvider.worksheetScala3AdjustmentsForPC + val indent = + if pos.source.path.isWorksheet && + editPos.getStart().getCharacter() == 0 + then indent0.drop(2) + else indent0 + val topPadding = + if importPosition.padTop then "\n" + else "" + + val formatted = imports + .map { + case ImportSel.Direct(sym) => importName(sym) + case ImportSel.Rename(sym, rename) => + s"${importName(sym.owner)}.{${sym.nameBacktickedImport} => $rename}" + } + .map(sel => s"${indent}import $sel") + .mkString(topPadding, "\n", "\n") + + Some(new l.TextEdit(editPos, formatted)) + else None + end renderImports + + private def importName(sym: Symbol): String = + if indexedContext.importContext.toplevelClashes(sym) then + s"_root_.${sym.fullNameBacktickedImport}" + else sym.fullNameBacktickedImport + end AutoImportsGenerator + + private def autoImportPosition( + pos: SourcePosition, + text: String, + tree: Tree, + )(using Context): AutoImportPosition = + + @tailrec + def lastPackageDef( + prev: Option[PackageDef], + tree: Tree, + ): Option[PackageDef] = + tree match + case curr @ PackageDef(_, (next: PackageDef) :: Nil) + if !curr.symbol.isPackageObject => + lastPackageDef(Some(curr), next) + case pkg: PackageDef if !pkg.symbol.isPackageObject => Some(pkg) + case _ => prev + + def firstObjectBody(tree: Tree)(using Context): Option[Template] = + tree match + case PackageDef(_, stats) => + stats.flatMap { + case s: PackageDef => firstObjectBody(s) + case TypeDef(_, t @ Template(defDef, _, _, _)) + if defDef.symbol.showName == "" => + Some(t) + case _ => None + }.headOption + case _ => None + + def forScalaSource: Option[AutoImportPosition] = + lastPackageDef(None, tree).map { pkg => + val lastImportStatement = + pkg.stats.takeWhile(_.isInstanceOf[Import]).lastOption + val (lineNumber, padTop) = lastImportStatement match + case Some(stm) => (stm.endPos.line + 1, false) + case None if pkg.pid.symbol.isEmptyPackage => + val offset = + ScriptFirstImportPosition.skipUsingDirectivesOffset(text) + (pos.source.offsetToLine(offset), false) + case None => + val pos = pkg.pid.endPos + val line = + // pos point at the last NL + if pos.endColumn == 0 then math.max(0, pos.line - 1) + else pos.line + 1 + (line, true) + val offset = pos.source.lineToOffset(lineNumber) + new AutoImportPosition(offset, text, padTop) + } + + def forScript(isAmmonite: Boolean): Option[AutoImportPosition] = + firstObjectBody(tree).map { tmpl => + val lastImportStatement = + tmpl.body.takeWhile(_.isInstanceOf[Import]).lastOption + val offset = lastImportStatement match + case Some(stm) => + val offset = pos.source.lineToOffset(stm.endPos.line + 1) + offset + case None => + val scriptOffset = + if isAmmonite then + ScriptFirstImportPosition.ammoniteScStartOffset(text) + else ScriptFirstImportPosition.scalaCliScStartOffset(text) + + scriptOffset.getOrElse( + pos.source.lineToOffset(tmpl.self.srcPos.line) + ) + new AutoImportPosition(offset, text, false) + } + end forScript + + val path = pos.source.path + + def fileStart = + AutoImportPosition( + ScriptFirstImportPosition.skipUsingDirectivesOffset(text), + 0, + padTop = false, + ) + + val scriptPos = + if path.isAmmoniteGeneratedFile then forScript(isAmmonite = true) + else if path.isScalaCLIGeneratedFile then forScript(isAmmonite = false) + else None + + scriptPos + .orElse(forScalaSource) + .getOrElse(fileStart) + end autoImportPosition + +end AutoImports + +trait AutoImportsBackticks: + // Avoids backticketing import parts that match soft keywords + extension (sym: Symbol)(using Context) + def fullNameBacktickedImport: String = + sym.fullNameBackticked(KeywordWrapper.Scala3SoftKeywords) + def nameBacktickedImport: String = + sym.nameBackticked(KeywordWrapper.Scala3SoftKeywords) diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/AutoImportsProvider.scala b/presentation-compiler/src/main/scala/meta/internal/pc/AutoImportsProvider.scala new file mode 100644 index 000000000000..f7f59e8ca657 --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/AutoImportsProvider.scala @@ -0,0 +1,103 @@ +package scala.meta.internal.pc + +import java.nio.file.Paths + +import scala.collection.mutable +import scala.jdk.CollectionConverters.* + +import scala.meta.internal.metals.ReportContext +import scala.meta.internal.mtags.MtagsEnrichments.* +import scala.meta.internal.pc.AutoImports.* +import scala.meta.internal.pc.completions.CompletionPos +import scala.meta.pc.* + +import dotty.tools.dotc.ast.tpd.* +import dotty.tools.dotc.core.Symbols.* +import dotty.tools.dotc.interactive.Interactive +import dotty.tools.dotc.interactive.InteractiveDriver +import dotty.tools.dotc.util.SourceFile +import org.eclipse.{lsp4j as l} + +final class AutoImportsProvider( + search: SymbolSearch, + driver: InteractiveDriver, + name: String, + params: OffsetParams, + config: PresentationCompilerConfig, + buildTargetIdentifier: String, +)(using ReportContext): + + def autoImports(isExtension: Boolean): List[AutoImportsResult] = + val uri = params.uri + val filePath = Paths.get(uri) + driver.run( + uri, + SourceFile.virtual(filePath.toString, params.text), + ) + val unit = driver.currentCtx.run.units.head + val tree = unit.tpdTree + + val pos = driver.sourcePosition(params) + + val newctx = driver.currentCtx.fresh.setCompilationUnit(unit) + val path = + Interactive.pathTo(newctx.compilationUnit.tpdTree, pos.span)(using newctx) + + val indexedContext = IndexedContext( + MetalsInteractive.contextOfPath(path)(using newctx) + ) + import indexedContext.ctx + + val isSeen = mutable.Set.empty[String] + val symbols = List.newBuilder[Symbol] + def visit(sym: Symbol): Boolean = + val name = sym.denot.fullName.show + if !isSeen(name) then + isSeen += name + symbols += sym + true + else false + def isExactMatch(sym: Symbol, query: String): Boolean = + sym.name.show == query + + val visitor = new CompilerSearchVisitor(visit) + if isExtension then + search.searchMethods(name, buildTargetIdentifier, visitor) + else search.search(name, buildTargetIdentifier, visitor) + val results = symbols.result.filter(isExactMatch(_, name)) + + if results.nonEmpty then + val correctedPos = CompletionPos.infer(pos, params, path).sourcePos + val mkEdit = + path match + // if we are in import section just specify full name + case (_: Ident) :: (_: Import) :: _ => + (sym: Symbol) => + val nameEdit = + new l.TextEdit(correctedPos.toLsp, sym.fullNameBackticked) + Some(List(nameEdit)) + case _ => + val generator = + AutoImports.generator( + correctedPos, + params.text, + tree, + indexedContext.importContext, + config, + ) + (sym: Symbol) => generator.forSymbol(sym) + end match + end mkEdit + + for + sym <- results + edits <- mkEdit(sym) + yield AutoImportsResultImpl( + sym.owner.showFullName, + edits.asJava, + ) + else List.empty + end if + end autoImports + +end AutoImportsProvider diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/CompilerInterfaces.scala b/presentation-compiler/src/main/scala/meta/internal/pc/CompilerInterfaces.scala new file mode 100644 index 000000000000..4e6a88479044 --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/CompilerInterfaces.scala @@ -0,0 +1,16 @@ +package scala.meta.internal.pc + +import java.net.URI +import java.nio.file.Paths + +import dotty.tools.dotc.util.SourceFile + +// note(@tgodzik) the plan is to be able to move the methods here back to Dotty compiler +// so that we can provide easier compatibility with multiple Scala 3 versions +object CompilerInterfaces: + + def toSource(uri: URI, sourceCode: String): SourceFile = + val path = Paths.get(uri).toString + SourceFile.virtual(path, sourceCode) + +end CompilerInterfaces diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/CompilerSearchVisitor.scala b/presentation-compiler/src/main/scala/meta/internal/pc/CompilerSearchVisitor.scala new file mode 100644 index 000000000000..8092ba841df5 --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/CompilerSearchVisitor.scala @@ -0,0 +1,92 @@ +package scala.meta.internal.pc + +import java.util.logging.Level +import java.util.logging.Logger + +import scala.util.control.NonFatal + +import scala.meta.internal.metals.Report +import scala.meta.internal.metals.ReportContext +import scala.meta.pc.* + +import dotty.tools.dotc.core.Contexts.* +import dotty.tools.dotc.core.Names.* +import dotty.tools.dotc.core.Symbols.* + +class CompilerSearchVisitor( + visitSymbol: Symbol => Boolean +)(using ctx: Context, reports: ReportContext) + extends SymbolSearchVisitor: + + val logger: Logger = Logger.getLogger(classOf[CompilerSearchVisitor].getName) + + private def isAccessible(sym: Symbol): Boolean = try + sym != NoSymbol && sym.isPublic + catch + case NonFatal(e) => + reports.incognito.create( + Report( + "is_public", + s"""Symbol: $sym""".stripMargin, + e, + ) + ) + logger.log(Level.SEVERE, e.getMessage(), e) + false + + private def toSymbols( + pkg: String, + parts: List[String], + ): List[Symbol] = + def loop(owners: List[Symbol], parts: List[String]): List[Symbol] = + parts match + case head :: tl => + val next = owners.flatMap { sym => + val term = sym.info.member(termName(head)) + val tpe = sym.info.member(typeName(head)) + + List(term, tpe) + .filter(denot => denot.exists) + .map(_.symbol) + .filter(isAccessible) + } + loop(next, tl) + case Nil => owners + + val pkgSym = requiredPackage(pkg) + loop(List(pkgSym), parts) + end toSymbols + + def visitClassfile(pkgPath: String, filename: String): Int = + val pkg = normalizePackage(pkgPath) + + val innerPath = filename + .stripSuffix(".class") + .stripSuffix("$") + .split("\\$") + + val added = toSymbols(pkg, innerPath.toList).filter(visitSymbol) + added.size + + def visitWorkspaceSymbol( + path: java.nio.file.Path, + symbol: String, + kind: org.eclipse.lsp4j.SymbolKind, + range: org.eclipse.lsp4j.Range, + ): Int = + val gsym = SemanticdbSymbols.inverseSemanticdbSymbol(symbol).headOption + gsym + .filter(isAccessible) + .map(visitSymbol) + .map(_ => 1) + .getOrElse(0) + + def shouldVisitPackage(pkg: String): Boolean = + isAccessible(requiredPackage(normalizePackage(pkg))) + + override def isCancelled: Boolean = false + + private def normalizePackage(pkg: String): String = + pkg.replace("/", ".").stripSuffix(".") + +end CompilerSearchVisitor diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/CompletionItemResolver.scala b/presentation-compiler/src/main/scala/meta/internal/pc/CompletionItemResolver.scala new file mode 100644 index 000000000000..40152b8c6e69 --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/CompletionItemResolver.scala @@ -0,0 +1,88 @@ +package scala.meta.internal.pc + +import scala.meta.internal.mtags.MtagsEnrichments.* +import scala.meta.pc.PresentationCompilerConfig +import scala.meta.pc.SymbolDocumentation +import scala.meta.pc.SymbolSearch + +import dotty.tools.dotc.core.Contexts.Context +import dotty.tools.dotc.core.Flags.* +import dotty.tools.dotc.core.Symbols.* +import dotty.tools.dotc.core.Types.TermRef +import org.eclipse.lsp4j.CompletionItem + +object CompletionItemResolver extends ItemResolver: + + override def adjustIndexOfJavaParams = 0 + + def resolve( + item: CompletionItem, + msym: String, + search: SymbolSearch, + metalsConfig: PresentationCompilerConfig, + )(using Context): CompletionItem = + SemanticdbSymbols.inverseSemanticdbSymbol(msym) match + case gsym :: _ if gsym != NoSymbol => + search + .symbolDocumentation(gsym) + .orElse( + search.symbolDocumentation(gsym.companion) + ) match + case Some(info) if item.getDetail != null => + enrichDocs( + item, + info, + metalsConfig, + fullDocstring(gsym, search), + gsym.is(JavaDefined), + ) + case _ => + item + end match + + case _ => item + end match + end resolve + + private def fullDocstring(gsym: Symbol, search: SymbolSearch)(using + Context + ): String = + def docs(gsym: Symbol): String = + search.symbolDocumentation(gsym).fold("")(_.docstring()) + val gsymDoc = docs(gsym) + def keyword(gsym: Symbol): String = + if gsym.isClass then "class" + else if gsym.is(Trait) then "trait" + else if gsym.isAllOf(JavaInterface) then "interface" + else if gsym.is(Module) then "object" + else "" + val companion = gsym.companion + if companion == NoSymbol || gsym.is(JavaDefined) then + if gsymDoc.isEmpty then + if gsym.isAliasType then + fullDocstring(gsym.info.metalsDealias.typeSymbol, search) + else if gsym.is(Method) then + gsym.info.finalResultType match + case tr @ TermRef(_, sym) => + fullDocstring(tr.symbol, search) + case _ => + "" + else "" + else gsymDoc + else + val companionDoc = docs(companion) + if companionDoc.isEmpty then gsymDoc + else if gsymDoc.isEmpty then companionDoc + else + List( + s"""|### ${keyword(companion)} ${companion.name} + |$companionDoc + |""".stripMargin, + s"""|### ${keyword(gsym)} ${gsym.name} + |${gsymDoc} + |""".stripMargin, + ).sorted.mkString("\n") + end if + end fullDocstring + +end CompletionItemResolver diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/ConvertToNamedArgumentsProvider.scala b/presentation-compiler/src/main/scala/meta/internal/pc/ConvertToNamedArgumentsProvider.scala new file mode 100644 index 000000000000..96ac7bba6aac --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/ConvertToNamedArgumentsProvider.scala @@ -0,0 +1,86 @@ +package scala.meta.internal.pc + +import java.nio.file.Paths + +import scala.meta.internal.mtags.MtagsEnrichments.* +import scala.meta.pc.OffsetParams + +import dotty.tools.dotc.ast.tpd +import dotty.tools.dotc.core.Contexts.Context +import dotty.tools.dotc.core.Flags +import dotty.tools.dotc.core.Types.MethodType +import dotty.tools.dotc.interactive.Interactive +import dotty.tools.dotc.interactive.InteractiveDriver +import dotty.tools.dotc.util.SourceFile +import org.eclipse.{lsp4j as l} + +final class ConvertToNamedArgumentsProvider( + driver: InteractiveDriver, + params: OffsetParams, + argIndices: Set[Int], +): + + def convertToNamedArguments: Either[String, List[l.TextEdit]] = + val uri = params.uri + val filePath = Paths.get(uri) + driver.run( + uri, + SourceFile.virtual(filePath.toString, params.text), + ) + val unit = driver.currentCtx.run.units.head + val newctx = driver.currentCtx.fresh.setCompilationUnit(unit) + val pos = driver.sourcePosition(params) + val trees = driver.openedTrees(uri) + val tree = Interactive.pathTo(trees, pos)(using newctx).headOption + + def paramss(fun: tpd.Tree)(using Context): List[String] = + fun.tpe match + case m: MethodType => m.paramNamess.flatten.map(_.toString) + case _ => + fun.symbol.rawParamss.flatten + .filter(!_.isTypeParam) + .map(_.nameBackticked) + + object FromNewApply: + def unapply(tree: tpd.Tree): Option[(tpd.Tree, List[tpd.Tree])] = + tree match + case fun @ tpd.Select(tpd.New(_), _) => + Some((fun, Nil)) + case tpd.TypeApply(FromNewApply(fun, argss), _) => + Some(fun, argss) + case tpd.Apply(FromNewApply(fun, argss), args) => + Some(fun, argss ++ args) + case _ => None + + def edits(tree: Option[tpd.Tree])(using + Context + ): Either[String, List[l.TextEdit]] = + def makeTextEdits(fun: tpd.Tree, args: List[tpd.Tree]) = + if fun.symbol.is(Flags.JavaDefined) then + Left(CodeActionErrorMessages.ConvertToNamedArguments.IsJavaObject) + else + Right( + args.zipWithIndex + .zip(paramss(fun)) + .collect { + case ((arg, index), param) if argIndices.contains(index) => + val position = arg.sourcePos.toLsp + position.setEnd(position.getStart()) + new l.TextEdit(position, s"$param = ") + } + ) + + tree match + case Some(t) => + t match + case FromNewApply(fun, args) => + makeTextEdits(fun, args) + case tpd.Apply(fun, args) => + makeTextEdits(fun, args) + case _ => Right(Nil) + case _ => Right(Nil) + end match + end edits + edits(tree)(using newctx) + end convertToNamedArguments +end ConvertToNamedArgumentsProvider diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/ExtractMethodProvider.scala b/presentation-compiler/src/main/scala/meta/internal/pc/ExtractMethodProvider.scala new file mode 100644 index 000000000000..b0051c5187ed --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/ExtractMethodProvider.scala @@ -0,0 +1,171 @@ +package scala.meta.internal.pc + +import java.nio.file.Paths + +import scala.meta as m + +import scala.meta.internal.mtags.MtagsEnrichments.* +import scala.meta.internal.pc.MetalsInteractive.* +import scala.meta.internal.pc.printer.MetalsPrinter +import scala.meta.internal.pc.printer.MetalsPrinter.IncludeDefaultParam +import scala.meta.pc.OffsetParams +import scala.meta.pc.RangeParams +import scala.meta.pc.SymbolSearch + +import dotty.tools.dotc.ast.Trees.* +import dotty.tools.dotc.ast.tpd +import dotty.tools.dotc.ast.tpd.DeepFolder +import dotty.tools.dotc.core.Contexts.* +import dotty.tools.dotc.core.Symbols.Symbol +import dotty.tools.dotc.core.Types.MethodType +import dotty.tools.dotc.core.Types.PolyType +import dotty.tools.dotc.core.Types.Type +import dotty.tools.dotc.interactive.Interactive +import dotty.tools.dotc.interactive.InteractiveDriver +import dotty.tools.dotc.util.SourceFile +import dotty.tools.dotc.util.SourcePosition +import org.eclipse.lsp4j.TextEdit +import org.eclipse.{lsp4j as l} + +final class ExtractMethodProvider( + range: RangeParams, + extractionPos: OffsetParams, + driver: InteractiveDriver, + search: SymbolSearch, + noIndent: Boolean, +) extends ExtractMethodUtils: + + def extractMethod(): List[TextEdit] = + val text = range.text() + val uri = range.uri + val filePath = Paths.get(uri) + val source = SourceFile.virtual(filePath.toString, text) + driver.run(uri, source) + val unit = driver.currentCtx.run.units.head + val pos = driver.sourcePosition(range).startPos + val path = + Interactive.pathTo(driver.openedTrees(uri), pos)(using driver.currentCtx) + given locatedCtx: Context = + val newctx = driver.currentCtx.fresh.setCompilationUnit(unit) + MetalsInteractive.contextOfPath(path)(using newctx) + val indexedCtx = IndexedContext(locatedCtx) + val printer = + MetalsPrinter.standard(indexedCtx, search, IncludeDefaultParam.Never) + def prettyPrint(tpe: Type) = + def prettyPrintReturnType(tpe: Type): String = + tpe match + case mt: (MethodType | PolyType) => + prettyPrintReturnType(tpe.resultType) + case tpe => printer.tpe(tpe) + def printParams(params: List[Type]) = + params match + case p :: Nil => prettyPrintReturnType(p) + case _ => s"(${params.map(prettyPrintReturnType).mkString(", ")})" + + if tpe.paramInfoss.isEmpty + then prettyPrintReturnType(tpe) + else + val params = tpe.paramInfoss.map(printParams).mkString(" => ") + s"$params => ${prettyPrintReturnType(tpe)}" + end prettyPrint + + def extractFromBlock(t: tpd.Tree): List[tpd.Tree] = + t match + case Block(stats, expr) => + (stats :+ expr).filter(stat => range.encloses(stat.sourcePos)) + case temp: Template[?] => + temp.body.filter(stat => range.encloses(stat.sourcePos)) + case other => List(other) + + def localRefs( + ts: List[tpd.Tree], + defnPos: SourcePosition, + extractedPos: SourcePosition, + ): (List[Symbol], List[Symbol]) = + def nonAvailable(sym: Symbol): Boolean = + val symPos = sym.sourcePos + symPos.exists && defnPos.contains(symPos) && !extractedPos + .contains(symPos) + def collectNames(symbols: Set[Symbol], tree: tpd.Tree): Set[Symbol] = + tree match + case id @ Ident(_) => + val sym = id.symbol + if nonAvailable(sym) && (sym.isTerm || sym.isTypeParam) + then symbols + sym + else symbols + case _ => symbols + + val traverser = new DeepFolder[Set[Symbol]](collectNames) + val allSymbols = ts + .foldLeft(Set.empty[Symbol])(traverser(_, _)) + + val methodParams = allSymbols.toList.filter(_.isTerm) + val methodParamTypes = methodParams + .flatMap(p => p :: p.paramSymss.flatten) + .map(_.info.typeSymbol) + .filter(tp => nonAvailable(tp) && tp.isTypeParam) + .distinct + // Type parameter can be a type of one of the parameters or a type parameter in extracted code + val typeParams = + allSymbols.filter(_.isTypeParam) ++ methodParamTypes + + ( + methodParams.sortBy(_.decodedName), + typeParams.toList.sortBy(_.decodedName), + ) + end localRefs + val edits = + for + enclosing <- path.find(src => src.sourcePos.encloses(range)) + extracted = extractFromBlock(enclosing) + head <- extracted.headOption + expr <- extracted.lastOption + shortenedPath = + path.takeWhile(src => extractionPos.offset() <= src.sourcePos.start) + stat = shortenedPath.lastOption.getOrElse(head) + yield + val defnPos = stat.sourcePos + val extractedPos = head.sourcePos.withEnd(expr.sourcePos.end) + val exprType = prettyPrint(expr.tpe.widen) + val name = + genName(indexedCtx.scopeSymbols.map(_.decodedName).toSet, "newMethod") + val (methodParams, typeParams) = + localRefs(extracted, stat.sourcePos, extractedPos) + val methodParamsText = methodParams + .map(sym => s"${sym.decodedName}: ${prettyPrint(sym.info)}") + .mkString(", ") + val typeParamsText = typeParams + .map(_.decodedName) match + case Nil => "" + case params => params.mkString("[", ", ", "]") + val exprParamsText = methodParams.map(_.decodedName).mkString(", ") + val newIndent = stat.startPos.startColumnPadding + val oldIndentLen = head.startPos.startColumnPadding.length() + val toExtract = + textToExtract( + range.text(), + head.startPos.start, + expr.endPos.end, + newIndent, + oldIndentLen, + ) + val (obracket, cbracket) = + if noIndent && extracted.length > 1 then (" {", s"$newIndent}") + else ("", "") + val defText = + s"def $name$typeParamsText($methodParamsText): $exprType =$obracket\n${toExtract}\n$cbracket\n$newIndent" + val replacedText = s"$name($exprParamsText)" + List( + new l.TextEdit( + extractedPos.toLsp, + replacedText, + ), + new l.TextEdit( + defnPos.startPos.toLsp, + defText, + ), + ) + + edits.getOrElse(Nil) + end extractMethod +end ExtractMethodProvider diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/HoverProvider.scala b/presentation-compiler/src/main/scala/meta/internal/pc/HoverProvider.scala new file mode 100644 index 000000000000..17a8b59d1da1 --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/HoverProvider.scala @@ -0,0 +1,217 @@ +package scala.meta.internal.pc + +import java.{util as ju} + +import scala.meta.internal.metals.Report +import scala.meta.internal.metals.ReportContext +import scala.meta.internal.mtags.MtagsEnrichments.* +import scala.meta.internal.pc.printer.MetalsPrinter +import scala.meta.pc.HoverSignature +import scala.meta.pc.OffsetParams +import scala.meta.pc.SymbolSearch + +import dotty.tools.dotc.ast.tpd.* +import dotty.tools.dotc.core.Constants.* +import dotty.tools.dotc.core.Contexts.* +import dotty.tools.dotc.core.Flags.* +import dotty.tools.dotc.core.Names.* +import dotty.tools.dotc.core.StdNames.* +import dotty.tools.dotc.core.Symbols.* +import dotty.tools.dotc.core.Types.* +import dotty.tools.dotc.interactive.Interactive +import dotty.tools.dotc.interactive.InteractiveDriver +import dotty.tools.dotc.util.SourcePosition + +object HoverProvider: + + def hover( + params: OffsetParams, + driver: InteractiveDriver, + search: SymbolSearch, + )(implicit reportContext: ReportContext): ju.Optional[HoverSignature] = + val uri = params.uri + val sourceFile = CompilerInterfaces.toSource(params.uri, params.text) + driver.run(uri, sourceFile) + + given ctx: Context = driver.currentCtx + val pos = driver.sourcePosition(params) + val trees = driver.openedTrees(uri) + val indexedContext = IndexedContext(ctx) + + def typeFromPath(path: List[Tree]) = + if path.isEmpty then NoType else path.head.tpe + + val path = Interactive.pathTo(trees, pos) + val tp = typeFromPath(path) + val tpw = tp.widenTermRefExpr + // For expression we need to find all enclosing applies to get the exact generic type + val enclosing = path.expandRangeToEnclosingApply(pos) + + if tp.isError || tpw == NoType || tpw.isError || path.isEmpty + then + def report = + val posId = + if path.isEmpty || path.head.sourcePos == null || !path.head.sourcePos.exists + then pos.start + else path.head.sourcePos.start + Report( + "empty-hover-scala3", + s"""|$uri + |pos: ${pos.toLsp} + | + |tp: $tp + |has error: ${tp.isError} + | + |tpw: $tpw + |has error: ${tpw.isError} + | + |path: + |- ${path.map(_.toString()).mkString("\n- ")} + |trees: + |- ${trees.map(_.toString()).mkString("\n- ")} + |""".stripMargin, + s"$uri::$posId", + ) + end report + reportContext.unsanitized.create(report, ifVerbose = true) + ju.Optional.empty() + else + val skipCheckOnName = + !pos.isPoint // don't check isHoveringOnName for RangeHover + + val printerContext = + driver.compilationUnits.get(uri) match + case Some(unit) => + val newctx = + ctx.fresh.setCompilationUnit(unit) + MetalsInteractive.contextOfPath(enclosing)(using newctx) + case None => ctx + val printer = MetalsPrinter.standard( + IndexedContext(printerContext), + search, + includeDefaultParam = MetalsPrinter.IncludeDefaultParam.Include, + ) + MetalsInteractive.enclosingSymbolsWithExpressionType( + enclosing, + pos, + indexedContext, + skipCheckOnName, + ) match + case Nil => + fallbackToDynamics(path, printer) + case (symbol, tpe) :: _ + if symbol.name == nme.selectDynamic || symbol.name == nme.applyDynamic => + fallbackToDynamics(path, printer) + case symbolTpes @ ((symbol, tpe) :: _) => + val exprTpw = tpe.widenTermRefExpr.metalsDealias + val hoverString = + tpw match + // https://github.com/lampepfl/dotty/issues/8891 + case tpw: ImportType => + printer.hoverSymbol(symbol, symbol.paramRef) + case _ => + val (tpe, sym) = + if symbol.isType then (symbol.typeRef, symbol) + else enclosing.head.seenFrom(symbol) + + val finalTpe = + if tpe != NoType then tpe + else tpw + + printer.hoverSymbol(sym, finalTpe) + end match + end hoverString + + val docString = symbolTpes + .flatMap(symTpe => search.symbolDocumentation(symTpe._1)) + .map(_.docstring) + .mkString("\n") + printer.expressionType(exprTpw) match + case Some(expressionType) => + val forceExpressionType = + !pos.span.isZeroExtent || ( + !hoverString.endsWith(expressionType) && + !symbol.isType && + !symbol.is(Module) && + !symbol.flags.isAllOf(EnumCase) + ) + ju.Optional.of( + new ScalaHover( + expressionType = Some(expressionType), + symbolSignature = Some(hoverString), + docstring = Some(docString), + forceExpressionType = forceExpressionType, + ) + ) + case _ => + ju.Optional.empty + end match + end match + end if + end hover + + extension (pos: SourcePosition) + private def isPoint: Boolean = pos.start == pos.end + + private def fallbackToDynamics( + path: List[Tree], + printer: MetalsPrinter, + )(using Context): ju.Optional[HoverSignature] = path match + case SelectDynamicExtractor(sel, n, name) => + def findRefinement(tp: Type): ju.Optional[HoverSignature] = + tp match + case RefinedType(info, refName, tpe) if name == refName.toString() => + val tpeString = + if n == nme.selectDynamic then s": ${printer.tpe(tpe.resultType)}" + else printer.tpe(tpe) + ju.Optional.of( + new ScalaHover( + expressionType = Some(tpeString), + symbolSignature = Some(s"def $name$tpeString"), + ) + ) + case RefinedType(info, _, _) => + findRefinement(info) + case _ => ju.Optional.empty() + + findRefinement(sel.tpe.termSymbol.info.dealias) + case _ => + ju.Optional.empty() + +end HoverProvider + +object SelectDynamicExtractor: + def unapply(path: List[Tree])(using Context) = + path match + // the same tests as below, since 3.3.1-RC1 path starts with Select + case Select(_, _) :: Apply( + Select(Apply(reflSel, List(sel)), n), + List(Literal(Constant(name: String))), + ) :: _ + if (n == nme.selectDynamic || n == nme.applyDynamic) && + nme.reflectiveSelectable == reflSel.symbol.name => + Some(sel, n, name) + // tests `structural-types` and `structural-types1` in HoverScala3TypeSuite + case Apply( + Select(Apply(reflSel, List(sel)), n), + List(Literal(Constant(name: String))), + ) :: _ + if (n == nme.selectDynamic || n == nme.applyDynamic) && + nme.reflectiveSelectable == reflSel.symbol.name => + Some(sel, n, name) + // the same tests as below, since 3.3.1-RC1 path starts with Select + case Select(_, _) :: Apply( + Select(sel, n), + List(Literal(Constant(name: String))), + ) :: _ if n == nme.selectDynamic || n == nme.applyDynamic => + Some(sel, n, name) + // tests `selectable`, `selectable2` and `selectable-full` in HoverScala3TypeSuite + case Apply( + Select(sel, n), + List(Literal(Constant(name: String))), + ) :: _ if n == nme.selectDynamic || n == nme.applyDynamic => + Some(sel, n, name) + case _ => None + end match + end unapply +end SelectDynamicExtractor diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/IndexedContext.scala b/presentation-compiler/src/main/scala/meta/internal/pc/IndexedContext.scala new file mode 100644 index 000000000000..0d47fb425916 --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/IndexedContext.scala @@ -0,0 +1,220 @@ +package scala.meta.internal.pc + +import scala.annotation.tailrec +import scala.util.control.NonFatal + +import scala.meta.internal.mtags.MtagsEnrichments.* +import scala.meta.internal.pc.IndexedContext.Result + +import dotty.tools.dotc.core.Contexts.* +import dotty.tools.dotc.core.Flags.* +import dotty.tools.dotc.core.Names.* +import dotty.tools.dotc.core.Symbols.* +import dotty.tools.dotc.core.Types.* +import dotty.tools.dotc.typer.ImportInfo + +sealed trait IndexedContext: + given ctx: Context + def scopeSymbols: List[Symbol] + def names: IndexedContext.Names + def rename(sym: Symbol): Option[String] + def outer: IndexedContext + + def findSymbol(name: String): Option[List[Symbol]] + + final def findSymbol(name: Name): Option[List[Symbol]] = + findSymbol(name.decoded) + + final def lookupSym(sym: Symbol): Result = + findSymbol(sym.decodedName) match + case Some(symbols) if symbols.exists(_ == sym) => + Result.InScope + case Some(symbols) + if symbols + .exists(s => isTypeAliasOf(s, sym) || isTermAliasOf(s, sym)) => + Result.InScope + // when all the conflicting symbols came from an old version of the file + case Some(symbols) if symbols.nonEmpty && symbols.forall(_.isStale) => + Result.Missing + case Some(_) => Result.Conflict + case None => Result.Missing + end lookupSym + + final def hasRename(sym: Symbol, as: String): Boolean = + rename(sym) match + case Some(v) => v == as + case None => false + + // detects import scope aliases like + // object Predef: + // val Nil = scala.collection.immutable.Nil + private def isTermAliasOf(termAlias: Symbol, sym: Symbol): Boolean = + termAlias.isTerm && ( + sym.info match + case clz: ClassInfo => clz.appliedRef =:= termAlias.info.resultType + case _ => false + ) + + private def isTypeAliasOf(alias: Symbol, sym: Symbol): Boolean = + alias.isAliasType && alias.info.metalsDealias.typeSymbol == sym + + final def isEmpty: Boolean = this match + case IndexedContext.Empty => true + case _ => false + + final def importContext: IndexedContext = + this match + case IndexedContext.Empty => this + case _ if ctx.owner.is(Package) => this + case _ => outer.importContext + + @tailrec + final def toplevelClashes(sym: Symbol): Boolean = + if sym == NoSymbol || sym.owner == NoSymbol || sym.owner.isRoot then + lookupSym(sym) match + case IndexedContext.Result.Conflict => true + case _ => false + else toplevelClashes(sym.owner) + +end IndexedContext + +object IndexedContext: + + def apply(ctx: Context): IndexedContext = + ctx match + case null => Empty + case NoContext => Empty + case _ => LazyWrapper(using ctx) + + case object Empty extends IndexedContext: + given ctx: Context = NoContext + def findSymbol(name: String): Option[List[Symbol]] = None + def scopeSymbols: List[Symbol] = List.empty + val names: Names = Names(Map.empty, Map.empty) + def rename(sym: Symbol): Option[String] = None + def outer: IndexedContext = this + + class LazyWrapper(using val ctx: Context) extends IndexedContext: + val outer: IndexedContext = IndexedContext(ctx.outer) + val names: Names = extractNames(ctx) + + def findSymbol(name: String): Option[List[Symbol]] = + names.symbols + .get(name) + .map(_.toList) + .orElse(outer.findSymbol(name)) + + def scopeSymbols: List[Symbol] = + val acc = Set.newBuilder[Symbol] + (this :: outers).foreach { ref => + acc ++= ref.names.symbols.values.flatten + } + acc.result.toList + + def rename(sym: Symbol): Option[String] = + names.renames + .get(sym) + .orElse(outer.rename(sym)) + + private def outers: List[IndexedContext] = + val builder = List.newBuilder[IndexedContext] + var curr = outer + while !curr.isEmpty do + builder += curr + curr = curr.outer + builder.result + end LazyWrapper + + enum Result: + case InScope, Conflict, Missing + def exists: Boolean = this match + case InScope | Conflict => true + case Missing => false + + case class Names( + symbols: Map[String, List[Symbol]], + renames: Map[Symbol, String], + ) + + private def extractNames(ctx: Context): Names = + + def accessibleSymbols(site: Type, tpe: Type)(using + Context + ): List[Symbol] = + tpe.decls.toList.filter(sym => + sym.isAccessibleFrom(site, superAccess = false) + ) + + def accesibleMembers(site: Type)(using Context): List[Symbol] = + site.allMembers + .filter(denot => + try denot.symbol.isAccessibleFrom(site, superAccess = false) + catch + case NonFatal(e) => + false + ) + .map(_.symbol) + .toList + + def allAccessibleSymbols( + tpe: Type, + filter: Symbol => Boolean = _ => true, + )(using Context): List[Symbol] = + val initial = accessibleSymbols(tpe, tpe).filter(filter) + val fromPackageObjects = + initial + .filter(_.isPackageObject) + .flatMap(sym => accessibleSymbols(tpe, sym.thisType)) + initial ++ fromPackageObjects + + def fromImport(site: Type, name: Name)(using Context): List[Symbol] = + List(site.member(name.toTypeName), site.member(name.toTermName)) + .flatMap(_.alternatives) + .map(_.symbol) + + def fromImportInfo( + imp: ImportInfo + )(using Context): List[(Symbol, Option[TermName])] = + val excludedNames = imp.excluded.map(_.decoded) + + if imp.isWildcardImport then + allAccessibleSymbols( + imp.site, + sym => !excludedNames.contains(sym.name.decoded), + ).map((_, None)) + else + imp.forwardMapping.toList.flatMap { (name, rename) => + val isRename = name != rename + if !isRename && !excludedNames.contains(name.decoded) then + fromImport(imp.site, name).map((_, None)) + else if isRename then + fromImport(imp.site, name).map((_, Some(rename))) + else Nil + } + end if + end fromImportInfo + + given Context = ctx + val (symbols, renames) = + if ctx.isImportContext then + val (syms, renames) = + fromImportInfo(ctx.importInfo) + .map((sym, rename) => (sym, rename.map(r => sym -> r.decoded))) + .unzip + (syms, renames.flatten.toMap) + else if ctx.owner.isClass then + val site = ctx.owner.thisType + (accesibleMembers(site), Map.empty) + else if ctx.scope != null then (ctx.scope.toList, Map.empty) + else (List.empty, Map.empty) + + val initial = Map.empty[String, List[Symbol]] + val values = + symbols.foldLeft(initial) { (acc, sym) => + val name = sym.decodedName + val syms = acc.getOrElse(name, List.empty) + acc.updated(name, sym :: syms) + } + Names(values, renames) + end extractNames +end IndexedContext diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/InferredTypeProvider.scala b/presentation-compiler/src/main/scala/meta/internal/pc/InferredTypeProvider.scala new file mode 100644 index 000000000000..6beec50875cd --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/InferredTypeProvider.scala @@ -0,0 +1,328 @@ +package scala.meta.internal.pc + +import java.nio.file.Paths + +import scala.annotation.tailrec +import scala.meta as m + +import scala.meta.internal.mtags.MtagsEnrichments.* +import scala.meta.internal.pc.printer.MetalsPrinter +import scala.meta.internal.pc.printer.ShortenedNames +import scala.meta.pc.OffsetParams +import scala.meta.pc.PresentationCompilerConfig +import scala.meta.pc.SymbolSearch + +import dotty.tools.dotc.ast.Trees.* +import dotty.tools.dotc.ast.untpd +import dotty.tools.dotc.core.Contexts.* +import dotty.tools.dotc.core.NameOps.* +import dotty.tools.dotc.core.Names.* +import dotty.tools.dotc.core.Symbols.* +import dotty.tools.dotc.core.Types.* +import dotty.tools.dotc.interactive.Interactive +import dotty.tools.dotc.interactive.InteractiveDriver +import dotty.tools.dotc.util.SourceFile +import dotty.tools.dotc.util.SourcePosition +import dotty.tools.dotc.util.Spans +import dotty.tools.dotc.util.Spans.Span +import org.eclipse.lsp4j.TextEdit +import org.eclipse.lsp4j as l + +/** + * Tries to calculate edits needed to insert the inferred type annotation + * in all the places that it is possible such as: + * - value or variable declaration + * - methods + * - pattern matches + * - for comprehensions + * - lambdas + * + * The provider will not check if the type does not exist, since there is no way to + * get that data from the presentation compiler. The actual check is being done via + * scalameta parser in InsertInferredType code action. + * + * @param params position and actual source + * @param driver Scala 3 interactive compiler driver + * @param config presentation compielr configuration + */ +final class InferredTypeProvider( + params: OffsetParams, + driver: InteractiveDriver, + config: PresentationCompilerConfig, + symbolSearch: SymbolSearch, +): + + case class AdjustTypeOpts( + text: String, + adjustedEndPos: l.Position, + ) + + def inferredTypeEdits( + adjustOpt: Option[AdjustTypeOpts] = None + ): List[TextEdit] = + val retryType = adjustOpt.isEmpty + val uri = params.uri + val filePath = Paths.get(uri) + + val sourceText = adjustOpt.map(_.text).getOrElse(params.text) + val source = + SourceFile.virtual(filePath.toString, sourceText) + driver.run(uri, source) + val unit = driver.currentCtx.run.units.head + val pos = driver.sourcePosition(params) + val path = + Interactive.pathTo(driver.openedTrees(uri), pos)(using driver.currentCtx) + + given locatedCtx: Context = driver.localContext(params) + val indexedCtx = IndexedContext(locatedCtx) + val autoImportsGen = AutoImports.generator( + pos, + params.text, + unit.tpdTree, + indexedCtx, + config, + ) + val shortenedNames = new ShortenedNames(indexedCtx) + + def removeType(nameEnd: Int, tptEnd: Int) = + sourceText.substring(0, nameEnd) + + sourceText.substring(tptEnd + 1, sourceText.length()) + + def imports: List[TextEdit] = + shortenedNames.imports(autoImportsGen) + + def optDealias(tpe: Type): Type = + def isInScope(tpe: Type): Boolean = + tpe match + case tref: TypeRef => + indexedCtx.lookupSym( + tref.currentSymbol + ) == IndexedContext.Result.InScope + case AppliedType(tycon, args) => + isInScope(tycon) && args.forall(isInScope) + case _ => true + if isInScope(tpe) + then tpe + else tpe.metalsDealias + + def printType(tpe: Type): String = + val printer = MetalsPrinter.forInferredType( + shortenedNames, + indexedCtx, + symbolSearch, + includeDefaultParam = MetalsPrinter.IncludeDefaultParam.ResolveLater, + ) + printer.tpe(tpe) + + path.headOption match + /* `val a = 1` or `var b = 2` + * turns into + * `val a: Int = 1` or `var b: Int = 2` + * + *`.map(a => a + a)` + * turns into + * `.map((a: Int) => a + a)` + */ + case Some(vl @ ValDef(sym, tpt, rhs)) => + val isParam = path match + case head :: next :: _ if next.symbol.isAnonymousFunction => true + case head :: (b @ Block(stats, expr)) :: next :: _ + if next.symbol.isAnonymousFunction => + true + case _ => false + def baseEdit(withParens: Boolean): TextEdit = + val keywordOffset = if isParam then 0 else 4 + val endPos = + findNamePos(params.text, vl, keywordOffset).endPos.toLsp + adjustOpt.foreach(adjust => endPos.setEnd(adjust.adjustedEndPos)) + new TextEdit( + endPos, + ": " + printType(optDealias(tpt.tpe)) + { + if withParens then ")" else "" + }, + ) + + def checkForParensAndEdit( + applyEndingPos: Int, + toCheckFor: Char, + blockStartPos: SourcePosition, + ) = + val text = params.text + val isParensFunction: Boolean = text(applyEndingPos) == toCheckFor + + val alreadyHasParens = + text(blockStartPos.start) == '(' + + if isParensFunction && !alreadyHasParens then + new TextEdit(blockStartPos.toLsp, "(") :: baseEdit(withParens = + true + ) :: Nil + else baseEdit(withParens = false) :: Nil + end checkForParensAndEdit + + def typeNameEdit: List[TextEdit] = + path match + // lambda `map(a => ???)` apply + case _ :: _ :: (block: untpd.Block) :: (appl: untpd.Apply) :: _ + if isParam => + checkForParensAndEdit(appl.fun.endPos.end, '(', block.startPos) + + // labda `map{a => ???}` apply + // Ensures that this becomes {(a: Int) => ???} since parentheses + // are required around the parameter of a lambda in Scala 3 + case valDef :: defDef :: (block: untpd.Block) :: (_: untpd.Block) :: (appl: untpd.Apply) :: _ + if isParam => + checkForParensAndEdit(appl.fun.endPos.end, '{', block.startPos) + + case _ => + baseEdit(withParens = false) :: Nil + + def simpleType = + typeNameEdit ::: imports + + rhs match + case t: Tree[?] + if t.typeOpt.isErroneous && retryType && !tpt.sourcePos.span.isZeroExtent => + inferredTypeEdits( + Some( + AdjustTypeOpts( + removeType(vl.namePos.end, tpt.sourcePos.end - 1), + tpt.sourcePos.toLsp.getEnd(), + ) + ) + ) + case _ => simpleType + end match + /* `def a[T](param : Int) = param` + * turns into + * `def a[T](param : Int): Int = param` + */ + case Some(df @ DefDef(name, _, tpt, rhs)) => + def typeNameEdit = + /* NOTE: In Scala 3.1.3, `List((1,2)).map((<>,b) => ...)` + * turns into `List((1,2)).map((:Inta,b) => ...)`, + * because `tpt.SourcePos == df.namePos.startPos`, so we use `df.namePos.endPos` instead + * After dropping support for 3.1.3 this can be removed + */ + val end = + if tpt.endPos.end > df.namePos.end then tpt.endPos.toLsp + else df.namePos.endPos.toLsp + + adjustOpt.foreach(adjust => end.setEnd(adjust.adjustedEndPos)) + new TextEdit( + end, + ": " + printType(optDealias(tpt.tpe)), + ) + end typeNameEdit + + def lastColon = + var i = tpt.startPos.start + while i >= 0 && sourceText(i) != ':' do i -= 1 + i + rhs match + case t: Tree[?] + if t.typeOpt.isErroneous && retryType && !tpt.sourcePos.span.isZeroExtent => + inferredTypeEdits( + Some( + AdjustTypeOpts( + removeType(lastColon, tpt.sourcePos.end - 1), + tpt.sourcePos.toLsp.getEnd(), + ) + ) + ) + case _ => + typeNameEdit :: imports + + /* `case t =>` + * turns into + * `case t: Int =>` + */ + case Some(bind @ Bind(name, body)) => + def baseEdit(withParens: Boolean) = + new TextEdit( + bind.endPos.toLsp, + ": " + printType(optDealias(body.tpe)) + { + if withParens then ")" else "" + }, + ) + val typeNameEdit = path match + /* In case it's an infix pattern match + * we need to add () for example in: + * case (head : Int) :: tail => + */ + case _ :: (unappl @ UnApply(_, _, patterns)) :: _ + if patterns.size > 1 => + val firstEnd = patterns(0).endPos.end + val secondStart = patterns(1).startPos.start + val hasDot = params + .text() + .substring(firstEnd, secondStart) + .exists(_ == ',') + if !hasDot then + val leftParen = new TextEdit(body.startPos.toLsp, "(") + leftParen :: baseEdit(withParens = true) :: Nil + else baseEdit(withParens = false) :: Nil + + case _ => + baseEdit(withParens = false) :: Nil + typeNameEdit ::: imports + + /* `for(t <- 0 to 10)` + * turns into + * `for(t: Int <- 0 to 10)` + */ + case Some(i @ Ident(name)) => + val typeNameEdit = new TextEdit( + i.endPos.toLsp, + ": " + printType(optDealias(i.tpe.widen)), + ) + typeNameEdit :: imports + + case _ => + Nil + end match + end inferredTypeEdits + + private def findNamePos( + text: String, + tree: untpd.NamedDefTree, + kewordOffset: Int, + )(using + Context + ): SourcePosition = + val realName = tree.name.stripModuleClassSuffix.toString.toList + + // `NamedDefTree.namePos` is incorrect for bacticked names + @tailrec + def lookup( + idx: Int, + start: Option[(Int, List[Char])], + withBacktick: Boolean, + ): Option[SourcePosition] = + start match + case Some((start, nextMatch :: left)) => + if text.charAt(idx) == nextMatch then + lookup(idx + 1, Some((start, left)), withBacktick) + else lookup(idx + 1, None, withBacktick = false) + case Some((start, Nil)) => + val end = if withBacktick then idx + 1 else idx + val pos = tree.source.atSpan(Span(start, end, start)) + Some(pos) + case None if idx < text.length => + val ch = text.charAt(idx) + if ch == realName.head then + lookup(idx + 1, Some((idx, realName.tail)), withBacktick) + else if ch == '`' then lookup(idx + 1, None, withBacktick = true) + else lookup(idx + 1, None, withBacktick = false) + case _ => + None + + val matchedByText = + if realName.nonEmpty then + lookup(tree.sourcePos.start + kewordOffset, None, false) + else None + + matchedByText.getOrElse(tree.namePos) + end findNamePos + +end InferredTypeProvider diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/MetalsDriver.scala b/presentation-compiler/src/main/scala/meta/internal/pc/MetalsDriver.scala new file mode 100644 index 000000000000..b3328cfe6d9b --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/MetalsDriver.scala @@ -0,0 +1,56 @@ +package scala.meta.internal.pc + +import java.net.URI +import java.{util as ju} + +import dotty.tools.dotc.interactive.InteractiveDriver +import dotty.tools.dotc.reporting.Diagnostic +import dotty.tools.dotc.util.SourceFile + +/** + * MetalsDriver is a wrapper class that provides a compilation cache for InteractiveDriver. + * MetalsDriver skips running compilation if + * - the target URI of `run` is the same as the previous target URI + * - the content didn't change since the last compilation. + * + * This compilation cache enables Metals to skip compilation and re-use + * the typed tree under the situation like developers + * sequentially hover on the symbols in the same file without any changes. + * + * Note: we decided to cache only if the target URI is the same as in the previous run + * because of `InteractiveDriver.currentCtx` that should return the context that + * refers to the last compiled source file. + * It would be ideal if we could update currentCtx even when we skip the compilation, + * but we struggled to do that. See the discussion https://github.com/scalameta/metals/pull/4225#discussion_r941138403 + * To avoid the complexity related to currentCtx, + * we decided to cache only when the target URI only if the same as the previous run. + */ +class MetalsDriver( + override val settings: List[String] +) extends InteractiveDriver(settings): + + @volatile private var lastCompiledURI: URI = _ + + private def alreadyCompiled(uri: URI, content: Array[Char]): Boolean = + compilationUnits.get(uri) match + case Some(unit) + if lastCompiledURI == uri && + ju.Arrays.equals(unit.source.content(), content) => + true + case _ => false + + override def run(uri: URI, source: SourceFile): List[Diagnostic] = + val diags = + if alreadyCompiled(uri, source.content) then Nil + else super.run(uri, source) + lastCompiledURI = uri + diags + + override def run(uri: URI, sourceCode: String): List[Diagnostic] = + val diags = + if alreadyCompiled(uri, sourceCode.toCharArray()) then Nil + else super.run(uri, sourceCode) + lastCompiledURI = uri + diags + +end MetalsDriver diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/MetalsInteractive.scala b/presentation-compiler/src/main/scala/meta/internal/pc/MetalsInteractive.scala new file mode 100644 index 000000000000..d1aff2f1d643 --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/MetalsInteractive.scala @@ -0,0 +1,341 @@ +package scala.meta.internal.pc + +import scala.annotation.tailrec + +import dotty.tools.dotc.ast.tpd +import dotty.tools.dotc.ast.tpd.* +import dotty.tools.dotc.ast.untpd +import dotty.tools.dotc.core.ContextOps.* +import dotty.tools.dotc.core.Contexts.* +import dotty.tools.dotc.core.CyclicReference +import dotty.tools.dotc.core.Flags.* +import dotty.tools.dotc.core.Names.Name +import dotty.tools.dotc.core.StdNames +import dotty.tools.dotc.core.Symbols.* +import dotty.tools.dotc.core.Types.Type +import dotty.tools.dotc.interactive.SourceTree +import dotty.tools.dotc.util.SourceFile +import dotty.tools.dotc.util.SourcePosition + +object MetalsInteractive: + + // This is a copy of dotty.tools.dotc.Interactive.contextOfPath + // with a minor fix in how it processes `Template` + // + // Might be removed after merging https://github.com/lampepfl/dotty/pull/12783 + def contextOfPath(path: List[Tree])(using Context): Context = path match + case Nil | _ :: Nil => + ctx.fresh + case nested :: encl :: rest => + val outer = contextOfPath(encl :: rest) + try + encl match + case tree @ PackageDef(pkg, stats) => + assert(tree.symbol.exists) + if nested `eq` pkg then outer + else + contextOfStat( + stats, + nested, + pkg.symbol.moduleClass, + outer.packageContext(tree, tree.symbol), + ) + case tree: DefDef => + assert(tree.symbol.exists) + val localCtx = outer.localContext(tree, tree.symbol).setNewScope + for params <- tree.paramss; param <- params do + localCtx.enter(param.symbol) + // Note: this overapproximates visibility a bit, since value parameters are only visible + // in subsequent parameter sections + localCtx + case tree: MemberDef => + assert(tree.symbol.exists) + outer.localContext(tree, tree.symbol) + case tree @ Block(stats, expr) => + val localCtx = outer.fresh.setNewScope + stats.foreach { + case stat: MemberDef => localCtx.enter(stat.symbol) + case _ => + } + contextOfStat(stats, nested, ctx.owner, localCtx) + case tree @ CaseDef(pat, guard, rhs) if nested `eq` rhs => + val localCtx = outer.fresh.setNewScope + pat.foreachSubTree { + case bind: Bind => localCtx.enter(bind.symbol) + case _ => + } + localCtx + case tree @ Template(constr, _, self, _) => + if (constr :: self :: tree.parents).contains(nested) then outer + else + contextOfStat( + tree.body, + nested, + tree.symbol, + outer.inClassContext(self.symbol), + ) + case _ => + outer + catch case ex: CyclicReference => outer + end try + + def contextOfStat( + stats: List[Tree], + stat: Tree, + exprOwner: Symbol, + ctx: Context, + ): Context = stats match + case Nil => + ctx + case first :: _ if first eq stat => + ctx.exprContext(stat, exprOwner) + case (imp: Import) :: rest => + contextOfStat( + rest, + stat, + exprOwner, + ctx.importContext(imp, inContext(ctx) { imp.symbol }), + ) + case _ :: rest => + contextOfStat(rest, stat, exprOwner, ctx) + + /** + * Check if the given `sourcePos` is on the name of enclosing tree. + * ``` + * // For example, if the postion is on `foo`, returns true + * def foo(x: Int) = { ... } + * ^ + * + * // On the other hand, it points to non-name position, return false. + * def foo(x: Int) = { ... } + * ^ + * ``` + * @param path - path to the position given by `Interactive.pathTo` + */ + def isOnName( + path: List[Tree], + sourcePos: SourcePosition, + source: SourceFile, + )(using Context): Boolean = + def contains(tree: Tree): Boolean = tree match + case select: Select => + // using `nameSpan` as SourceTree for Select (especially symbolic-infix e.g. `::` of `1 :: Nil`) miscalculate positions + select.nameSpan.contains(sourcePos.span) + case tree: Ident => + tree.sourcePos.contains(sourcePos) + case tree: NamedDefTree => + tree.namePos.contains(sourcePos) + case tree: NameTree => + SourceTree(tree, source).namePos.contains(sourcePos) + // TODO: check the positions for NamedArg and Import + case _: NamedArg => true + case _: Import => true + case app: (Apply | TypeApply) => contains(app.fun) + case _ => false + end contains + + val enclosing = path + .dropWhile(t => !t.symbol.exists && !t.isInstanceOf[NamedArg]) + .headOption + .getOrElse(EmptyTree) + contains(enclosing) + end isOnName + + private lazy val isForName: Set[Name] = Set[Name]( + StdNames.nme.map, + StdNames.nme.withFilter, + StdNames.nme.flatMap, + StdNames.nme.foreach, + ) + def isForSynthetic(gtree: Tree)(using Context): Boolean = + def isForComprehensionSyntheticName(select: Select): Boolean = + select.sourcePos.toSynthetic == select.qualifier.sourcePos.toSynthetic && isForName( + select.name + ) + gtree match + case Apply(fun, List(_: Block)) => isForSynthetic(fun) + case TypeApply(fun, _) => isForSynthetic(fun) + case gtree: Select if isForComprehensionSyntheticName(gtree) => true + case _ => false + + def enclosingSymbols( + path: List[Tree], + pos: SourcePosition, + indexed: IndexedContext, + skipCheckOnName: Boolean = false, + ): List[Symbol] = + enclosingSymbolsWithExpressionType(path, pos, indexed, skipCheckOnName) + .map(_._1) + + /** + * Returns the list of tuple enclosing symbol and + * the symbol's expression type if possible. + */ + @tailrec + def enclosingSymbolsWithExpressionType( + path: List[Tree], + pos: SourcePosition, + indexed: IndexedContext, + skipCheckOnName: Boolean = false, + ): List[(Symbol, Type)] = + import indexed.ctx + path match + // For a named arg, find the target `DefDef` and jump to the param + case NamedArg(name, _) :: Apply(fn, _) :: _ => + val funSym = fn.symbol + if funSym.is(Synthetic) && funSym.owner.is(CaseClass) then + val sym = funSym.owner.info.member(name).symbol + List((sym, sym.info)) + else + val paramSymbol = + for param <- funSym.paramSymss.flatten.find(_.name == name) + yield param + val sym = paramSymbol.getOrElse(fn.symbol) + List((sym, sym.info)) + + case (_: untpd.ImportSelector) :: (imp: Import) :: _ => + importedSymbols(imp, _.span.contains(pos.span)).map(sym => + (sym, sym.info) + ) + + case (imp: Import) :: _ => + importedSymbols(imp, _.span.contains(pos.span)).map(sym => + (sym, sym.info) + ) + + // wildcard param + case head :: _ if (head.symbol.is(Param) && head.symbol.is(Synthetic)) => + List((head.symbol, head.typeOpt)) + + case (head @ Select(target, name)) :: _ + if head.symbol.is(Synthetic) && name == StdNames.nme.apply => + val sym = target.symbol + if sym.is(Synthetic) && sym.is(Module) then + List((sym.companionClass, sym.companionClass.info)) + else List((target.symbol, target.typeOpt)) + + // L@@ft(...) + case (head @ ApplySelect(select)) :: _ + if select.qualifier.sourcePos.contains(pos) && + select.name == StdNames.nme.apply => + List((head.symbol, head.typeOpt)) + + // for Inlined we don't have a symbol, but it's needed to show proper type + case (head @ Inlined(call, bindings, expansion)) :: _ => + List((call.symbol, head.typeOpt)) + + // for comprehension + case (head @ ApplySelect(select)) :: _ if isForSynthetic(head) => + // If the cursor is on the qualifier, return the symbol for it + // `for { x <- List(1).head@@Option }` returns the symbol of `headOption` + if select.qualifier.sourcePos.contains(pos) then + List((select.qualifier.symbol, select.qualifier.typeOpt)) + // Otherwise, returns the symbol of for synthetics such as "withFilter" + else List((head.symbol, head.typeOpt)) + + // f@@oo.bar + case Select(target, _) :: _ + if target.span.isSourceDerived && + target.sourcePos.contains(pos) => + List((target.symbol, target.typeOpt)) + + /* In some cases type might be represented by TypeTree, however it's possible + * that the type tree will not be marked properly as synthetic even if it doesn't + * exist in the code. + * + * For example for `Li@@st(1)` we will get the type tree representing [Int] + * despite it not being in the code. + * + * To work around it we check if the current and parent spans match, if they match + * this most likely means that the type tree is synthetic, since it has efectively + * span of 0. + */ + case (tpt: TypeTree) :: parent :: _ + if tpt.span != parent.span && !tpt.symbol.is(Synthetic) => + List((tpt.symbol, tpt.tpe)) + + /* TypeTest class https://dotty.epfl.ch/docs/reference/other-new-features/type-test.html + * compiler automatically adds unapply if possible, we need to find the type symbol + */ + case (head @ CaseDef(pat, _, _)) :: _ + if defn.TypeTestClass == pat.symbol.owner => + pat match + case UnApply(fun, _, pats) => + val tpeSym = pats.head.typeOpt.typeSymbol + List((tpeSym, tpeSym.info)) + case _ => + Nil + + case path @ head :: tail => + if head.symbol.is(Synthetic) then + enclosingSymbolsWithExpressionType( + tail, + pos, + indexed, + skipCheckOnName, + ) + else if head.symbol != NoSymbol then + if skipCheckOnName || + MetalsInteractive.isOnName( + path, + pos, + indexed.ctx.source, + ) + then List((head.symbol, head.typeOpt)) + /* Type tree for List(1) has an Int type variable, which has span + * but doesn't exist in code. + * https://github.com/lampepfl/dotty/issues/15937 + */ + else if head.isInstanceOf[TypeTree] then + enclosingSymbolsWithExpressionType(tail, pos, indexed) + else Nil + else + val recovered = recoverError(head, indexed) + if recovered.isEmpty then + enclosingSymbolsWithExpressionType( + tail, + pos, + indexed, + skipCheckOnName, + ) + else recovered.map(sym => (sym, sym.info)) + end if + case Nil => Nil + end match + end enclosingSymbolsWithExpressionType + + import scala.meta.internal.mtags.MtagsEnrichments.* + + private def recoverError( + tree: Tree, + indexed: IndexedContext, + ): List[Symbol] = + import indexed.ctx + + tree match + case select: Select => + select.qualifier.typeOpt + .member(select.name) + .allSymbols + .filter(_ != NoSymbol) + case ident: Ident => indexed.findSymbol(ident.name).toList.flatten + case _ => Nil + end recoverError + + object ApplySelect: + def unapply(tree: Tree): Option[Select] = Option(tree).collect { + case select: Select => select + case Apply(select: Select, _) => select + case Apply(TypeApply(select: Select, _), _) => select + } + end ApplySelect + + object TreeApply: + def unapply(tree: Tree): Option[(Tree, List[Tree])] = + tree match + case TypeApply(qual, args) => Some(qual -> args) + case Apply(qual, args) => Some(qual -> args) + case UnApply(qual, implicits, args) => Some(qual -> (implicits ++ args)) + case AppliedTypeTree(qual, args) => Some(qual -> args) + case _ => None +end MetalsInteractive diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/Params.scala b/presentation-compiler/src/main/scala/meta/internal/pc/Params.scala new file mode 100644 index 000000000000..9c268af0b02a --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/Params.scala @@ -0,0 +1,23 @@ +package scala.meta.internal.pc + +import dotty.tools.dotc.core.Contexts.Context +import dotty.tools.dotc.core.Flags.* +import dotty.tools.dotc.core.Symbols.Symbol + +case class Params( + labels: Seq[String], + kind: Params.Kind, +) + +object Params: + enum Kind: + case TypeParameter, Normal, Implicit, Using + + def paramsKind(syms: List[Symbol])(using Context): Params.Kind = + syms match + case head :: _ => + if head.isType then Kind.TypeParameter + else if head.is(Given) then Kind.Using + else if head.is(Implicit) then Kind.Implicit + else Kind.Normal + case Nil => Kind.Normal diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/PcCollector.scala b/presentation-compiler/src/main/scala/meta/internal/pc/PcCollector.scala new file mode 100644 index 000000000000..7473ad7e7a2e --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/PcCollector.scala @@ -0,0 +1,574 @@ +package scala.meta.internal.pc + +import java.nio.file.Paths + +import scala.meta as m + +import scala.meta.internal.metals.CompilerOffsetParams +import scala.meta.internal.mtags.MtagsEnrichments.* +import scala.meta.pc.OffsetParams +import scala.meta.pc.VirtualFileParams + +import dotty.tools.dotc.ast.NavigateAST +import dotty.tools.dotc.ast.Positioned +import dotty.tools.dotc.ast.tpd +import dotty.tools.dotc.ast.tpd.* +import dotty.tools.dotc.ast.untpd +import dotty.tools.dotc.ast.untpd.ExtMethods +import dotty.tools.dotc.ast.untpd.ImportSelector +import dotty.tools.dotc.core.Contexts.* +import dotty.tools.dotc.core.Flags +import dotty.tools.dotc.core.NameOps.* +import dotty.tools.dotc.core.Names.* +import dotty.tools.dotc.core.StdNames.* +import dotty.tools.dotc.core.Symbols.* +import dotty.tools.dotc.core.Types.* +import dotty.tools.dotc.interactive.Interactive +import dotty.tools.dotc.interactive.InteractiveDriver +import dotty.tools.dotc.util.SourceFile +import dotty.tools.dotc.util.SourcePosition +import dotty.tools.dotc.util.Spans.Span + +abstract class PcCollector[T]( + driver: InteractiveDriver, + params: VirtualFileParams, +): + private val caseClassSynthetics: Set[Name] = Set(nme.apply, nme.copy) + val uri = params.uri() + val filePath = Paths.get(uri) + val sourceText = params.text + val source = + SourceFile.virtual(filePath.toString, sourceText) + driver.run(uri, source) + given ctx: Context = driver.currentCtx + + val unit = driver.currentCtx.run.units.head + val compilatonUnitContext = ctx.fresh.setCompilationUnit(unit) + val offset = params match + case op: OffsetParams => op.offset() + case _ => 0 + val offsetParams = + params match + case op: OffsetParams => op + case _ => + CompilerOffsetParams(params.uri(), params.text(), 0, params.token()) + val pos = driver.sourcePosition(offsetParams) + val rawPath = + Interactive + .pathTo(driver.openedTrees(uri), pos)(using driver.currentCtx) + .dropWhile(t => // NamedArg anyway doesn't have symbol + t.symbol == NoSymbol && !t.isInstanceOf[NamedArg] || + // same issue https://github.com/lampepfl/dotty/issues/15937 as below + t.isInstanceOf[TypeTree] + ) + + val path = rawPath match + // For type it will sometimes go into the wrong tree since TypeTree also contains the same span + // https://github.com/lampepfl/dotty/issues/15937 + case TypeApply(sel: Select, _) :: tail if sel.span.contains(pos.span) => + Interactive.pathTo(sel, pos.span) ::: rawPath + case _ => rawPath + def collect( + parent: Option[Tree] + )(tree: Tree, pos: SourcePosition, symbol: Option[Symbol]): T + + /** + * @return (adjusted position, should strip backticks) + */ + def adjust( + pos1: SourcePosition, + forRename: Boolean = false, + ): (SourcePosition, Boolean) = + if !pos1.span.isCorrect then (pos1, false) + else + val pos0 = + val span = pos1.span + if span.exists && span.point > span.end then + pos1.withSpan( + span + .withStart(span.point) + .withEnd(span.point + (span.end - span.start)) + ) + else pos1 + + val pos = + if sourceText(pos0.`end` - 1) == ',' then pos0.withEnd(pos0.`end` - 1) + else pos0 + val isBackticked = + sourceText(pos.start) == '`' && sourceText(pos.end - 1) == '`' + // when the old name contains backticks, the position is incorrect + val isOldNameBackticked = sourceText(pos.start) != '`' && + sourceText(pos.start - 1) == '`' && + sourceText(pos.end) == '`' + if isBackticked && forRename then + (pos.withStart(pos.start + 1).withEnd(pos.`end` - 1), true) + else if isOldNameBackticked then + (pos.withStart(pos.start - 1).withEnd(pos.`end` + 1), false) + else (pos, false) + end adjust + + def symbolAlternatives(sym: Symbol) = + val all = + if sym.is(Flags.ModuleClass) then + Set(sym, sym.companionModule, sym.companionModule.companion) + else if sym.isClass then + Set(sym, sym.companionModule, sym.companion.moduleClass) + else if sym.is(Flags.Module) then + Set(sym, sym.companionClass, sym.moduleClass) + else if sym.isTerm && (sym.owner.isClass || sym.owner.isConstructor) + then + val info = + if sym.owner.isClass then sym.owner.info else sym.owner.owner.info + Set( + sym, + info.member(sym.asTerm.name.setterName).symbol, + info.member(sym.asTerm.name.getterName).symbol, + ) ++ sym.allOverriddenSymbols.toSet + // type used in primary constructor will not match the one used in the class + else if sym.isTypeParam && sym.owner.isPrimaryConstructor then + Set(sym, sym.owner.owner.info.member(sym.name).symbol) + else Set(sym) + all.filter(s => s != NoSymbol && !s.isError) + end symbolAlternatives + + private def isGeneratedGiven(df: NamedDefTree)(using Context) = + val nameSpan = df.nameSpan + df.symbol.is(Flags.Given) && sourceText.substring( + nameSpan.start, + nameSpan.end, + ) != df.name.toString() + + // First identify the symbol we are at, comments identify @@ as current cursor position + def soughtSymbols(path: List[Tree]): Option[(Set[Symbol], SourcePosition)] = + val sought = path match + /* reference of an extension paramter + * extension [EF](<>: List[EF]) + * def double(ys: List[EF]) = <> ++ ys + */ + case (id: Ident) :: _ + if id.symbol + .is(Flags.Param) && id.symbol.owner.is(Flags.ExtensionMethod) => + Some(findAllExtensionParamSymbols(id.sourcePos, id.name, id.symbol)) + /* simple identifier: + * val a = val@@ue + value + */ + case (id: Ident) :: _ => + Some(symbolAlternatives(id.symbol), id.sourcePos) + /* simple selector: + * object.val@@ue + */ + case (sel: Select) :: _ if selectNameSpan(sel).contains(pos.span) => + Some(symbolAlternatives(sel.symbol), pos.withSpan(sel.nameSpan)) + /* named argument: + * foo(nam@@e = "123") + */ + case (arg: NamedArg) :: (appl: Apply) :: _ => + val realName = arg.name.stripModuleClassSuffix.lastPart + if pos.span.start > arg.span.start && pos.span.end < arg.span.point + realName.length + then + appl.symbol.paramSymss.flatten.find(_.name == arg.name).map { s => + // if it's a case class we need to look for parameters also + if caseClassSynthetics(s.owner.name) && s.owner.is(Flags.Synthetic) + then + ( + Set( + s, + s.owner.owner.companion.info.member(s.name).symbol, + s.owner.owner.info.member(s.name).symbol, + ) + .filter(_ != NoSymbol), + arg.sourcePos, + ) + else (Set(s), arg.sourcePos) + } + else None + end if + /* all definitions: + * def fo@@o = ??? + * class Fo@@o = ??? + * etc. + */ + case (df: NamedDefTree) :: _ + if df.nameSpan.contains(pos.span) && !isGeneratedGiven(df) => + Some(symbolAlternatives(df.symbol), pos.withSpan(df.nameSpan)) + /** + * For traversing annotations: + * @JsonNo@@tification("") + * def params() = ??? + */ + case (df: MemberDef) :: _ if df.span.contains(pos.span) => + val annotTree = df.mods.annotations.find { t => + t.span.contains(pos.span) + } + collectTrees(annotTree).flatMap { t => + soughtSymbols( + Interactive.pathTo(t, pos.span) + ) + }.headOption + + /* Import selectors: + * import scala.util.Tr@@y + */ + case (imp: Import) :: _ if imp.span.contains(pos.span) => + imp + .selector(pos.span) + .map(sym => (symbolAlternatives(sym), sym.sourcePos)) + + case _ => None + + sought match + case None => seekInExtensionParameters() + case _ => sought + + end soughtSymbols + + lazy val extensionMethods = + NavigateAST + .untypedPath(pos.span)(using compilatonUnitContext) + .collectFirst { case em @ ExtMethods(_, _) => em } + + private def findAllExtensionParamSymbols( + pos: SourcePosition, + name: Name, + sym: Symbol, + ) = + val symbols = + for + methods <- extensionMethods.map(_.methods) + symbols <- collectAllExtensionParamSymbols( + unit.tpdTree, + ExtensionParamOccurence(name, pos, sym, methods), + ) + yield symbols + symbols.getOrElse((symbolAlternatives(sym), pos)) + end findAllExtensionParamSymbols + + private def seekInExtensionParameters() = + def collectParams( + extMethods: ExtMethods + ): Option[ExtensionParamOccurence] = + MetalsNavigateAST + .pathToExtensionParam(pos.span, extMethods)(using compilatonUnitContext) + .collectFirst { + case v: untpd.ValOrTypeDef => + ExtensionParamOccurence( + v.name, + v.namePos, + v.symbol, + extMethods.methods, + ) + case i: untpd.Ident => + ExtensionParamOccurence( + i.name, + i.sourcePos, + i.symbol, + extMethods.methods, + ) + } + + for + extensionMethodScope <- extensionMethods + occurence <- collectParams(extensionMethodScope) + symbols <- collectAllExtensionParamSymbols( + path.headOption.getOrElse(unit.tpdTree), + occurence, + ) + yield symbols + end seekInExtensionParameters + + private def collectAllExtensionParamSymbols( + tree: tpd.Tree, + occurrence: ExtensionParamOccurence, + ): Option[(Set[Symbol], SourcePosition)] = + occurrence match + case ExtensionParamOccurence(_, namePos, symbol, _) + if symbol != NoSymbol && !symbol.isError && !symbol.owner.is( + Flags.ExtensionMethod + ) => + Some((symbolAlternatives(symbol), namePos)) + case ExtensionParamOccurence(name, namePos, _, methods) => + val symbols = + for + method <- methods.toSet + symbol <- + Interactive.pathTo(tree, method.span) match + case (d: DefDef) :: _ => + d.paramss.flatten.collect { + case param if param.name.decoded == name.decoded => + param.symbol + } + case _ => Set.empty[Symbol] + if (symbol != NoSymbol && !symbol.isError) + withAlt <- symbolAlternatives(symbol) + yield withAlt + if symbols.nonEmpty then Some((symbols, namePos)) else None + end collectAllExtensionParamSymbols + + def result(): List[T] = + params match + case _: OffsetParams => resultWithSought() + case _ => resultAllOccurences().toList + + def resultAllOccurences(): Set[T] = + def noTreeFilter = (tree: Tree) => true + def noSoughtFilter = (f: Symbol => Boolean) => true + + traverseSought(noTreeFilter, noSoughtFilter) + + def resultWithSought(): List[T] = + soughtSymbols(path) match + case Some((sought, _)) => + lazy val owners = sought + .flatMap { s => Set(s.owner, s.owner.companionModule) } + .filter(_ != NoSymbol) + lazy val soughtNames: Set[Name] = sought.map(_.name) + + /* + * For comprehensions have two owners, one for the enumerators and one for + * yield. This is a heuristic to find that out. + */ + def isForComprehensionOwner(named: NameTree) = + soughtNames(named.name) && + scala.util + .Try(named.symbol.owner) + .toOption + .exists(_.isAnonymousFunction) && + owners.exists(o => + o.span.exists && o.span.point == named.symbol.owner.span.point + ) + + def soughtOrOverride(sym: Symbol) = + sought(sym) || sym.allOverriddenSymbols.exists(sought(_)) + + def soughtTreeFilter(tree: Tree): Boolean = + tree match + case ident: Ident + if soughtOrOverride(ident.symbol) || + isForComprehensionOwner(ident) => + true + case sel: Select if soughtOrOverride(sel.symbol) => true + case df: NamedDefTree + if soughtOrOverride(df.symbol) && !df.symbol.isSetter => + true + case imp: Import if owners(imp.expr.symbol) => true + case _ => false + + def soughtFilter(f: Symbol => Boolean): Boolean = + sought.exists(f) + + traverseSought(soughtTreeFilter, soughtFilter).toList + + case None => Nil + + extension (span: Span) + def isCorrect = + !span.isZeroExtent && span.exists && span.start < sourceText.size && span.end <= sourceText.size + + def traverseSought( + filter: Tree => Boolean, + soughtFilter: (Symbol => Boolean) => Boolean, + ): Set[T] = + def collectNamesWithParent( + occurences: Set[T], + tree: Tree, + parent: Option[Tree], + ): Set[T] = + def collect( + tree: Tree, + pos: SourcePosition, + symbol: Option[Symbol] = None, + ) = + this.collect(parent)(tree, pos, symbol) + tree match + /** + * All indentifiers such as: + * val a = <> + */ + case ident: Ident if ident.span.isCorrect && filter(ident) => + // symbols will differ for params in different ext methods, but source pos will be the same + if soughtFilter(_.sourcePos == ident.symbol.sourcePos) + then + occurences + collect( + ident, + ident.sourcePos, + ) + else occurences + /** + * All select statements such as: + * val a = hello.<> + */ + case sel: Select if sel.span.isCorrect && filter(sel) => + occurences + collect( + sel, + pos.withSpan(selectNameSpan(sel)), + ) + /* all definitions: + * def <> = ??? + * class <> = ??? + * etc. + */ + case df: NamedDefTree + if df.span.isCorrect && df.nameSpan.isCorrect && + filter(df) && !isGeneratedGiven(df) => + val annots = collectTrees(df.mods.annotations) + val traverser = + new PcCollector.DeepFolderWithParent[Set[T]]( + collectNamesWithParent + ) + annots.foldLeft( + occurences + collect( + df, + pos.withSpan(df.nameSpan), + ) + ) { case (set, tree) => + traverser(set, tree) + } + + /* Named parameters don't have symbol so we need to check the owner + * foo(<> = "abc") + * User(<> = "abc") + * etc. + */ + case apply: Apply => + val args: List[NamedArg] = apply.args.collect { + case arg: NamedArg + if soughtFilter(sym => + sym.name == arg.name && + // foo(name = "123") for normal params + (sym.owner == apply.symbol || + // Bar(name = "123") for case class, copy and apply methods + apply.symbol.is(Flags.Synthetic) && + (sym.owner == apply.symbol.owner.companion || sym.owner == apply.symbol.owner)) + ) => + arg + } + val named = args.map { arg => + val realName = arg.name.stripModuleClassSuffix.lastPart + val sym = apply.symbol.paramSymss.flatten + .find(_.name == realName) + collect( + arg, + pos + .withSpan( + arg.span + .withEnd(arg.span.start + realName.length) + .withPoint(arg.span.start) + ), + sym, + ) + } + occurences ++ named + + /** + * For traversing annotations: + * @<>("") + * def params() = ??? + */ + case mdf: MemberDef if mdf.mods.annotations.nonEmpty => + val trees = collectTrees(mdf.mods.annotations) + val traverser = + new PcCollector.DeepFolderWithParent[Set[T]]( + collectNamesWithParent + ) + trees.foldLeft(occurences) { case (set, tree) => + traverser(set, tree) + } + /** + * For traversing import selectors: + * import scala.util.<> + */ + case imp: Import if filter(imp) => + imp.selectors + .collect { + case sel: ImportSelector + if soughtFilter(_.decodedName == sel.name.decoded) => + // Show both rename and main together + val spans = + if !sel.renamed.isEmpty then + Set(sel.renamed.span, sel.imported.span) + else Set(sel.imported.span) + // See https://github.com/scalameta/metals/pull/5100 + val symbol = imp.expr.symbol.info.member(sel.name).symbol match + // We can get NoSymbol when we import "_", "*"", "given" or when the names don't match + // eg. "@@" doesn't match "$at$at". + // Then we try to find member based on decodedName + case NoSymbol => + imp.expr.symbol.info.allMembers + .find(_.name.decoded == sel.name.decoded) + .map(_.symbol) + .getOrElse(NoSymbol) + case sym => sym + spans.filter(_.isCorrect).map { span => + collect( + imp, + pos.withSpan(span), + Some(symbol), + ) + } + } + .flatten + .toSet ++ occurences + case inl: Inlined => + val traverser = + new PcCollector.DeepFolderWithParent[Set[T]]( + collectNamesWithParent + ) + val trees = inl.call :: inl.bindings + trees.foldLeft(occurences) { case (set, tree) => + traverser(set, tree) + } + case o => + occurences + end match + end collectNamesWithParent + + val traverser = + new PcCollector.DeepFolderWithParent[Set[T]](collectNamesWithParent) + val all = traverser(Set.empty[T], unit.tpdTree) + all + end traverseSought + + // @note (tgodzik) Not sure currently how to get rid of the warning, but looks to correctly + // @nowarn + private def collectTrees(trees: Iterable[Positioned]): Iterable[Tree] = + trees.collect { case t: Tree => + t + } + + // NOTE: Connected to https://github.com/lampepfl/dotty/issues/16771 + // `sel.nameSpan` is calculated incorrectly in (1 + 2).toString + // See test DocumentHighlightSuite.select-parentheses + private def selectNameSpan(sel: Select): Span = + val span = sel.span + if span.exists then + val point = span.point + if sel.name.toTermName == nme.ERROR then Span(point) + else if sel.qualifier.span.start > span.point then // right associative + val realName = sel.name.stripModuleClassSuffix.lastPart + Span(span.start, span.start + realName.length, point) + else Span(point, span.end, point) + else span +end PcCollector + +object PcCollector: + private class WithParentTraverser[X](f: (X, Tree, Option[Tree]) => X) + extends TreeAccumulator[List[Tree]]: + def apply(x: List[Tree], tree: Tree)(using Context): List[Tree] = tree :: x + def traverse(acc: X, tree: Tree, parent: Option[Tree])(using Context): X = + val res = f(acc, tree, parent) + val children = foldOver(Nil, tree).reverse + children.foldLeft(res)((a, c) => traverse(a, c, Some(tree))) + + // Folds over the tree as `DeepFolder` but `f` takes also the parent. + class DeepFolderWithParent[X](f: (X, Tree, Option[Tree]) => X): + private val traverser = WithParentTraverser[X](f) + def apply(x: X, tree: Tree)(using Context) = + traverser.traverse(x, tree, None) +end PcCollector + +case class ExtensionParamOccurence( + name: Name, + pos: SourcePosition, + sym: Symbol, + methods: List[untpd.Tree], +) diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/PcDefinitionProvider.scala b/presentation-compiler/src/main/scala/meta/internal/pc/PcDefinitionProvider.scala new file mode 100644 index 000000000000..cd69e097b07d --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/PcDefinitionProvider.scala @@ -0,0 +1,172 @@ +package scala.meta.internal.pc + +import java.nio.file.Paths +import java.util.ArrayList + +import scala.jdk.CollectionConverters.* + +import scala.meta.internal.mtags.MtagsEnrichments.* +import scala.meta.pc.DefinitionResult +import scala.meta.pc.OffsetParams +import scala.meta.pc.SymbolSearch + +import dotty.tools.dotc.CompilationUnit +import dotty.tools.dotc.ast.NavigateAST +import dotty.tools.dotc.ast.tpd.* +import dotty.tools.dotc.ast.untpd +import dotty.tools.dotc.core.Contexts.Context +import dotty.tools.dotc.core.Flags.ModuleClass +import dotty.tools.dotc.core.Symbols.* +import dotty.tools.dotc.interactive.Interactive +import dotty.tools.dotc.interactive.InteractiveDriver +import dotty.tools.dotc.util.SourceFile +import dotty.tools.dotc.util.SourcePosition +import org.eclipse.lsp4j.Location + +class PcDefinitionProvider( + driver: InteractiveDriver, + params: OffsetParams, + search: SymbolSearch, +): + + def definitions(): DefinitionResult = + definitions(findTypeDef = false) + + def typeDefinitions(): DefinitionResult = + definitions(findTypeDef = true) + + private def definitions(findTypeDef: Boolean): DefinitionResult = + val uri = params.uri + val filePath = Paths.get(uri) + driver.run( + uri, + SourceFile.virtual(filePath.toString, params.text), + ) + val unit = driver.currentCtx.run.units.head + val tree = unit.tpdTree + + val pos = driver.sourcePosition(params) + val path = + Interactive.pathTo(driver.openedTrees(uri), pos)(using driver.currentCtx) + + given ctx: Context = driver.localContext(params) + val indexedContext = IndexedContext(ctx) + val result = + if findTypeDef then findTypeDefinitions(path, pos, indexedContext) + else findDefinitions(path, pos, indexedContext) + + if result.locations().isEmpty() then fallbackToUntyped(unit, pos)(using ctx) + else result + end definitions + + /** + * Some nodes might disapear from the typed tree, since they are mostly + * used as syntactic sugar. In those cases we check the untyped tree + * and try to get the symbol from there, which might actually be there, + * because these are the same nodes that go through the typer. + * + * This will happen for: + * - `.. derives Show` + * @param unit compilation unit of the file + * @param pos cursor position + * @return definition result + */ + private def fallbackToUntyped(unit: CompilationUnit, pos: SourcePosition)( + using ctx: Context + ) = + lazy val untpdPath = NavigateAST + .untypedPath(pos.span) + .collect { case t: untpd.Tree => t } + + definitionsForSymbol(untpdPath.headOption.map(_.symbol).toList, pos) + end fallbackToUntyped + + private def findDefinitions( + path: List[Tree], + pos: SourcePosition, + indexed: IndexedContext, + ): DefinitionResult = + import indexed.ctx + definitionsForSymbol( + MetalsInteractive.enclosingSymbols(path, pos, indexed), + pos, + ) + end findDefinitions + + private def findTypeDefinitions( + path: List[Tree], + pos: SourcePosition, + indexed: IndexedContext, + ): DefinitionResult = + import indexed.ctx + val enclosing = path.expandRangeToEnclosingApply(pos) + val typeSymbols = MetalsInteractive + .enclosingSymbolsWithExpressionType(enclosing, pos, indexed) + .map { case (_, tpe) => + tpe.typeSymbol + } + typeSymbols match + case Nil => + path.headOption match + case Some(value: Literal) => + definitionsForSymbol(List(value.tpe.widen.typeSymbol), pos) + case _ => DefinitionResultImpl.empty + case _ => + definitionsForSymbol(typeSymbols, pos) + + end findTypeDefinitions + + private def definitionsForSymbol( + symbols: List[Symbol], + pos: SourcePosition, + )(using ctx: Context): DefinitionResult = + symbols match + case symbols @ (sym :: other) => + val isLocal = sym.source == pos.source + if isLocal then + val defs = + Interactive.findDefinitions(List(sym), driver, false, false) + defs.headOption match + case Some(srcTree) => + val pos = srcTree.namePos + pos.toLocation match + case None => DefinitionResultImpl.empty + case Some(loc) => + DefinitionResultImpl( + SemanticdbSymbols.symbolName(sym), + List(loc).asJava, + ) + case None => + DefinitionResultImpl.empty + else + val res = new ArrayList[Location]() + semanticSymbolsSorted(symbols) + .foreach { sym => + res.addAll(search.definition(sym, params.uri())) + } + DefinitionResultImpl( + SemanticdbSymbols.symbolName(sym), + res, + ) + end if + case Nil => DefinitionResultImpl.empty + end match + end definitionsForSymbol + + def semanticSymbolsSorted( + syms: List[Symbol] + )(using ctx: Context): List[String] = + syms + .map { sym => + // in case of having the same type and teerm symbol + // term comes first + // used only for ordering symbols that come from `Import` + val termFlag = + if sym.is(ModuleClass) then sym.sourceModule.isTerm + else sym.isTerm + (termFlag, SemanticdbSymbols.symbolName(sym)) + } + .sorted + .map(_._2) + +end PcDefinitionProvider diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/PcDocumentHighlightProvider.scala b/presentation-compiler/src/main/scala/meta/internal/pc/PcDocumentHighlightProvider.scala new file mode 100644 index 000000000000..e36fa9c152b6 --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/PcDocumentHighlightProvider.scala @@ -0,0 +1,33 @@ +package scala.meta.internal.pc + +import scala.meta.internal.mtags.MtagsEnrichments.* +import scala.meta.pc.OffsetParams + +import dotty.tools.dotc.ast.tpd.* +import dotty.tools.dotc.core.Symbols.* +import dotty.tools.dotc.interactive.InteractiveDriver +import dotty.tools.dotc.util.SourcePosition +import org.eclipse.lsp4j.DocumentHighlight +import org.eclipse.lsp4j.DocumentHighlightKind + +final class PcDocumentHighlightProvider( + driver: InteractiveDriver, + params: OffsetParams, +) extends PcCollector[DocumentHighlight](driver, params): + + def collect( + parent: Option[Tree] + )( + tree: Tree, + toAdjust: SourcePosition, + sym: Option[Symbol], + ): DocumentHighlight = + val (pos, _) = adjust(toAdjust) + tree match + case _: NamedDefTree => + DocumentHighlight(pos.toLsp, DocumentHighlightKind.Write) + case _ => DocumentHighlight(pos.toLsp, DocumentHighlightKind.Read) + + def highlights: List[DocumentHighlight] = + result().distinctBy(_.getRange()) +end PcDocumentHighlightProvider diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/PcInlineValueProviderImpl.scala b/presentation-compiler/src/main/scala/meta/internal/pc/PcInlineValueProviderImpl.scala new file mode 100644 index 000000000000..a249125ed0c6 --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/PcInlineValueProviderImpl.scala @@ -0,0 +1,201 @@ +package scala.meta.internal.pc + +import scala.meta.internal.mtags.MtagsEnrichments.* +import scala.meta.internal.pc.InlineValueProvider.Errors +import scala.meta.pc.OffsetParams + +import dotty.tools.dotc.ast.NavigateAST +import dotty.tools.dotc.ast.tpd.* +import dotty.tools.dotc.ast.untpd +import dotty.tools.dotc.core.Contexts.Context +import dotty.tools.dotc.core.Flags.* +import dotty.tools.dotc.core.StdNames +import dotty.tools.dotc.core.Symbols.Symbol +import dotty.tools.dotc.interactive.Interactive +import dotty.tools.dotc.interactive.InteractiveDriver +import dotty.tools.dotc.util.SourcePosition +import org.eclipse.{lsp4j as l} + +final class PcInlineValueProviderImpl( + val driver: InteractiveDriver, + val params: OffsetParams, +) extends PcCollector[Occurence](driver, params) + with InlineValueProvider: + + val text = params.text.toCharArray() + + val position: l.Position = pos.toLsp.getStart() + + override def collect(parent: Option[Tree])( + tree: Tree, + pos: SourcePosition, + sym: Option[Symbol], + ): Occurence = + val (adjustedPos, _) = adjust(pos) + Occurence(tree, parent, adjustedPos) + + override def defAndRefs(): Either[String, (Definition, List[Reference])] = + val newctx = driver.currentCtx.fresh.setCompilationUnit(unit) + val allOccurences = result() + for + definition <- allOccurences + .collectFirst { case Occurence(defn: ValDef, _, pos) => + DefinitionTree(defn, pos) + } + .toRight(Errors.didNotFindDefinition) + symbols = symbolsUsedInDefn(definition.tree.rhs) + references <- getReferencesToInline(definition, allOccurences, symbols) + yield + val (deleteDefinition, refsEdits) = references + + val defPos = definition.tree.sourcePos + val defEdit = Definition( + defPos.toLsp, + adjustRhs(definition.tree.rhs.sourcePos), + RangeOffset(defPos.start, defPos.end), + definitionRequiresBrackets(definition.tree.rhs)(using newctx), + deleteDefinition, + ) + + (defEdit, refsEdits) + end for + end defAndRefs + + private def definitionRequiresBrackets(tree: Tree)(using Context): Boolean = + NavigateAST + .untypedPath(tree.span) + .headOption + .map { + case _: untpd.If => true + case _: untpd.Function => true + case _: untpd.Match => true + case _: untpd.ForYield => true + case _: untpd.InfixOp => true + case _: untpd.ParsedTry => true + case _: untpd.Try => true + case _: untpd.Block => true + case _: untpd.Typed => true + case _ => false + } + .getOrElse(false) + + end definitionRequiresBrackets + + private def referenceRequiresBrackets(tree: Tree)(using Context): Boolean = + NavigateAST.untypedPath(tree.span) match + case (_: untpd.InfixOp) :: _ => true + case _ => + tree match + case _: Apply => StdNames.nme.raw.isUnary(tree.symbol.name) + case _: Select => true + case _: Ident => true + case _ => false + + end referenceRequiresBrackets + // format: on + + private def adjustRhs(pos: SourcePosition) = + def extend(point: Int, acceptedChar: Char, step: Int): Int = + val newPoint = point + step + if newPoint > 0 && newPoint < text.length && text( + newPoint + ) == acceptedChar + then extend(newPoint, acceptedChar, step) + else point + val adjustedStart = extend(pos.start, '(', -1) + val adjustedEnd = extend(pos.end - 1, ')', 1) + 1 + text.slice(adjustedStart, adjustedEnd).mkString + + private def symbolsUsedInDefn( + rhs: Tree + ): List[Symbol] = + def collectNames( + symbols: List[Symbol], + tree: Tree, + ): List[Symbol] = + tree match + case id: (Ident | Select) + if !id.symbol.is(Synthetic) && !id.symbol.is(Implicit) => + tree.symbol :: symbols + case _ => symbols + + val traverser = new DeepFolder[List[Symbol]](collectNames) + traverser(List(), rhs) + end symbolsUsedInDefn + + private def getReferencesToInline( + definition: DefinitionTree, + allOccurences: List[Occurence], + symbols: List[Symbol], + ): Either[String, (Boolean, List[Reference])] = + val defIsLocal = definition.tree.symbol.ownersIterator + .drop(1) + .exists(e => e.isTerm) + def allreferences = allOccurences.filterNot(_.isDefn) + def inlineAll() = + makeRefsEdits(allreferences, symbols).map((true, _)) + if definition.tree.sourcePos.toLsp.encloses(position) + then if defIsLocal then inlineAll() else Left(Errors.notLocal) + else + allreferences match + case ref :: Nil if defIsLocal => inlineAll() + case list => + for + ref <- list + .find(_.pos.toLsp.encloses(position)) + .toRight(Errors.didNotFindReference) + refEdits <- makeRefsEdits(List(ref), symbols) + yield (false, refEdits) + end if + end getReferencesToInline + + private def makeRefsEdits( + refs: List[Occurence], + symbols: List[Symbol], + ): Either[String, List[Reference]] = + val newctx = driver.currentCtx.fresh.setCompilationUnit(unit) + def buildRef(occurence: Occurence): Either[String, Reference] = + val path = + Interactive.pathTo(unit.tpdTree, occurence.pos.span)(using newctx) + val indexedContext = IndexedContext( + MetalsInteractive.contextOfPath(path)(using newctx) + ) + import indexedContext.ctx + val conflictingSymbols = symbols + .withFilter { + indexedContext.lookupSym(_) match + case IndexedContext.Result.Conflict => true + case _ => false + } + .map(_.fullNameBackticked) + if conflictingSymbols.isEmpty then + Right( + Reference( + occurence.pos.toLsp, + occurence.parent.map(p => + RangeOffset(p.sourcePos.start, p.sourcePos.end) + ), + occurence.parent + .map(p => referenceRequiresBrackets(p)(using newctx)) + .getOrElse(false), + ) + ) + else Left(Errors.variablesAreShadowed(conflictingSymbols.mkString(", "))) + end buildRef + refs.foldLeft((Right(List())): Either[String, List[Reference]])((acc, r) => + for + collectedEdits <- acc + currentEdit <- buildRef(r) + yield currentEdit :: collectedEdits + ) + end makeRefsEdits + +end PcInlineValueProviderImpl + +case class Occurence(tree: Tree, parent: Option[Tree], pos: SourcePosition): + def isDefn = + tree match + case _: ValDef => true + case _ => false + +case class DefinitionTree(tree: ValDef, pos: SourcePosition) diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/PcRenameProvider.scala b/presentation-compiler/src/main/scala/meta/internal/pc/PcRenameProvider.scala new file mode 100644 index 000000000000..1837d3aa420f --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/PcRenameProvider.scala @@ -0,0 +1,53 @@ +package scala.meta.internal.pc + +import scala.meta.internal.mtags.MtagsEnrichments.* +import scala.meta.pc.OffsetParams + +import dotty.tools.dotc.ast.tpd.* +import dotty.tools.dotc.core.Contexts.Context +import dotty.tools.dotc.core.Flags.* +import dotty.tools.dotc.core.Symbols.Symbol +import dotty.tools.dotc.interactive.InteractiveDriver +import dotty.tools.dotc.util.SourcePosition +import org.eclipse.{lsp4j as l} + +final class PcRenameProvider( + driver: InteractiveDriver, + params: OffsetParams, + name: Option[String], +) extends PcCollector[l.TextEdit](driver, params): + private val forbiddenMethods = + Set("equals", "hashCode", "unapply", "unary_!", "!") + def canRenameSymbol(sym: Symbol)(using Context): Boolean = + (!sym.is(Method) || !forbiddenMethods(sym.decodedName)) + && (sym.ownersIterator.drop(1).exists(ow => ow.is(Method)) + || sym.source.path.isWorksheet) + + def prepareRename(): Option[l.Range] = + soughtSymbols(path).flatMap((symbols, pos) => + if symbols.forall(canRenameSymbol) then Some(pos.toLsp) + else None + ) + + val newName = name.map(_.stripBackticks.backticked).getOrElse("newName") + + def collect( + parent: Option[Tree] + )(tree: Tree, toAdjust: SourcePosition, sym: Option[Symbol]): l.TextEdit = + val (pos, stripBackticks) = adjust(toAdjust, forRename = true) + l.TextEdit( + pos.toLsp, + if stripBackticks then newName.stripBackticks else newName, + ) + end collect + + def rename( + ): List[l.TextEdit] = + val (symbols, _) = soughtSymbols(path).getOrElse(Set.empty, pos) + if symbols.nonEmpty && symbols.forall(canRenameSymbol(_)) + then + val res = result() + res + else Nil + end rename +end PcRenameProvider diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/PcSemanticTokensProvider.scala b/presentation-compiler/src/main/scala/meta/internal/pc/PcSemanticTokensProvider.scala new file mode 100644 index 000000000000..3f6654e8ff0d --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/PcSemanticTokensProvider.scala @@ -0,0 +1,150 @@ +package scala.meta.internal.pc + +import scala.meta.internal.mtags.MtagsEnrichments.* +import scala.meta.internal.pc.SemanticTokens.* +import scala.meta.pc.Node +import scala.meta.pc.VirtualFileParams + +import dotty.tools.dotc.ast.tpd.* +import dotty.tools.dotc.core.Contexts.Context +import dotty.tools.dotc.core.Flags +import dotty.tools.dotc.core.Symbols.NoSymbol +import dotty.tools.dotc.core.Symbols.Symbol +import dotty.tools.dotc.interactive.InteractiveDriver +import dotty.tools.dotc.util.SourcePosition +import org.eclipse.lsp4j.SemanticTokenModifiers +import org.eclipse.lsp4j.SemanticTokenTypes + +/** + * Provides semantic tokens of file(@param params) + * according to the LSP specification. + */ +final class PcSemanticTokensProvider( + driver: InteractiveDriver, + params: VirtualFileParams, +): + /** + * Declaration is set for: + * 1. parameters, + * 2. defs/vals/vars without rhs, + * 3. type parameters, + * In all those cases we don't have a specific value for sure. + */ + private def isDeclaration(tree: Tree) = tree match + case df: ValOrDefDef => df.rhs.isEmpty + case df: TypeDef => + df.rhs match + case _: Template => false + case _ => df.rhs.isEmpty + case _ => false + + /** + * Definition is set for: + * 1. defs/vals/vars/type with rhs. + * 2. pattern matches + * + * We don't want to set it for enum cases despite the fact + * that the compiler sees them as vals, as it's not clear + * if they should be declaration/definition at all. + */ + private def isDefinition(tree: Tree) = tree match + case df: Bind => true + case df: ValOrDefDef => + !df.rhs.isEmpty && !df.symbol.isAllOf(Flags.EnumCase) + case df: TypeDef => + df.rhs match + case _: Template => false + case _ => !df.rhs.isEmpty + case _ => false + + object Collector extends PcCollector[Option[Node]](driver, params): + override def collect( + parent: Option[Tree] + )(tree: Tree, pos: SourcePosition, symbol: Option[Symbol]): Option[Node] = + val sym = symbol.fold(tree.symbol)(identity) + if !pos.exists || sym == null || sym == NoSymbol then None + else + Some( + makeNode( + sym = sym, + pos = adjust(pos)._1, + isDefinition = isDefinition(tree), + isDeclaration = isDeclaration(tree), + ) + ) + end collect + end Collector + + given Context = Collector.ctx + + def provide(): List[Node] = + Collector + .result() + .flatten + .sortWith((n1, n2) => + if n1.start() == n2.start() then n1.end() < n2.end() + else n1.start() < n2.start() + ) + + def makeNode( + sym: Symbol, + pos: SourcePosition, + isDefinition: Boolean, + isDeclaration: Boolean, + ): Node = + + var mod: Int = 0 + def addPwrToMod(tokenID: String) = + val place: Int = getModifierId(tokenID) + if place != -1 then mod += (1 << place) + // get Type + val typ = + if sym.is(Flags.Param) && !sym.isTypeParam + then + addPwrToMod(SemanticTokenModifiers.Readonly) + getTypeId(SemanticTokenTypes.Parameter) + else if sym.isTypeParam || sym.isSkolem then + getTypeId(SemanticTokenTypes.TypeParameter) + else if sym.is(Flags.Enum) || sym.isAllOf(Flags.EnumVal) + then getTypeId(SemanticTokenTypes.Enum) + else if sym.is(Flags.Trait) then + getTypeId(SemanticTokenTypes.Interface) // "interface" + else if sym.isClass then getTypeId(SemanticTokenTypes.Class) // "class" + else if sym.isType && !sym.is(Flags.Param) then + getTypeId(SemanticTokenTypes.Type) // "type" + else if sym.is(Flags.Mutable) then + getTypeId(SemanticTokenTypes.Variable) // "var" + else if sym.is(Flags.Package) then + getTypeId(SemanticTokenTypes.Namespace) // "package" + else if sym.is(Flags.Module) then + getTypeId(SemanticTokenTypes.Class) // "object" + else if sym.isRealMethod then + if sym.isGetter | sym.isSetter then + getTypeId(SemanticTokenTypes.Variable) + else getTypeId(SemanticTokenTypes.Method) // "def" + else if isPredefClass(sym) then + getTypeId(SemanticTokenTypes.Class) // "class" + else if sym.isTerm && + (!sym.is(Flags.Param) || sym.is(Flags.ParamAccessor)) + then + addPwrToMod(SemanticTokenModifiers.Readonly) + getTypeId(SemanticTokenTypes.Variable) // "val" + else -1 + + // Modifiers except by ReadOnly + if sym.is(Flags.Abstract) || sym.isAbstractOrParamType || + sym.isOneOf(Flags.AbstractOrTrait) + then addPwrToMod(SemanticTokenModifiers.Abstract) + if sym.annotations.exists(_.symbol.decodedName == "deprecated") + then addPwrToMod(SemanticTokenModifiers.Deprecated) + + if isDeclaration then addPwrToMod(SemanticTokenModifiers.Declaration) + if isDefinition then addPwrToMod(SemanticTokenModifiers.Definition) + + TokenNode(pos.start, pos.`end`, typ, mod) + end makeNode + + def isPredefClass(sym: Symbol)(using Context) = + sym.is(Flags.Method) && sym.info.resultType.typeSymbol.is(Flags.Module) + +end PcSemanticTokensProvider diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/Scala3CompilerAccess.scala b/presentation-compiler/src/main/scala/meta/internal/pc/Scala3CompilerAccess.scala new file mode 100644 index 000000000000..7348fea2d643 --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/Scala3CompilerAccess.scala @@ -0,0 +1,38 @@ +package scala.meta.internal.pc + +import java.util.concurrent.ScheduledExecutorService + +import scala.concurrent.ExecutionContextExecutor + +import scala.meta.pc.PresentationCompilerConfig + +import dotty.tools.dotc.reporting.StoreReporter + +class Scala3CompilerAccess( + config: PresentationCompilerConfig, + sh: Option[ScheduledExecutorService], + newCompiler: () => Scala3CompilerWrapper, +)(using ec: ExecutionContextExecutor) + extends CompilerAccess[StoreReporter, MetalsDriver]( + config, + sh, + newCompiler, + /* If running inside the executor, we need to reset the job queue + * Otherwise it will block indefinetely in case of infinite loops. + */ + shouldResetJobQueue = true, + ): + + def newReporter = new StoreReporter(null) + + /** + * Handle the exception in order to make sure that + * we retry immediately. Otherwise, we will wait until + * the end of the timeout, which is 20s by default. + */ + protected def handleSharedCompilerException( + t: Throwable + ): Option[String] = None + + protected def ignoreException(t: Throwable): Boolean = false +end Scala3CompilerAccess diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/Scala3CompilerWrapper.scala b/presentation-compiler/src/main/scala/meta/internal/pc/Scala3CompilerWrapper.scala new file mode 100644 index 000000000000..fc2ecbfb3a4d --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/Scala3CompilerWrapper.scala @@ -0,0 +1,25 @@ +package scala.meta.internal.pc + +import dotty.tools.dotc.reporting.StoreReporter + +class Scala3CompilerWrapper(driver: MetalsDriver) + extends CompilerWrapper[StoreReporter, MetalsDriver]: + + override def compiler(): MetalsDriver = driver + + override def resetReporter(): Unit = + val ctx = driver.currentCtx + ctx.reporter.removeBufferedMessages(using ctx) + + override def reporterAccess: ReporterAccess[StoreReporter] = + new ReporterAccess[StoreReporter]: + def reporter = driver.currentCtx.reporter.asInstanceOf[StoreReporter] + + override def askShutdown(): Unit = () + + override def isAlive(): Boolean = false + + override def stop(): Unit = {} + + override def presentationCompilerThread: Option[Thread] = None +end Scala3CompilerWrapper diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/ScalaPresentationCompiler.scala b/presentation-compiler/src/main/scala/meta/internal/pc/ScalaPresentationCompiler.scala new file mode 100644 index 000000000000..ef91a612de33 --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/ScalaPresentationCompiler.scala @@ -0,0 +1,394 @@ +package scala.meta.internal.pc + +import java.io.File +import java.net.URI +import java.nio.file.Path +import java.util.Optional +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ExecutorService +import java.util.concurrent.ScheduledExecutorService +import java.{util as ju} + +import scala.collection.JavaConverters.* +import scala.concurrent.ExecutionContext +import scala.concurrent.ExecutionContextExecutor + +import scala.meta.internal.metals.EmptyCancelToken +import scala.meta.internal.metals.EmptyReportContext +import scala.meta.internal.metals.ReportContext +import scala.meta.internal.metals.ReportLevel +import scala.meta.internal.metals.StdReportContext +import scala.meta.internal.mtags.BuildInfo +import scala.meta.internal.pc.completions.CompletionProvider +import scala.meta.internal.pc.completions.OverrideCompletions +import scala.meta.pc.* + +import dotty.tools.dotc.reporting.StoreReporter +import org.eclipse.lsp4j.DocumentHighlight +import org.eclipse.lsp4j.TextEdit +import org.eclipse.{lsp4j as l} + +case class ScalaPresentationCompiler( + buildTargetIdentifier: String = "", + classpath: Seq[Path] = Nil, + options: List[String] = Nil, + search: SymbolSearch = EmptySymbolSearch, + ec: ExecutionContextExecutor = ExecutionContext.global, + sh: Option[ScheduledExecutorService] = None, + config: PresentationCompilerConfig = PresentationCompilerConfigImpl(), + folderPath: Option[Path] = None, + reportsLevel: ReportLevel = ReportLevel.Info, +) extends PresentationCompiler: + + def this() = this("", Nil, Nil) + + val scalaVersion = BuildInfo.scalaCompilerVersion + + private val forbiddenOptions = Set("-print-lines", "-print-tasty") + private val forbiddenDoubleOptions = Set("-release") + given ReportContext = + folderPath + .map(StdReportContext(_, reportsLevel)) + .getOrElse(EmptyReportContext) + + override def withReportsLoggerLevel(level: String): PresentationCompiler = + copy(reportsLevel = ReportLevel.fromString(level)) + + val compilerAccess: CompilerAccess[StoreReporter, MetalsDriver] = + Scala3CompilerAccess( + config, + sh, + () => new Scala3CompilerWrapper(newDriver), + )(using + ec + ) + + private def removeDoubleOptions(options: List[String]): List[String] = + options match + case head :: _ :: tail if forbiddenDoubleOptions(head) => + removeDoubleOptions(tail) + case head :: tail => head :: removeDoubleOptions(tail) + case Nil => options + + def newDriver: MetalsDriver = + val implicitSuggestionTimeout = List("-Ximport-suggestion-timeout", "0") + val defaultFlags = List("-color:never") + val filteredOptions = removeDoubleOptions( + options.filterNot(forbiddenOptions) + ) + val settings = + filteredOptions ::: defaultFlags ::: implicitSuggestionTimeout ::: "-classpath" :: classpath + .mkString( + File.pathSeparator + ) :: Nil + new MetalsDriver(settings) + + override def semanticTokens( + params: VirtualFileParams + ): CompletableFuture[ju.List[Node]] = + compilerAccess.withInterruptableCompiler( + new ju.ArrayList[Node](), + params.token(), + ) { access => + val driver = access.compiler() + new PcSemanticTokensProvider(driver, params).provide().asJava + } + + override def getTasty( + targetUri: URI, + isHttpEnabled: Boolean, + ): CompletableFuture[String] = + CompletableFuture.completedFuture { + TastyUtils.getTasty(targetUri, isHttpEnabled) + } + + def complete(params: OffsetParams): CompletableFuture[l.CompletionList] = + compilerAccess.withInterruptableCompiler( + EmptyCompletionList(), + params.token, + ) { access => + val driver = access.compiler() + new CompletionProvider( + search, + driver, + params, + config, + buildTargetIdentifier, + folderPath, + ).completions() + + } + + def definition(params: OffsetParams): CompletableFuture[DefinitionResult] = + compilerAccess.withNonInterruptableCompiler( + DefinitionResultImpl.empty, + params.token, + ) { access => + val driver = access.compiler() + PcDefinitionProvider(driver, params, search).definitions() + } + + override def typeDefinition( + params: OffsetParams + ): CompletableFuture[DefinitionResult] = + compilerAccess.withNonInterruptableCompiler( + DefinitionResultImpl.empty, + params.token, + ) { access => + val driver = access.compiler() + PcDefinitionProvider(driver, params, search).typeDefinitions() + } + + def documentHighlight( + params: OffsetParams + ): CompletableFuture[ju.List[DocumentHighlight]] = + compilerAccess.withNonInterruptableCompiler( + List.empty[DocumentHighlight].asJava, + params.token, + ) { access => + val driver = access.compiler() + PcDocumentHighlightProvider(driver, params).highlights.asJava + } + + def shutdown(): Unit = + compilerAccess.shutdown() + + def restart(): Unit = + compilerAccess.shutdownCurrentCompiler() + + def diagnosticsForDebuggingPurposes(): ju.List[String] = + List[String]().asJava + + def semanticdbTextDocument( + filename: URI, + code: String, + ): CompletableFuture[Array[Byte]] = + compilerAccess.withNonInterruptableCompiler( + Array.empty[Byte], + EmptyCancelToken, + ) { access => + val driver = access.compiler() + val provider = SemanticdbTextDocumentProvider(driver, folderPath) + provider.textDocument(filename, code) + } + + def completionItemResolve( + item: l.CompletionItem, + symbol: String, + ): CompletableFuture[l.CompletionItem] = + compilerAccess.withNonInterruptableCompiler( + item, + EmptyCancelToken, + ) { access => + val driver = access.compiler() + CompletionItemResolver.resolve(item, symbol, search, config)(using + driver.currentCtx + ) + } + + def autoImports( + name: String, + params: scala.meta.pc.OffsetParams, + isExtension: java.lang.Boolean, + ): CompletableFuture[ + ju.List[scala.meta.pc.AutoImportsResult] + ] = + compilerAccess.withNonInterruptableCompiler( + List.empty[scala.meta.pc.AutoImportsResult].asJava, + params.token, + ) { access => + val driver = access.compiler() + new AutoImportsProvider( + search, + driver, + name, + params, + config, + buildTargetIdentifier, + ) + .autoImports(isExtension) + .asJava + } + + def implementAbstractMembers( + params: OffsetParams + ): CompletableFuture[ju.List[l.TextEdit]] = + val empty: ju.List[l.TextEdit] = new ju.ArrayList[l.TextEdit]() + compilerAccess.withInterruptableCompiler(empty, params.token) { pc => + val driver = pc.compiler() + OverrideCompletions.implementAllAt( + params, + driver, + search, + config, + ) + } + end implementAbstractMembers + + override def insertInferredType( + params: OffsetParams + ): CompletableFuture[ju.List[l.TextEdit]] = + val empty: ju.List[l.TextEdit] = new ju.ArrayList[l.TextEdit]() + compilerAccess.withInterruptableCompiler(empty, params.token) { pc => + new InferredTypeProvider(params, pc.compiler(), config, search) + .inferredTypeEdits() + .asJava + } + + override def inlineValue( + params: OffsetParams + ): CompletableFuture[ju.List[l.TextEdit]] = + val empty: Either[String, List[l.TextEdit]] = Right(List()) + (compilerAccess + .withInterruptableCompiler(empty, params.token) { pc => + new PcInlineValueProviderImpl(pc.compiler(), params) + .getInlineTextEdits() + }) + .thenApply { + case Right(edits: List[TextEdit]) => edits.asJava + case Left(error: String) => throw new DisplayableException(error) + } + end inlineValue + + override def extractMethod( + range: RangeParams, + extractionPos: OffsetParams, + ): CompletableFuture[ju.List[l.TextEdit]] = + val empty: ju.List[l.TextEdit] = new ju.ArrayList[l.TextEdit]() + compilerAccess.withInterruptableCompiler(empty, range.token) { pc => + new ExtractMethodProvider( + range, + extractionPos, + pc.compiler(), + search, + options.contains("-no-indent"), + ) + .extractMethod() + .asJava + } + end extractMethod + + override def convertToNamedArguments( + params: OffsetParams, + argIndices: ju.List[Integer], + ): CompletableFuture[ju.List[l.TextEdit]] = + val empty: Either[String, List[l.TextEdit]] = Right(List()) + (compilerAccess + .withInterruptableCompiler(empty, params.token) { pc => + new ConvertToNamedArgumentsProvider( + pc.compiler(), + params, + argIndices.asScala.map(_.toInt).toSet, + ).convertToNamedArguments + }) + .thenApplyAsync { + case Left(error: String) => throw new DisplayableException(error) + case Right(edits: List[l.TextEdit]) => edits.asJava + } + end convertToNamedArguments + override def selectionRange( + params: ju.List[OffsetParams] + ): CompletableFuture[ju.List[l.SelectionRange]] = + CompletableFuture.completedFuture { + compilerAccess.withSharedCompiler(List.empty[l.SelectionRange].asJava) { + pc => + new SelectionRangeProvider( + pc.compiler(), + params, + ).selectionRange().asJava + } + } + end selectionRange + + def hover( + params: OffsetParams + ): CompletableFuture[ju.Optional[HoverSignature]] = + compilerAccess.withNonInterruptableCompiler( + ju.Optional.empty[HoverSignature](), + params.token, + ) { access => + val driver = access.compiler() + HoverProvider.hover(params, driver, search) + } + end hover + + def prepareRename( + params: OffsetParams + ): CompletableFuture[ju.Optional[l.Range]] = + compilerAccess.withNonInterruptableCompiler( + Optional.empty[l.Range](), + params.token, + ) { access => + val driver = access.compiler() + Optional.ofNullable( + PcRenameProvider(driver, params, None).prepareRename().orNull + ) + } + + def rename( + params: OffsetParams, + name: String, + ): CompletableFuture[ju.List[l.TextEdit]] = + compilerAccess.withNonInterruptableCompiler( + List[l.TextEdit]().asJava, + params.token, + ) { access => + val driver = access.compiler() + PcRenameProvider(driver, params, Some(name)).rename().asJava + } + + def newInstance( + buildTargetIdentifier: String, + classpath: ju.List[Path], + options: ju.List[String], + ): PresentationCompiler = + copy( + buildTargetIdentifier = buildTargetIdentifier, + classpath = classpath.asScala.toSeq, + options = options.asScala.toList, + ) + + def signatureHelp(params: OffsetParams): CompletableFuture[l.SignatureHelp] = + compilerAccess.withNonInterruptableCompiler( + new l.SignatureHelp(), + params.token, + ) { access => + val driver = access.compiler() + SignatureHelpProvider.signatureHelp(driver, params, search) + } + + override def didChange( + params: VirtualFileParams + ): CompletableFuture[ju.List[l.Diagnostic]] = + CompletableFuture.completedFuture(Nil.asJava) + + override def didClose(uri: URI): Unit = + compilerAccess.withNonInterruptableCompiler( + (), + EmptyCancelToken, + ) { access => access.compiler().close(uri) } + + override def withExecutorService( + executorService: ExecutorService + ): PresentationCompiler = + copy(ec = ExecutionContext.fromExecutorService(executorService)) + + override def withConfiguration( + config: PresentationCompilerConfig + ): PresentationCompiler = + copy(config = config) + + override def withScheduledExecutorService( + sh: ScheduledExecutorService + ): PresentationCompiler = + copy(sh = Some(sh)) + + def withSearch(search: SymbolSearch): PresentationCompiler = + copy(search = search) + + def withWorkspace(workspace: Path): PresentationCompiler = + copy(folderPath = Some(workspace)) + + override def isLoaded() = compilerAccess.isLoaded() + +end ScalaPresentationCompiler diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/SelectionRangeProvider.scala b/presentation-compiler/src/main/scala/meta/internal/pc/SelectionRangeProvider.scala new file mode 100644 index 000000000000..7260340dad00 --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/SelectionRangeProvider.scala @@ -0,0 +1,155 @@ +package scala.meta.internal.pc + +import java.nio.file.Paths +import java.{util as ju} + +import scala.collection.JavaConverters.* + +import scala.meta.inputs.Position +import scala.meta.internal.mtags.MtagsEnrichments.* +import scala.meta.internal.pc.SelectionRangeProvider.* +import scala.meta.pc.OffsetParams +import scala.meta.tokens.Token +import scala.meta.tokens.Token.Trivia + +import dotty.tools.dotc.core.Contexts.Context +import dotty.tools.dotc.interactive.Interactive +import dotty.tools.dotc.interactive.InteractiveDriver +import dotty.tools.dotc.semanticdb.Scala3 +import dotty.tools.dotc.util.SourceFile +import dotty.tools.dotc.util.SourcePosition +import org.eclipse.lsp4j +import org.eclipse.lsp4j.SelectionRange + +/** + * Provides the functionality necessary for the `textDocument/selectionRange` request. + * + * @param compiler Metals Global presentation compiler wrapper. + * @param params offset params converted from the selectionRange params. + */ +class SelectionRangeProvider( + driver: InteractiveDriver, + params: ju.List[OffsetParams], +): + + /** + * Get the seletion ranges for the provider params + * + * @return selection ranges + */ + def selectionRange(): List[SelectionRange] = + given ctx: Context = driver.currentCtx + + val selectionRanges = params.asScala.toList.map { param => + + val uri = param.uri + val filePath = Paths.get(uri) + val source = SourceFile.virtual(filePath.toString, param.text) + driver.run(uri, source) + val pos = driver.sourcePosition(param) + val path = + Interactive.pathTo(driver.openedTrees(uri), pos)(using ctx) + + val bareRanges = path + .map { tree => + val selectionRange = new SelectionRange() + selectionRange.setRange(tree.sourcePos.toLsp) + selectionRange + } + + // if cursor is in comment return range in comment4 + val commentRanges = getCommentRanges(pos, path, param.text()).map { x => + new SelectionRange(): + setRange(x) + }.toList + + (commentRanges ++ bareRanges) + .reduceRightOption(setParent) + .getOrElse(new SelectionRange()) + } + + selectionRanges + end selectionRange + + private def setParent( + child: SelectionRange, + parent: SelectionRange, + ): SelectionRange = + // If the parent and the child have the same exact range we just skip it. + // This happens in a lot of various places. For example: + // + // val total = for { + // a <- >>region>>Some(1)<, )), + // Apply( + // Select(Apply(Ident(Some), List(Literal(Constant(2)))), map), <-- Same as this range + // ... + // ) + // ) + // ) + // ) + if child.getRange() == parent.getRange() then parent + else + child.setParent(parent) + child + +end SelectionRangeProvider + +object SelectionRangeProvider: + + import scala.meta.dialects.Scala3 + import scala.meta.* + import scala.meta.Token.Comment + import dotty.tools.dotc.ast.tpd + + def commentRangesFromTokens( + tokenList: List[Token], + cursorStart: SourcePosition, + offsetStart: Int, + ) = + val cursorStartShifted = cursorStart.start - offsetStart + + tokenList + .collect { case x: Comment => + (x.start, x.end, x.pos) + } + .collect { + case (commentStart, commentEnd, _) + if commentStart <= cursorStartShifted && cursorStartShifted <= commentEnd => + cursorStart + .withStart(commentStart + offsetStart) + .withEnd(commentEnd + offsetStart) + .toLsp + + } + end commentRangesFromTokens + + /** get comments under cursor */ + def getCommentRanges( + cursor: SourcePosition, + path: List[tpd.Tree], + srcText: String, + )(using Context): List[lsp4j.Range] = + val (treeStart, treeEnd) = path.headOption + .map(t => (t.sourcePos.start, t.sourcePos.end)) + .getOrElse((0, srcText.size)) + + // only parse comments from first range to reduce computation + val srcSliced = srcText.slice(treeStart, treeEnd) + + val tokens = srcSliced.tokenize.toOption + if tokens.isEmpty then Nil + else + commentRangesFromTokens( + tokens.toList.flatten, + cursor, + treeStart, + ) + end getCommentRanges +end SelectionRangeProvider diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/SemanticdbSymbols.scala b/presentation-compiler/src/main/scala/meta/internal/pc/SemanticdbSymbols.scala new file mode 100644 index 000000000000..84ec07180317 --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/SemanticdbSymbols.scala @@ -0,0 +1,143 @@ +package scala.meta.internal.pc + +import scala.util.control.NonFatal + +import dotty.tools.dotc.core.Contexts.* +import dotty.tools.dotc.core.Flags.* +import dotty.tools.dotc.core.Names.* +import dotty.tools.dotc.core.Symbols.* + +object SemanticdbSymbols: + + def inverseSemanticdbSymbol(sym: String)(using ctx: Context): List[Symbol] = + import scala.meta.internal.semanticdb.Scala.* + + val defns = ctx.definitions + import defns.* + + def loop(s: String): List[Symbol] = + if s.isNone || s.isRootPackage then RootPackage :: Nil + else if s.isEmptyPackage then EmptyPackageVal :: Nil + else if s.isPackage then + try requiredPackage(s.stripSuffix("/").replace("/", ".")) :: Nil + catch + case NonFatal(_) => + Nil + else + val (desc, parent) = DescriptorParser(s) + val parentSymbol = loop(parent) + + def tryMember(sym: Symbol): List[Symbol] = + sym match + case NoSymbol => + Nil + case owner => + desc match + case Descriptor.None => + Nil + case Descriptor.Type(value) => + val typeSym = owner.info.decl(typeName(value)).symbol + // Semanticdb describes java static members as a reference from type + // while scalac puts static members into synthetic companion class - term + // To avoid issues with resolving static members return type and term in case of Java type + // Example: + // `java/nio/file/Files#exists()` - `exists` is a member of type `Files#` + // however in scalac this method is defined only in `module Files` + if typeSym.is(JavaDefined) then + typeSym :: owner.info.decl(termName(value)).symbol :: Nil + else typeSym :: Nil + case Descriptor.Term(value) => + owner.info.decl(termName(value)).symbol :: Nil + case Descriptor.Package(value) => + owner.info.decl(termName(value)).symbol :: Nil + case Descriptor.Parameter(value) => + // TODO - need to check how to implement this properly + // owner.paramSymss.flatten.filter(_.name.containsName(value)) + Nil + case Descriptor.TypeParameter(value) => + // TODO - need to check how to implement this properly + // owner.typeParams.filter(_.name.containsName(value)) + Nil + case Descriptor.Method(value, _) => + owner.info + .decl(termName(value)) + .alternatives + .iterator + .map(_.symbol) + .filter(sym => symbolName(sym) == s) + .toList + + parentSymbol.flatMap(tryMember) + try loop(sym).filterNot(_ == NoSymbol) + catch case NonFatal(e) => Nil + end inverseSemanticdbSymbol + + /** The semanticdb name of the given symbol */ + def symbolName(sym: Symbol)(using Context): String = + val b = StringBuilder(20) + addSymName(b, sym) + b.toString + + /** + * Taken from https://github.com/lampepfl/dotty/blob/2db43dae1480825227eb30d291b0dd0f0494e0f6/compiler/src/dotty/tools/dotc/semanticdb/ExtractSemanticDB.scala#L293 + * In future might be replaced by usage of compiler implementation after merging https://github.com/lampepfl/dotty/pull/12885 + */ + private def addSymName(b: StringBuilder, sym: Symbol)(using Context): Unit = + + import dotty.tools.dotc.semanticdb.Scala3.{*, given} + + def addName(name: Name) = + val str = name.toString.unescapeUnicode + if str.nonEmpty && str.isJavaIdent then b append str + else b append '`' append str append '`' + + def addOwner(owner: Symbol): Unit = + if !owner.isRoot then addSymName(b, owner) + + def addOverloadIdx(sym: Symbol): Unit = + val decls = + val decls0 = sym.owner.info.decls.lookupAll(sym.name) + if sym.owner.isAllOf(JavaModule) then + decls0 ++ sym.owner.companionClass.info.decls.lookupAll(sym.name) + else decls0 + end decls + // private java constructors do not have a symbol created + val alts = decls + .filter(d => d.isOneOf(Method | Mutable) && !d.is(Private)) + .toList + .reverse + def find(filter: Symbol => Boolean) = alts match + case notSym :: rest if !filter(notSym) => + val idx = rest.indexWhere(filter) + if idx >= 0 then b.append('+').append(idx + 1) + case _ => + end find + val sig = sym.signature + find(_.signature == sig) + end addOverloadIdx + + def addDescriptor(sym: Symbol): Unit = + if sym.is(ModuleClass) then addDescriptor(sym.sourceModule) + else if sym.is(TypeParam) then + b.append('['); addName(sym.name); b.append(']') + else if sym.is(Param) then + b.append('('); addName(sym.name); b.append(')') + else if sym.isRoot then b.append(Symbols.RootPackage) + else if sym.isEmptyPackage then b.append(Symbols.EmptyPackage) + else if sym.isScala2PackageObject then + b.append(Symbols.PackageObjectDescriptor) + else + addName(sym.name) + if sym.is(Package) then b.append('/') + else if sym.isType || sym.isAllOf(JavaModule) then b.append('#') + else if sym.isOneOf(Method | Mutable) + && (!sym.is(StableRealizable) || sym.isConstructor) + then + b.append('('); addOverloadIdx(sym); b.append(").") + else b.append('.') + + if !sym.isRoot && sym != NoSymbol then addOwner(sym.owner) + addDescriptor(sym) + end addSymName + +end SemanticdbSymbols diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/SemanticdbTextDocumentProvider.scala b/presentation-compiler/src/main/scala/meta/internal/pc/SemanticdbTextDocumentProvider.scala new file mode 100644 index 000000000000..5ef477332461 --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/SemanticdbTextDocumentProvider.scala @@ -0,0 +1,62 @@ +package scala.meta.internal.pc + +import java.io.ByteArrayOutputStream +import java.net.URI +import java.nio.file.Path +import java.nio.file.Paths + +import scala.meta.internal.mtags.MD5 + +import dotty.tools.dotc.interactive.InteractiveDriver +import dotty.tools.dotc.semanticdb.ExtractSemanticDB +import dotty.tools.dotc.semanticdb.Language +import dotty.tools.dotc.semanticdb.Schema +import dotty.tools.dotc.semanticdb.TextDocument +import dotty.tools.dotc.semanticdb.internal.SemanticdbOutputStream +import dotty.tools.dotc.util.SourceFile + +class SemanticdbTextDocumentProvider( + driver: InteractiveDriver, + workspace: Option[Path], +) extends WorksheetSemanticdbProvider: + + def textDocument( + uri: URI, + sourceCode: String, + ): Array[Byte] = + val filePath = Paths.get(uri) + val validCode = removeMagicImports(sourceCode, filePath) + driver.run( + uri, + SourceFile.virtual(filePath.toString, validCode), + ) + val tree = driver.currentCtx.run.units.head.tpdTree + val extract = ExtractSemanticDB() + val extractor = extract.Extractor() + extractor.traverse(tree)(using driver.currentCtx) + val path = workspace + .flatMap { workspacePath => + scala.util.Try(workspacePath.relativize(filePath)).toOption + } + .map { relativeUri => + relativeUri.toString() + } + .getOrElse(filePath.toString) + + val document = TextDocument( + schema = Schema.SEMANTICDB4, + language = Language.SCALA, + uri = path, + text = sourceCode, + md5 = MD5.compute(sourceCode), + symbols = extractor.symbolInfos.toList, + occurrences = extractor.occurrences.toList, + ) + val byteStream = new ByteArrayOutputStream() + val out = SemanticdbOutputStream.newInstance(byteStream) + document.writeTo(out) + out.flush() + byteStream.toByteArray + end textDocument + +end SemanticdbTextDocumentProvider diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/SignatureHelpProvider.scala b/presentation-compiler/src/main/scala/meta/internal/pc/SignatureHelpProvider.scala new file mode 100644 index 000000000000..5f552c1bd95d --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/SignatureHelpProvider.scala @@ -0,0 +1,193 @@ +package scala.meta.internal.pc + +import scala.collection.JavaConverters.* + +import scala.meta.internal.mtags.BuildInfo +import scala.meta.internal.mtags.MtagsEnrichments.* +import scala.meta.internal.semver.SemVer +import scala.meta.pc.OffsetParams +import scala.meta.pc.SymbolDocumentation +import scala.meta.pc.SymbolSearch + +import dotty.tools.dotc.ast.Trees.AppliedTypeTree +import dotty.tools.dotc.ast.Trees.TypeApply +import dotty.tools.dotc.ast.tpd +import dotty.tools.dotc.core.Contexts.* +import dotty.tools.dotc.core.Flags +import dotty.tools.dotc.core.Symbols.* +import dotty.tools.dotc.interactive.Interactive +import dotty.tools.dotc.interactive.InteractiveDriver +import dotty.tools.dotc.util.Signatures +import dotty.tools.dotc.util.Signatures.Signature +import dotty.tools.dotc.util.SourcePosition +import org.eclipse.{lsp4j as l} + +object SignatureHelpProvider: + + private val versionSupportsTypeParams = + SemVer.isCompatibleVersion( + "3.2.1-RC1-bin-20220628-65a86ae-NIGHTLY", + BuildInfo.scalaCompilerVersion, + ) + + def signatureHelp( + driver: InteractiveDriver, + params: OffsetParams, + search: SymbolSearch, + ) = + val uri = params.uri + val sourceFile = CompilerInterfaces.toSource(params.uri, params.text) + driver.run(uri, sourceFile) + + given ctx: Context = driver.currentCtx + + val pos = driver.sourcePosition(params) + val trees = driver.openedTrees(uri) + + val path = + Interactive.pathTo(trees, pos).dropWhile(t => notCurrentApply(t, pos)) + + val (paramN, callableN, alternativeSignatures) = + MetalsSignatures.signatures(path, pos) + + val signatureInfos = alternativeSignatures.map { case (signature, denot) => + search.symbolDocumentation(denot.symbol) match + case Some(doc) => + withDocumentation( + doc, + signature, + denot.symbol.is(Flags.JavaDefined), + ).getOrElse(signature) + case _ => signature + + } + + /* Versions prior to 3.2.1 did not support type parameters + * so we need to skip them. + */ + val adjustedParamN = + if versionSupportsTypeParams then paramN + else + val adjusted = + signatureInfos.lift(callableN).map(_.tparams.size).getOrElse(0) + paramN + adjusted + new l.SignatureHelp( + signatureInfos.map(signatureToSignatureInformation).asJava, + callableN, + adjustedParamN, + ) + end signatureHelp + + private def isValid(tree: tpd.Tree)(using Context): Boolean = + ctx.definitions.isTupleClass( + tree.symbol.owner.companionClass + ) || ctx.definitions.isFunctionType(tree.tpe) + + private def notCurrentApply( + tree: tpd.Tree, + pos: SourcePosition, + )(using Context): Boolean = + tree match + case unapply: tpd.UnApply => + unapply.fun.span.contains(pos.span) || isValid(unapply) + case typeTree @ AppliedTypeTree(fun, _) => + fun.span.contains(pos.span) || isValid(typeTree) + case typeApply @ TypeApply(fun, _) => + fun.span.contains(pos.span) || isValid(typeApply) + case appl: tpd.GenericApply => + /* find first apply that the cursor is located in arguments and not at function name + * for example in: + * `Option(1).fold(2)(@@_ + 1)` + * we want to find the tree responsible for the entire location, not just `_ + 1` + */ + appl.fun.span.contains(pos.span) + + case _ => true + + private def withDocumentation( + info: SymbolDocumentation, + signature: Signatures.Signature, + isJavaSymbol: Boolean, + ): Option[Signature] = + val allParams = info.parameters.asScala + def updateParams( + params: List[Signatures.Param], + index: Int, + ): List[Signatures.Param] = + params match + case Nil => Nil + case head :: tail => + val rest = updateParams(tail, index + 1) + allParams.lift(index) match + case Some(paramDoc) => + val newName = + if isJavaSymbol && head.name.startsWith("x$") then + paramDoc.displayName + else head.name + head.copy( + doc = Some(paramDoc.docstring), + name = newName, + ) :: rest + case _ => head :: rest + + def updateParamss( + params: List[List[Signatures.Param]], + index: Int, + ): List[List[Signatures.Param]] = + params match + case Nil => Nil + case head :: tail => + val updated = updateParams(head, index) + updated :: updateParamss(tail, index + head.size) + val updatedParams = updateParamss(signature.paramss, 0) + Some(signature.copy(doc = Some(info.docstring), paramss = updatedParams)) + end withDocumentation + + private def signatureToSignatureInformation( + signature: Signatures.Signature + ): l.SignatureInformation = + val tparams = signature.tparams.map(Signatures.Param("", _)) + val paramInfoss = + (tparams ::: signature.paramss.flatten).map(paramToParameterInformation) + val paramLists = + if signature.paramss.forall(_.isEmpty) && tparams.nonEmpty then "" + else + signature.paramss + .map { paramList => + val labels = paramList.map(_.show) + val prefix = if paramList.exists(_.isImplicit) then "using " else "" + labels.mkString(prefix, ", ", "") + } + .mkString("(", ")(", ")") + val tparamsLabel = + if signature.tparams.isEmpty then "" + else signature.tparams.mkString("[", ", ", "]") + val returnTypeLabel = signature.returnType.map(t => s": $t").getOrElse("") + val label = s"${signature.name}$tparamsLabel$paramLists$returnTypeLabel" + val documentation = signature.doc.map(markupContent) + val sig = new l.SignatureInformation(label) + sig.setParameters(paramInfoss.asJava) + documentation.foreach(sig.setDocumentation(_)) + sig + end signatureToSignatureInformation + + /** + * Convert `param` to `ParameterInformation` + */ + private def paramToParameterInformation( + param: Signatures.Param + ): l.ParameterInformation = + val documentation = param.doc.map(markupContent) + val info = new l.ParameterInformation(param.show) + documentation.foreach(info.setDocumentation(_)) + info + + private def markupContent(content: String): l.MarkupContent = + if content.isEmpty then null + else + val markup = new l.MarkupContent + markup.setKind("markdown") + markup.setValue(content.trim) + markup + +end SignatureHelpProvider diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/TastyUtils.scala b/presentation-compiler/src/main/scala/meta/internal/pc/TastyUtils.scala new file mode 100644 index 000000000000..f2b4b432ced6 --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/TastyUtils.scala @@ -0,0 +1,73 @@ +package scala.meta.internal.pc + +import java.net.URI + +import scala.meta.internal.io.PathIO +import scala.meta.internal.metals.HtmlBuilder +import scala.meta.io.AbsolutePath + +import dotty.tools.dotc.core.tasty.TastyHTMLPrinter +import dotty.tools.dotc.core.tasty.TastyPrinter + +object TastyUtils: + def getTasty( + tastyURI: URI, + isHttpEnabled: Boolean, + ): String = + if isHttpEnabled then getStandaloneHtmlTasty(tastyURI) + else normalTasty(tastyURI) + + def getStandaloneHtmlTasty(tastyURI: URI): String = + htmlTasty(tastyURI, List(standaloneHtmlStyles)) + + private def normalTasty(tastyURI: URI): String = + val tastyBytes = AbsolutePath.fromAbsoluteUri(tastyURI).readAllBytes + new TastyPrinter(tastyBytes).showContents() + + private def htmlTasty( + tastyURI: URI, + headElems: List[String] = Nil, + bodyAttributes: String = "", + ): String = + val title = tastyHtmlPageTitle(tastyURI) + val tastyBytes = AbsolutePath.fromAbsoluteUri(tastyURI).readAllBytes + val tastyHtml = new TastyHTMLPrinter(tastyBytes).showContents() + HtmlBuilder() + .page(title, htmlStyles :: headElems, bodyAttributes) { builder => + builder + .element("pre", "class='container is-dark'")(_.raw(tastyHtml)) + } + .render + end htmlTasty + + private def tastyHtmlPageTitle(file: URI) = + val filename = PathIO.basename(AbsolutePath.fromAbsoluteUri(file).toString) + s"TASTy for $filename" + + private val standaloneHtmlStyles = + """| + |""".stripMargin + + private val htmlStyles = + """| + |""".stripMargin + +end TastyUtils diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/WorksheetSemanticdbProvider.scala b/presentation-compiler/src/main/scala/meta/internal/pc/WorksheetSemanticdbProvider.scala new file mode 100644 index 000000000000..4dd3aba70427 --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/WorksheetSemanticdbProvider.scala @@ -0,0 +1,22 @@ +package scala.meta.internal.pc + +import java.nio.file.Path + +import scala.meta.internal.mtags.MtagsEnrichments.* + +trait WorksheetSemanticdbProvider: + + private val magicImportsRegex = + """import\s+(\$ivy|\$repo|\$dep|\$scalac)\..*""".r + + def removeMagicImports(code: String, filePath: Path): String = + val absoluteFilePath = filePath.toAbsolutePath() + if absoluteFilePath.toString.isWorksheet then + code.linesIterator + .map { + case magicImportsRegex(_) => "" + case other => other + } + .mkString("\n") + else code +end WorksheetSemanticdbProvider diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/completions/AmmoniteFileCompletions.scala b/presentation-compiler/src/main/scala/meta/internal/pc/completions/AmmoniteFileCompletions.scala new file mode 100644 index 000000000000..46e6850feeb0 --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/completions/AmmoniteFileCompletions.scala @@ -0,0 +1,104 @@ +package scala.meta.internal.pc +package completions + +import java.nio.file.Files +import java.nio.file.Path + +import scala.collection.JavaConverters.* + +import scala.meta.internal.mtags.MtagsEnrichments.* + +import dotty.tools.dotc.ast.tpd.Tree +import dotty.tools.dotc.ast.tpd.* +import dotty.tools.dotc.ast.untpd.ImportSelector +import dotty.tools.dotc.core.Contexts.Context +import dotty.tools.dotc.core.StdNames.* +import org.eclipse.{lsp4j as l} + +object AmmoniteFileCompletions: + + private def translateImportToPath(tree: Tree): String = + tree match + case Select(qual, name) => + val pathPart = name.toString() + translateImportToPath(qual) + "/" + { + if pathPart == "^" then ".." + else pathPart + } + case Ident(_) => + "" + case _ => "" + + def contribute( + select: Tree, + selector: List[ImportSelector], + posRange: l.Range, + rawPath: String, + workspace: Option[Path], + rawFileName: String, + )(using Context): List[CompletionValue] = + + val fileName = rawFileName + .split("/") + .last + .stripSuffix(".amm.sc.scala") + + val split = rawPath + .split("\\$file") + .toList + + val editRange = selector.headOption.map { sel => + if sel.sourcePos.span.isZeroExtent then posRange + else sel.imported.sourcePos.toLsp + } + val query = selector.collectFirst { case sel: ImportSelector => + if sel.name.isEmpty || sel.name == nme.ERROR then "" + else sel.name.toString.replace(Cursor.value, "") + } + + def parent = + val name = "^" + + CompletionValue.FileSystemMember( + name, + editRange, + isDirectory = true, + ) + + (split, workspace) match + case (_ :: script :: Nil, Some(workspace)) => + // drop / or \ + val current = workspace.resolve(script.drop(1)) + val importPath = translateImportToPath(select).drop(1) + val currentPath = current.getParent.resolve(importPath).toAbsolutePath + val parentTextEdit = + if query.exists(_.isEmpty()) && + Files.exists(currentPath.getParent) && Files.isDirectory( + currentPath + ) + then List(parent) + else Nil + Files + .list(currentPath) + .asScala + .toList + .filter(_.getFileName.toString.stripSuffix(".sc") != fileName) + .collect { + case file + if (Files.isDirectory( + file + ) || file.toAbsolutePath.toString.isAmmoniteScript) && + query.exists( + CompletionFuzzy.matches(_, file.getFileName.toString) + ) => + CompletionValue.FileSystemMember( + file.getFileName.toString, + editRange, + isDirectory = Files.isDirectory(file), + ) + } ++ parentTextEdit + case _ => + Nil + end match + end contribute +end AmmoniteFileCompletions diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/completions/AmmoniteIvyCompletions.scala b/presentation-compiler/src/main/scala/meta/internal/pc/completions/AmmoniteIvyCompletions.scala new file mode 100644 index 000000000000..a7c75f8a6ff0 --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/completions/AmmoniteIvyCompletions.scala @@ -0,0 +1,46 @@ +package scala.meta.internal.pc.completions + +import scala.meta.internal.mtags.CoursierComplete +import scala.meta.internal.mtags.MtagsEnrichments.* + +import dotty.tools.dotc.ast.tpd.* +import dotty.tools.dotc.ast.untpd.ImportSelector +import dotty.tools.dotc.core.Contexts.Context + +object AmmoniteIvyCompletions: + def contribute( + coursierComplete: CoursierComplete, + selector: List[ImportSelector], + completionPos: CompletionPos, + text: String, + )(using Context): List[CompletionValue] = + val pos = completionPos.sourcePos + val query = selector.collectFirst { + case sel: ImportSelector + if sel.sourcePos.encloses(pos) && sel.sourcePos.`end` > pos.`end` => + sel.name.decoded.replace(Cursor.value, "") + } + query match + case None => Nil + case Some(dependency) => + val isInitialCompletion = + pos.lineContent.trim == "import $ivy." + val ivyEditRange = + if isInitialCompletion then completionPos.toEditRange + else + // We need the text edit to span the whole group/artefact/version + val (rangeStart, rangeEnd) = + CoursierComplete.inferEditRange(pos.point, text) + pos.withStart(rangeStart).withEnd(rangeEnd).toLsp + val completions = coursierComplete.complete(dependency) + completions + .map(insertText => + CompletionValue.IvyImport( + insertText.stripPrefix(":"), + Some(insertText), + Some(ivyEditRange), + ) + ) + end match + end contribute +end AmmoniteIvyCompletions diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/completions/CompletionPos.scala b/presentation-compiler/src/main/scala/meta/internal/pc/completions/CompletionPos.scala new file mode 100644 index 000000000000..bb9faa370903 --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/completions/CompletionPos.scala @@ -0,0 +1,138 @@ +package scala.meta.internal.pc +package completions + +import java.net.URI + +import scala.meta.internal.mtags.MtagsEnrichments.* +import scala.meta.internal.tokenizers.Chars +import scala.meta.pc.OffsetParams + +import dotty.tools.dotc.ast.tpd.* +import dotty.tools.dotc.ast.untpd.ImportSelector +import dotty.tools.dotc.core.Contexts.* +import dotty.tools.dotc.core.StdNames.* +import dotty.tools.dotc.util.SourcePosition +import dotty.tools.dotc.util.Spans +import org.eclipse.{lsp4j as l} + +enum CompletionKind: + case Empty, Scope, Members + +case class CompletionPos( + kind: CompletionKind, + start: Int, + end: Int, + query: String, + cursorPos: SourcePosition, + sourceUri: URI, +): + + def sourcePos: SourcePosition = cursorPos.withSpan(Spans.Span(start, end)) + + def toEditRange: l.Range = + new l.Range(cursorPos.offsetToPos(start), cursorPos.offsetToPos(end)) + end toEditRange +end CompletionPos + +object CompletionPos: + + def infer( + cursorPos: SourcePosition, + offsetParams: OffsetParams, + treePath: List[Tree], + )(using Context): CompletionPos = + infer(cursorPos, offsetParams.uri, offsetParams.text, treePath) + + def infer( + cursorPos: SourcePosition, + uri: URI, + text: String, + treePath: List[Tree], + )(using Context): CompletionPos = + val start = inferIdentStart(cursorPos, text, treePath) + val end = inferIdentEnd(cursorPos, text) + val query = text.substring(start, end) + val prevIsDot = + if start - 1 >= 0 then text.charAt(start - 1) == '.' else false + val kind = + if query.isEmpty && !prevIsDot then CompletionKind.Empty + else if prevIsDot then CompletionKind.Members + else CompletionKind.Scope + + CompletionPos( + kind, + start, + end, + query, + cursorPos, + uri, + ) + end infer + + /** + * Infer the indentation by counting the number of spaces in the given line. + * + * @param lineOffset the offset position of the beginning of the line + */ + private[completions] def inferIndent( + lineOffset: Int, + text: String, + ): (Int, Boolean) = + var i = 0 + var tabIndented = false + while lineOffset + i < text.length && { + val char = text.charAt(lineOffset + i) + if char == '\t' then + tabIndented = true + true + else char == ' ' + } + do i += 1 + (i, tabIndented) + end inferIndent + + /** + * Returns the start offset of the identifier starting as the given offset position. + */ + private def inferIdentStart( + pos: SourcePosition, + text: String, + path: List[Tree], + )(using Context): Int = + def fallback: Int = + var i = pos.point - 1 + while i >= 0 && Chars.isIdentifierPart(text.charAt(i)) do i -= 1 + i + 1 + def loop(enclosing: List[Tree]): Int = + enclosing match + case Nil => fallback + case head :: tl => + if !head.sourcePos.contains(pos) then loop(tl) + else + head match + case i: Ident => i.sourcePos.point + case s: Select => + if s.name.toTermName == nme.ERROR || s.span.exists && pos.span.point < s.span.point + then fallback + else s.span.point + case Import(_, sel) => + sel + .collectFirst { + case ImportSelector(imported, renamed, _) + if imported.sourcePos.contains(pos) => + imported.sourcePos.point + } + .getOrElse(fallback) + case _ => fallback + loop(path) + end inferIdentStart + + /** + * Returns the end offset of the identifier starting as the given offset position. + */ + private def inferIdentEnd(pos: SourcePosition, text: String): Int = + var i = pos.point + while i < text.length && Chars.isIdentifierPart(text.charAt(i)) do i += 1 + i + +end CompletionPos diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/completions/CompletionProvider.scala b/presentation-compiler/src/main/scala/meta/internal/pc/completions/CompletionProvider.scala new file mode 100644 index 000000000000..023e0f610467 --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/completions/CompletionProvider.scala @@ -0,0 +1,278 @@ +package scala.meta.internal.pc +package completions + +import java.nio.file.Path + +import scala.collection.JavaConverters.* + +import scala.meta.internal.metals.ReportContext +import scala.meta.internal.mtags.MtagsEnrichments.* +import scala.meta.internal.pc.AutoImports.AutoImportEdits +import scala.meta.internal.pc.AutoImports.AutoImportsGenerator +import scala.meta.internal.pc.printer.MetalsPrinter +import scala.meta.pc.OffsetParams +import scala.meta.pc.PresentationCompilerConfig +import scala.meta.pc.SymbolSearch + +import dotty.tools.dotc.ast.tpd +import dotty.tools.dotc.ast.tpd.* +import dotty.tools.dotc.core.Constants.Constant +import dotty.tools.dotc.core.Contexts.Context +import dotty.tools.dotc.core.StdNames +import dotty.tools.dotc.interactive.Interactive +import dotty.tools.dotc.interactive.InteractiveDriver +import org.eclipse.lsp4j.Command +import org.eclipse.lsp4j.CompletionItem +import org.eclipse.lsp4j.CompletionItemKind +import org.eclipse.lsp4j.CompletionList +import org.eclipse.lsp4j.InsertTextFormat +import org.eclipse.lsp4j.InsertTextMode +import org.eclipse.lsp4j.TextEdit +import org.eclipse.lsp4j.Range as LspRange + +class CompletionProvider( + search: SymbolSearch, + driver: InteractiveDriver, + params: OffsetParams, + config: PresentationCompilerConfig, + buildTargetIdentifier: String, + folderPath: Option[Path], +)(using reports: ReportContext): + def completions(): CompletionList = + val uri = params.uri + + val code = applyCompletionCursor(params) + val sourceFile = CompilerInterfaces.toSource(params.uri, code) + driver.run(uri, sourceFile) + + val ctx = driver.currentCtx + val pos = driver.sourcePosition(params) + val (items, isIncomplete) = driver.compilationUnits.get(uri) match + case Some(unit) => + val path = + Interactive.pathTo(driver.openedTrees(uri), pos)(using ctx) + + val newctx = ctx.fresh.setCompilationUnit(unit) + val tpdPath = + Interactive.pathTo(newctx.compilationUnit.tpdTree, pos.span)(using + newctx + ) + val locatedCtx = + MetalsInteractive.contextOfPath(tpdPath)(using newctx) + val indexedCtx = IndexedContext(locatedCtx) + val completionPos = + CompletionPos.infer(pos, params, path)(using newctx) + val autoImportsGen = AutoImports.generator( + completionPos.sourcePos, + params.text, + unit.tpdTree, + indexedCtx, + config, + ) + val (completions, searchResult) = + new Completions( + pos, + params.text, + ctx.fresh.setCompilationUnit(unit), + search, + buildTargetIdentifier, + completionPos, + indexedCtx, + path, + config, + folderPath, + autoImportsGen, + driver.settings, + ).completions() + + val items = completions.zipWithIndex.map { case (item, idx) => + completionItems( + item, + idx, + autoImportsGen, + completionPos, + path, + indexedCtx, + )(using newctx) + } + val isIncomplete = searchResult match + case SymbolSearch.Result.COMPLETE => false + case SymbolSearch.Result.INCOMPLETE => true + (items, isIncomplete) + case None => (Nil, false) + + new CompletionList( + isIncomplete, + items.asJava, + ) + end completions + + /** + * In case if completion comes from empty line like: + * {{{ + * class Foo: + * val a = 1 + * @@ + * }}} + * it's required to modify actual code by addition Ident. + * + * Otherwise, completion poisition doesn't point at any tree + * because scala parser trim end position to the last statement pos. + */ + private def applyCompletionCursor(params: OffsetParams): String = + import params.* + val isStartMultilineComment = + val i = params.offset() + i >= 3 && (params.text().charAt(i - 1) match + case '*' => + params.text().charAt(i - 2) == '*' && + params.text().charAt(i - 3) == '/' + case _ => false + ) + if isStartMultilineComment then + // Insert potentially missing `*/` to avoid comment out all codes after the "/**". + text.substring(0, offset) + Cursor.value + "*/" + text.substring(offset) + else + text.substring(0, offset) + Cursor.value + text.substring( + offset + ) + end applyCompletionCursor + + private def completionItems( + completion: CompletionValue, + idx: Int, + autoImports: AutoImportsGenerator, + completionPos: CompletionPos, + path: List[Tree], + indexedContext: IndexedContext, + )(using ctx: Context): CompletionItem = + val printer = MetalsPrinter.standard( + indexedContext, + search, + includeDefaultParam = MetalsPrinter.IncludeDefaultParam.ResolveLater, + ) + val editRange = completionPos.toEditRange + + // For overloaded signatures we get multiple symbols, so we need + // to recalculate the description + // related issue https://github.com/lampepfl/dotty/issues/11941 + lazy val kind: CompletionItemKind = completion.completionItemKind + val description = completion.description(printer) + val label = completion.labelWithDescription(printer) + val ident = completion.insertText.getOrElse(completion.label) + + def mkItem( + insertText: String, + additionalEdits: List[TextEdit] = Nil, + range: Option[LspRange] = None, + ): CompletionItem = + val nameEdit = new TextEdit( + range.getOrElse(editRange), + insertText, + ) + val item = new CompletionItem(label) + item.setSortText(f"${idx}%05d") + item.setDetail(description) + item.setFilterText( + completion.filterText.getOrElse(completion.label) + ) + item.setTextEdit(nameEdit) + item.setAdditionalTextEdits( + (completion.additionalEdits ++ additionalEdits).asJava + ) + completion.insertMode.foreach(item.setInsertTextMode) + + completion + .completionData(buildTargetIdentifier) + .foreach(data => item.setData(data.toJson)) + + item.setTags(completion.lspTags.asJava) + + if config.isCompletionSnippetsEnabled then + item.setInsertTextFormat(InsertTextFormat.Snippet) + + completion.command.foreach { command => + item.setCommand(new Command("", command)) + } + + item.setKind(kind) + item + end mkItem + + val completionTextSuffix = completion.snippetSuffix.toEdit + + lazy val isInStringInterpolation = + path match + // s"My name is $name" + case (_: Ident) :: (_: SeqLiteral) :: (_: Typed) :: Apply( + Select(Apply(Select(Select(_, name), _), _), _), + _, + ) :: _ => + name == StdNames.nme.StringContext + // "My name is $name" + case Literal(Constant(_: String)) :: _ => + true + case _ => + false + + def mkItemWithImports( + v: CompletionValue.Workspace | CompletionValue.Extension | + CompletionValue.Interpolator + ) = + val sym = v.symbol + path match + case (_: Ident) :: (_: Import) :: _ => + mkItem(sym.fullNameBackticked) + case _ => + autoImports.editsForSymbol(v.importSymbol) match + case Some(edits) => + edits match + case AutoImportEdits(Some(nameEdit), other) => + mkItem( + nameEdit.getNewText(), + other.toList, + range = Some(nameEdit.getRange()), + ) + case _ => + mkItem( + v.insertText.getOrElse( + ident.backticked + completionTextSuffix + ), + edits.edits, + range = v.range, + ) + case None => + val r = indexedContext.lookupSym(sym) + r match + case IndexedContext.Result.InScope => + mkItem( + ident.backticked + completionTextSuffix + ) + case _ if isInStringInterpolation => + mkItem( + "{" + sym.fullNameBackticked + completionTextSuffix + "}" + ) + case _ => + mkItem(sym.fullNameBackticked + completionTextSuffix) + end match + end match + end match + end mkItemWithImports + + completion match + case v: (CompletionValue.Workspace | CompletionValue.Extension) => + mkItemWithImports(v) + case v: CompletionValue.Interpolator if v.isWorkspace || v.isExtension => + mkItemWithImports(v) + case _ => + val insert = completion.insertText.getOrElse(ident.backticked) + mkItem( + insert + completionTextSuffix, + range = completion.range, + ) + end match + end completionItems +end CompletionProvider + +case object Cursor: + val value = "CURSOR" diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/completions/CompletionSuffix.scala b/presentation-compiler/src/main/scala/meta/internal/pc/completions/CompletionSuffix.scala new file mode 100644 index 000000000000..02376efb2160 --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/completions/CompletionSuffix.scala @@ -0,0 +1,48 @@ +package scala.meta.internal.pc.completions + +/** + * @param brace should we add "()" suffix? + * @param bracket should we add "[]" suffix? + * @param template should we add "{}" suffix? + * @param snippet which suffix should we insert the snippet $0 + */ +case class CompletionSuffix( + brace: Boolean, + bracket: Boolean, + template: Boolean, + snippet: SuffixKind, +): + def hasSnippet = snippet != SuffixKind.NoSuffix + def chain(copyFn: CompletionSuffix => CompletionSuffix) = copyFn(this) + def toEdit: String = + if !hasSuffix then "" + else + val braceSuffix = + if brace && snippet == SuffixKind.Brace then "($0)" + else if brace then "()" + else "" + val bracketSuffix = + if bracket && snippet == SuffixKind.Bracket then "[$0]" + else if bracket then "[]" + else "" + val templateSuffix = + if template && snippet == SuffixKind.Template then " {$0}" + else if template then " {}" + else "" + s"$bracketSuffix$braceSuffix$templateSuffix" + def toEditOpt: Option[String] = + val edit = toEdit + if edit.nonEmpty then Some(edit) else None + private def hasSuffix = brace || bracket || template +end CompletionSuffix + +object CompletionSuffix: + val empty = CompletionSuffix( + brace = false, + bracket = false, + template = false, + snippet = SuffixKind.NoSuffix, + ) + +enum SuffixKind: + case Brace, Bracket, Template, NoSuffix diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/completions/CompletionValue.scala b/presentation-compiler/src/main/scala/meta/internal/pc/completions/CompletionValue.scala new file mode 100644 index 000000000000..7a1c59f3022a --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/completions/CompletionValue.scala @@ -0,0 +1,261 @@ +package scala.meta.internal.pc +package completions + +import scala.meta.internal.pc.printer.MetalsPrinter + +import dotty.tools.dotc.core.Contexts.Context +import dotty.tools.dotc.core.Flags.* +import dotty.tools.dotc.core.Symbols.Symbol +import dotty.tools.dotc.core.Types.Type +import dotty.tools.dotc.transform.SymUtils.* +import org.eclipse.lsp4j.CompletionItemKind +import org.eclipse.lsp4j.CompletionItemTag +import org.eclipse.lsp4j.InsertTextMode +import org.eclipse.lsp4j.Range +import org.eclipse.lsp4j.TextEdit + +sealed trait CompletionValue: + def label: String + def insertText: Option[String] = None + def snippetSuffix: CompletionSuffix = CompletionSuffix.empty + def additionalEdits: List[TextEdit] = Nil + def range: Option[Range] = None + def filterText: Option[String] = None + def completionItemKind(using Context): CompletionItemKind + def description(printer: MetalsPrinter)(using Context): String = "" + def insertMode: Option[InsertTextMode] = None + def completionData(buildTargetIdentifier: String)(using + Context + ): Option[CompletionItemData] = None + def command: Option[String] = None + + /** + * Label with potentially attached description. + */ + def labelWithDescription(printer: MetalsPrinter)(using Context): String = + label + def lspTags(using Context): List[CompletionItemTag] = Nil +end CompletionValue + +object CompletionValue: + + sealed trait Symbolic extends CompletionValue: + def symbol: Symbol + def isFromWorkspace: Boolean = false + def completionItemDataKind = CompletionItemData.None + + override def completionData( + buildTargetIdentifier: String + )(using Context): Option[CompletionItemData] = + Some( + CompletionItemData( + SemanticdbSymbols.symbolName(symbol), + buildTargetIdentifier, + kind = completionItemDataKind, + ) + ) + def importSymbol: Symbol = symbol + + def completionItemKind(using Context): CompletionItemKind = + val symbol = this.symbol + if symbol.is(Package) || symbol.is(Module) then + // No CompletionItemKind.Package (https://github.com/Microsoft/language-server-protocol/issues/155) + CompletionItemKind.Module + else if symbol.isConstructor then CompletionItemKind.Constructor + else if symbol.isClass then CompletionItemKind.Class + else if symbol.is(Mutable) then CompletionItemKind.Variable + else if symbol.is(Method) then CompletionItemKind.Method + else CompletionItemKind.Field + + override def lspTags(using Context): List[CompletionItemTag] = + if symbol.isDeprecated then List(CompletionItemTag.Deprecated) else Nil + + override def labelWithDescription( + printer: MetalsPrinter + )(using Context): String = + if symbol.is(Method) then s"${label}${description(printer)}" + else if symbol.isConstructor then label + else if symbol.is(Mutable) then s"${label}: ${description(printer)}" + else if symbol.is(Package) || symbol.is(Module) || symbol.isClass then + if isFromWorkspace then s"${label} -${description(printer)}" + else s"${label}${description(printer)}" + else s"${label}: ${description(printer)}" + + override def description(printer: MetalsPrinter)(using Context): String = + printer.completionSymbol(symbol) + end Symbolic + + case class Compiler( + label: String, + symbol: Symbol, + override val snippetSuffix: CompletionSuffix, + ) extends Symbolic + case class Scope(label: String, symbol: Symbol) extends Symbolic + case class Workspace( + label: String, + symbol: Symbol, + override val snippetSuffix: CompletionSuffix, + override val importSymbol: Symbol, + ) extends Symbolic: + override def isFromWorkspace: Boolean = true + + /** + * CompletionValue for extension methods via SymbolSearch + */ + case class Extension( + label: String, + symbol: Symbol, + override val snippetSuffix: CompletionSuffix, + ) extends Symbolic: + override def completionItemKind(using Context): CompletionItemKind = + CompletionItemKind.Method + override def description(printer: MetalsPrinter)(using Context): String = + s"${printer.completionSymbol(symbol)} (extension)" + + /** + * @param shortenedNames shortened type names by `Printer`. This field should be used for autoImports + * @param start Starting position of the completion + * this is needed, because for OverrideCompletion, completionPos + * doesn't capture the "correct" starting position. For example, + * when we type `override def fo@@` (where `@@` we invoke completion) + * `completionPos` is `fo`, instead of `override def fo`. + */ + case class Override( + label: String, + value: String, + symbol: Symbol, + override val additionalEdits: List[TextEdit], + override val filterText: Option[String], + override val range: Option[Range], + ) extends Symbolic: + override def insertText: Option[String] = Some(value) + override def completionItemDataKind: Integer = + CompletionItemData.OverrideKind + override def completionItemKind(using Context): CompletionItemKind = + CompletionItemKind.Method + override def labelWithDescription(printer: MetalsPrinter)(using + Context + ): String = label + end Override + + case class NamedArg( + label: String, + tpe: Type, + symbol: Symbol, + ) extends Symbolic: + override def insertText: Option[String] = Some(label.replace("$", "$$")) + override def completionItemKind(using Context): CompletionItemKind = + CompletionItemKind.Field + override def description(printer: MetalsPrinter)(using Context): String = + ": " + printer.tpe(tpe) + + override def labelWithDescription(printer: MetalsPrinter)(using + Context + ): String = label + end NamedArg + + case class Autofill( + value: String + ) extends CompletionValue: + override def completionItemKind(using Context): CompletionItemKind = + CompletionItemKind.Enum + override def insertText: Option[String] = Some(value) + override def label: String = "Autofill with default values" + + case class Keyword(label: String, override val insertText: Option[String]) + extends CompletionValue: + override def completionItemKind(using Context): CompletionItemKind = + CompletionItemKind.Keyword + + case class FileSystemMember( + filename: String, + override val range: Option[Range], + isDirectory: Boolean, + ) extends CompletionValue: + override def label: String = filename + override def insertText: Option[String] = Some(filename.stripSuffix(".sc")) + override def completionItemKind(using Context): CompletionItemKind = + CompletionItemKind.File + + case class IvyImport( + label: String, + override val insertText: Option[String], + override val range: Option[Range], + ) extends CompletionValue: + override val filterText: Option[String] = insertText + override def completionItemKind(using Context): CompletionItemKind = + CompletionItemKind.Folder + + case class Interpolator( + symbol: Symbol, + label: String, + override val insertText: Option[String], + override val additionalEdits: List[TextEdit], + override val range: Option[Range], + override val filterText: Option[String], + override val importSymbol: Symbol, + isWorkspace: Boolean = false, + isExtension: Boolean = false, + ) extends Symbolic: + override def description(printer: MetalsPrinter)(using Context): String = + if isExtension then s"${printer.completionSymbol(symbol)} (extension)" + else super.description(printer) + end Interpolator + + case class MatchCompletion( + label: String, + override val insertText: Option[String], + override val additionalEdits: List[TextEdit], + desc: String, + ) extends CompletionValue: + override def completionItemKind(using Context): CompletionItemKind = + CompletionItemKind.Enum + override def description(printer: MetalsPrinter)(using Context): String = + desc + + case class CaseKeyword( + symbol: Symbol, + label: String, + override val insertText: Option[String], + override val additionalEdits: List[TextEdit], + override val range: Option[Range] = None, + override val command: Option[String] = None, + ) extends Symbolic: + override def completionItemKind(using Context): CompletionItemKind = + CompletionItemKind.Method + + override def labelWithDescription(printer: MetalsPrinter)(using + Context + ): String = label + end CaseKeyword + + case class Document(label: String, doc: String, description: String) + extends CompletionValue: + override def filterText: Option[String] = Some(description) + + override def insertText: Option[String] = Some(doc) + override def completionItemKind(using Context): CompletionItemKind = + CompletionItemKind.Snippet + + override def description(printer: MetalsPrinter)(using Context): String = + description + override def insertMode: Option[InsertTextMode] = Some(InsertTextMode.AsIs) + + def namedArg(label: String, sym: Symbol)(using + Context + ): CompletionValue = + NamedArg(label, sym.info.widenTermRefExpr, sym) + + def keyword(label: String, insertText: String): CompletionValue = + Keyword(label, Some(insertText)) + + def document( + label: String, + insertText: String, + description: String, + ): CompletionValue = + Document(label, insertText, description) + + def scope(label: String, sym: Symbol): CompletionValue = + Scope(label, sym) +end CompletionValue diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/completions/Completions.scala b/presentation-compiler/src/main/scala/meta/internal/pc/completions/Completions.scala new file mode 100644 index 000000000000..af777387afe1 --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/completions/Completions.scala @@ -0,0 +1,957 @@ +package scala.meta.internal.pc +package completions + +import java.nio.file.Path +import java.nio.file.Paths + +import scala.collection.mutable + +import scala.meta.internal.metals.Fuzzy +import scala.meta.internal.metals.ReportContext +import scala.meta.internal.mtags.BuildInfo +import scala.meta.internal.mtags.CoursierComplete +import scala.meta.internal.mtags.MtagsEnrichments.* +import scala.meta.internal.pc.AutoImports.AutoImportsGenerator +import scala.meta.internal.pc.completions.OverrideCompletions.OverrideExtractor +import scala.meta.internal.semver.SemVer +import scala.meta.pc.* + +import dotty.tools.dotc.ast.tpd.* +import dotty.tools.dotc.core.Constants.Constant +import dotty.tools.dotc.core.Contexts.* +import dotty.tools.dotc.core.Flags +import dotty.tools.dotc.core.Flags.* +import dotty.tools.dotc.core.NameOps.* +import dotty.tools.dotc.core.Names.* +import dotty.tools.dotc.core.StdNames +import dotty.tools.dotc.core.StdNames.* +import dotty.tools.dotc.core.Symbols.* +import dotty.tools.dotc.core.Types.* +import dotty.tools.dotc.interactive.Completion +import dotty.tools.dotc.transform.SymUtils.* +import dotty.tools.dotc.util.SourcePosition +import dotty.tools.dotc.util.Spans +import dotty.tools.dotc.util.Spans.Span +import dotty.tools.dotc.util.SrcPos + +class Completions( + pos: SourcePosition, + text: String, + ctx: Context, + search: SymbolSearch, + buildTargetIdentifier: String, + completionPos: CompletionPos, + indexedContext: IndexedContext, + path: List[Tree], + config: PresentationCompilerConfig, + workspace: Option[Path], + autoImports: AutoImportsGenerator, + options: List[String], +)(using ReportContext): + + implicit val context: Context = ctx + + val coursierComplete = new CoursierComplete(BuildInfo.scalaCompilerVersion) + + // versions prior to 3.1.0 sometimes didn't manage to detect properly Java objects + val canDetectJavaObjectsCorrectly = + SemVer.isLaterVersion("3.1.0", BuildInfo.scalaCompilerVersion) + + private lazy val shouldAddSnippet = + path match + /* In case of `method@@()` we should not add snippets and the path + * will contain apply as the parent of the current tree. + */ + case (fun) :: (appl: GenericApply) :: _ if appl.fun == fun => + false + case _ :: (withcursor @ Select(fun, name)) :: (appl: GenericApply) :: _ + if appl.fun == withcursor && name.decoded == Cursor.value => + false + case (_: Import) :: _ => false + case _ :: (_: Import) :: _ => false + case (_: Ident) :: (_: SeqLiteral) :: _ => false + case _ => true + + enum CursorPos: + case Type(hasTypeParams: Boolean, hasNewKw: Boolean) + case Term + case Import + + def include(sym: Symbol)(using Context): Boolean = + val generalExclude = + isUninterestingSymbol(sym) || + !isNotLocalForwardReference(sym) || + sym.isPackageObject + + def isWildcardParam(sym: Symbol) = + if sym.isTerm && sym.owner.isAnonymousFunction then + sym.name match + case DerivedName(under, _) => + under.isEmpty + case _ => false + else false + + if generalExclude then false + else + this match + case Type(_, _) if sym.isType => true + case Type(_, _) if sym.isTerm => + /* Type might be referenced by a path over an object: + * ``` + * object sample: + * class X + * val a: samp@@le.X = ??? + * ``` + * ignore objects that has companion class + * + * Also ignore objects that doesn't have any members. + * By some reason traits might have a fake companion object(example: scala.sys.process.ProcessBuilderImpl) + */ + val allowModule = + sym.is(Module) && + (sym.companionClass == NoSymbol && sym.info.allMembers.nonEmpty) + allowModule + case Term if isWildcardParam(sym) => false + case Term if sym.isTerm || sym.is(Package) => true + case Import => true + case _ => false + end if + end include + + def allowBracketSuffix: Boolean = + this match + case Type(hasTypeParams, _) => !hasTypeParams + case _ => false + + def allowTemplateSuffix: Boolean = + this match + case Type(_, hasNewKw) => hasNewKw + case _ => false + + end CursorPos + + private lazy val cursorPos = + calculateTypeInstanceAndNewPositions(path) + + private def calculateTypeInstanceAndNewPositions( + path: List[Tree] + ): CursorPos = + path match + case (_: Import) :: _ => CursorPos.Import + case _ :: (_: Import) :: _ => CursorPos.Import + case (head: (Select | Ident)) :: tail => + // https://github.com/lampepfl/dotty/issues/15750 + // due to this issue in dotty, because of which trees after typer lose information, + // we have to calculate hasNoSquareBracket manually: + val hasSquareBracket = + val span: Span = head.srcPos.span + if span.exists then + var i = span.end + while i < (text.length() - 1) && text(i).isWhitespace do i = i + 1 + + if i < text.length() then text(i) == '[' + else false + else false + + def typePos = CursorPos.Type(hasSquareBracket, hasNewKw = false) + def newTypePos = + CursorPos.Type(hasSquareBracket, hasNewKw = true) + + tail match + case (v: ValOrDefDef) :: _ if v.tpt.sourcePos.contains(pos) => + typePos + case New(selectOrIdent: (Select | Ident)) :: _ + if selectOrIdent.sourcePos.contains(pos) => + newTypePos + case (a @ AppliedTypeTree(_, args)) :: _ + if args.exists(_.sourcePos.contains(pos)) => + typePos + case (templ @ Template(constr, _, self, _)) :: _ + if (constr :: self :: templ.parents).exists( + _.sourcePos.contains(pos) + ) => + typePos + case _ => + CursorPos.Term + end match + + case (_: TypeTree) :: TypeApply(Select(newQualifier: New, _), _) :: _ + if newQualifier.sourcePos.contains(pos) => + CursorPos.Type(hasTypeParams = false, hasNewKw = true) + + case _ => CursorPos.Term + end match + end calculateTypeInstanceAndNewPositions + + def completions(): (List[CompletionValue], SymbolSearch.Result) = + val (advanced, exclusive) = advancedCompletions(path, pos, completionPos) + val (all, result) = + if exclusive then (advanced, SymbolSearch.Result.COMPLETE) + else + val keywords = KeywordsCompletions.contribute(path, completionPos) + val allAdvanced = advanced ++ keywords + path match + // should not show completions for toplevel + case Nil if pos.source.file.extension != "sc" => + (allAdvanced, SymbolSearch.Result.COMPLETE) + case Select(qual, _) :: _ if qual.tpe.isErroneous => + (allAdvanced, SymbolSearch.Result.COMPLETE) + case Select(qual, _) :: _ => + val (_, compilerCompletions) = Completion.completions(pos) + val (compiler, result) = compilerCompletions + .flatMap(toCompletionValues) + .filterInteresting(qual.typeOpt.widenDealias) + (allAdvanced ++ compiler, result) + case _ => + val (_, compilerCompletions) = Completion.completions(pos) + val (compiler, result) = compilerCompletions + .flatMap(toCompletionValues) + .filterInteresting() + (allAdvanced ++ compiler, result) + end match + + val application = CompletionApplication.fromPath(path) + val ordering = completionOrdering(application) + val values = application.postProcess(all.sorted(ordering)) + (values, result) + end completions + + private def toCompletionValues( + completion: Completion + ): List[CompletionValue] = + completion.symbols.flatMap( + completionsWithSuffix( + _, + completion.label, + CompletionValue.Compiler(_, _, _), + ) + ) + end toCompletionValues + + inline private def undoBacktick(label: String): String = + label.stripPrefix("`").stripSuffix("`") + + private def getParams(symbol: Symbol) = + lazy val extensionParam = symbol.extensionParam + if symbol.is(Flags.Extension) then + symbol.paramSymss.filterNot( + _.contains(extensionParam) + ) + else symbol.paramSymss + + private def isAbstractType(symbol: Symbol) = + (symbol.info.typeSymbol.is(Trait) // trait A{ def doSomething: Int} + // object B{ new A@@} + // Note: I realised that the value of Flag.Trait is flaky and + // leads to the failure of one of the DocSuite tests + || symbol.info.typeSymbol.isAllOf( + Flags.JavaInterface // in Java: interface A {} + // in Scala 3: object B { new A@@} + ) || symbol.info.typeSymbol.isAllOf( + Flags.PureInterface // in Java: abstract class Shape { abstract void draw();} + // Shape has only abstract members, so can be represented by a Java interface + // in Scala 3: object B{ new Shap@@ } + ) || (symbol.info.typeSymbol.is(Flags.Abstract) && + symbol.isClass) // so as to exclude abstract methods + // abstract class A(i: Int){ def doSomething: Int} + // object B{ new A@@} + ) + end isAbstractType + + private def findSuffix(symbol: Symbol): CompletionSuffix = + CompletionSuffix.empty + .chain { suffix => // for [] suffix + if shouldAddSnippet && + cursorPos.allowBracketSuffix && symbol.info.typeParams.nonEmpty + then suffix.copy(bracket = true, snippet = SuffixKind.Bracket) + else suffix + } + .chain { suffix => // for () suffix + if shouldAddSnippet && symbol.is(Flags.Method) + then + val paramss = getParams(symbol) + paramss match + case Nil => suffix + case List(Nil) => suffix.copy(brace = true) + case _ if config.isCompletionSnippetsEnabled => + val onlyParameterless = paramss.forall(_.isEmpty) + lazy val onlyImplicitOrTypeParams = paramss.forall( + _.exists { sym => + sym.isType || sym.is(Implicit) || sym.is(Given) + } + ) + if onlyParameterless then suffix.copy(brace = true) + else if onlyImplicitOrTypeParams then suffix + else if suffix.hasSnippet then suffix.copy(brace = true) + else suffix.copy(brace = true, snippet = SuffixKind.Brace) + case _ => suffix + end match + else suffix + } + .chain { suffix => // for {} suffix + if shouldAddSnippet && cursorPos.allowTemplateSuffix + && isAbstractType(symbol) + then + if suffix.hasSnippet then suffix.copy(template = true) + else suffix.copy(template = true, snippet = SuffixKind.Template) + else suffix + } + + end findSuffix + + def completionsWithSuffix( + sym: Symbol, + label: String, + toCompletionValue: (String, Symbol, CompletionSuffix) => CompletionValue, + ): List[CompletionValue] = + // workaround for earlier versions that force correctly detecting Java flags + def isJavaDefined = if canDetectJavaObjectsCorrectly then + sym.is(Flags.JavaDefined) + else + sym.info + sym.is(Flags.JavaDefined) + + def companionSynthetic = sym.companion.exists && sym.companion.is(Synthetic) + // find the apply completion that would need a snippet + val methodSymbols = + if shouldAddSnippet && + (sym.is(Flags.Module) || sym.isClass && !sym.is(Flags.Trait)) && + !isJavaDefined + then + val info = + /* Companion will be added even for normal classes now, + * but it will not show up from classpath. We can suggest + * constructors based on those synthetic applies. + */ + if sym.isClass && companionSynthetic then sym.companionModule.info + else sym.info + val applSymbols = info.member(nme.apply).allSymbols + sym :: applSymbols + else List(sym) + + methodSymbols.map { methodSymbol => + val suffix = findSuffix(methodSymbol) + val name = undoBacktick(label) + toCompletionValue( + name, + methodSymbol, + suffix, + ) + } + end completionsWithSuffix + + /** + * @return Tuple of completionValues and flag. If the latter boolean value is true + * Metals should provide advanced completions only. + */ + private def advancedCompletions( + path: List[Tree], + pos: SourcePosition, + completionPos: CompletionPos, + ): (List[CompletionValue], Boolean) = + lazy val rawPath = Paths + .get(pos.source.path) + lazy val rawFileName = rawPath + .getFileName() + .toString() + lazy val filename = rawFileName + .stripSuffix(".scala") + val MatchCaseExtractor = new MatchCaseExtractor(pos, text, completionPos) + val ScalaCliCompletions = + new ScalaCliCompletions(coursierComplete, pos, text) + + path match + case ScalaCliCompletions(dependency) => + (ScalaCliCompletions.contribute(dependency), true) + case _ if ScaladocCompletions.isScaladocCompletion(pos, text) => + val values = ScaladocCompletions.contribute(pos, text, config) + (values, true) + + case MatchCaseExtractor.MatchExtractor(selector) => + ( + CaseKeywordCompletion.matchContribute( + selector, + completionPos, + indexedContext, + config, + search, + autoImports, + options.contains("-no-indent"), + ), + false, + ) + + case MatchCaseExtractor.TypedCasePatternExtractor( + selector, + parent, + identName, + ) => + ( + CaseKeywordCompletion.contribute( + selector, + completionPos, + indexedContext, + config, + search, + parent, + autoImports, + patternOnly = Some(identName), + hasBind = true, + ), + false, + ) + + case MatchCaseExtractor.CasePatternExtractor( + selector, + parent, + identName, + ) => + ( + CaseKeywordCompletion.contribute( + selector, + completionPos, + indexedContext, + config, + search, + parent, + autoImports, + patternOnly = Some(identName), + ), + false, + ) + + case MatchCaseExtractor.CaseExtractor( + selector, + parent, + includeExhaustive, + ) => + ( + CaseKeywordCompletion.contribute( + selector, + completionPos, + indexedContext, + config, + search, + parent, + autoImports, + includeExhaustive = includeExhaustive, + ), + true, + ) + + // class FooImpl extends Foo: + // def x| + case OverrideExtractor(td, completing, start, exhaustive, fallbackName) => + ( + OverrideCompletions.contribute( + td, + completing, + start, + indexedContext, + search, + config, + autoImports, + fallbackName, + ), + exhaustive, + ) + + // class Fo@@ + case (td: TypeDef) :: _ + if Fuzzy.matches( + td.symbol.name.decoded.replace(Cursor.value, ""), + filename, + ) => + val values = FilenameCompletions.contribute(filename, td) + (values, true) + case (lit @ Literal(Constant(_: String))) :: _ => + val completions = InterpolatorCompletions + .contribute( + text, + pos, + completionPos, + indexedContext, + lit, + path, + this, + config.isCompletionSnippetsEnabled(), + search, + config, + buildTargetIdentifier, + ) + .filterInteresting(enrich = false) + ._1 + (completions, true) + + case (imp @ Import(expr, selectors)) :: _ + if isAmmoniteCompletionPosition(imp, rawFileName, "$file") => + ( + AmmoniteFileCompletions.contribute( + expr, + selectors, + pos.endPos.toLsp, + rawPath.toString(), + workspace, + rawFileName, + ), + true, + ) + + case (imp @ Import(_, selectors)) :: _ + if isAmmoniteCompletionPosition(imp, rawFileName, "$ivy") || + isWorksheetIvyCompletionPosition(imp, imp.sourcePos) => + ( + AmmoniteIvyCompletions.contribute( + coursierComplete, + selectors, + completionPos, + text, + ), + true, + ) + + // From Scala 3.1.3-RC3 (as far as I know), path contains + // `Literal(Constant(null))` on head for an incomplete program, in this case, just ignore the head. + case Literal(Constant(null)) :: tl => + advancedCompletions(tl, pos, completionPos) + + case _ => + val args = NamedArgCompletions.contribute( + pos, + path, + indexedContext, + config.isCompletionSnippetsEnabled, + ) + (args, false) + end match + end advancedCompletions + + private def isAmmoniteCompletionPosition( + tree: Tree, + fileName: String, + magicImport: String, + ): Boolean = + + def getQualifierStart(identOrSelect: Tree): String = + identOrSelect match + case Ident(name) => name.toString + case Select(newQual, name) => getQualifierStart(newQual) + case _ => "" + + tree match + case Import(identOrSelect, _) => + fileName.isAmmoniteGeneratedFile && getQualifierStart(identOrSelect) + .toString() + .startsWith(magicImport) + case _ => false + end isAmmoniteCompletionPosition + + def isWorksheetIvyCompletionPosition( + tree: Tree, + pos: SourcePosition, + ): Boolean = + tree match + case Import(Ident(ivy), _) => + pos.source.file.name.isWorksheet && + (ivy.decoded == "$ivy" || + ivy.decoded == "$dep") + case _ => false + + private def enrichWithSymbolSearch( + visit: CompletionValue => Boolean, + qualType: Type = ctx.definitions.AnyType, + ): Option[SymbolSearch.Result] = + val query = completionPos.query + completionPos.kind match + case CompletionKind.Empty => + val filtered = indexedContext.scopeSymbols + .filter(sym => + !sym.isConstructor && (!sym.is(Synthetic) || sym.is(Module)) + ) + + filtered.map { sym => + visit(CompletionValue.scope(sym.decodedName, sym)) + } + Some(SymbolSearch.Result.INCOMPLETE) + case CompletionKind.Scope => + val visitor = new CompilerSearchVisitor(sym => + indexedContext.lookupSym(sym) match + case IndexedContext.Result.InScope => + visit(CompletionValue.scope(sym.decodedName, sym)) + case _ => + completionsWithSuffix( + sym, + sym.decodedName, + CompletionValue.Workspace(_, _, _, sym), + ).map(visit).forall(_ == true), + ) + Some(search.search(query, buildTargetIdentifier, visitor)) + case CompletionKind.Members if query.nonEmpty => + val visitor = new CompilerSearchVisitor(sym => + if sym.is(ExtensionMethod) && + qualType.widenDealias <:< sym.extensionParam.info.widenDealias + then + completionsWithSuffix( + sym, + sym.decodedName, + CompletionValue.Extension(_, _, _), + ).map(visit).forall(_ == true) + else false, + ) + Some(search.searchMethods(query, buildTargetIdentifier, visitor)) + case CompletionKind.Members => // query.isEmpry + Some(SymbolSearch.Result.INCOMPLETE) + end match + end enrichWithSymbolSearch + + extension (s: SrcPos) + def isAfter(s1: SrcPos) = + s.sourcePos.exists && s1.sourcePos.exists && s.sourcePos.point > s1.sourcePos.point + + extension (sym: Symbol) + def detailString: String = + if sym.is(Method) then + val sig = sym.signature + val sigString = + if sig.paramsSig.isEmpty then "()" + else + sig.paramsSig + .map(p => p.toString) + .mkString("(", ",", ")") + sym.showFullName + sigString + else sym.fullName.stripModuleClassSuffix.show + + extension (l: List[CompletionValue]) + def filterInteresting( + qualType: Type = ctx.definitions.AnyType, + enrich: Boolean = true, + ): (List[CompletionValue], SymbolSearch.Result) = + + val isSeen = mutable.Set.empty[String] + val buf = List.newBuilder[CompletionValue] + def visit(head: CompletionValue): Boolean = + val (id, include) = + head match + case doc: CompletionValue.Document => (doc.label, true) + case over: CompletionValue.Override => (over.label, true) + case ck: CompletionValue.CaseKeyword => (ck.label, true) + case symOnly: CompletionValue.Symbolic => + val sym = symOnly.symbol + val name = SemanticdbSymbols.symbolName(sym) + val id = + if sym.isClass || sym.is(Module) then + // drop #|. at the end to avoid duplication + name.substring(0, name.length - 1) + else name + val include = cursorPos.include(sym) + (id, include) + case kw: CompletionValue.Keyword => (kw.label, true) + case mc: CompletionValue.MatchCompletion => (mc.label, true) + case autofill: CompletionValue.Autofill => + (autofill.label, true) + case fileSysMember: CompletionValue.FileSystemMember => + (fileSysMember.label, true) + case ii: CompletionValue.IvyImport => (ii.label, true) + + if !isSeen(id) && include then + isSeen += id + buf += head + true + else false + end visit + + l.foreach(visit) + + if enrich then + val searchResult = + enrichWithSymbolSearch(visit, qualType).getOrElse( + SymbolSearch.Result.COMPLETE + ) + (buf.result, searchResult) + else (buf.result, SymbolSearch.Result.COMPLETE) + + end filterInteresting + end extension + + private lazy val isUninterestingSymbol: Set[Symbol] = Set[Symbol]( + defn.Any_==, + defn.Any_!=, + defn.Any_##, + defn.Object_eq, + defn.Object_ne, + defn.RepeatedParamClass, + defn.ByNameParamClass2x, + defn.Object_notify, + defn.Object_notifyAll, + defn.Object_notify, + defn.Predef_undefined, + defn.ObjectClass.info.member(nme.wait_).symbol, + // NOTE(olafur) IntelliJ does not complete the root package and without this filter + // then `_root_` would appear as a completion result in the code `foobar(_)` + defn.RootPackage, + // NOTE(gabro) valueOf was added as a Predef member in 2.13. We filter it out since is a niche + // use case and it would appear upon typing 'val' + defn.ValueOfClass.info.member(nme.valueOf).symbol, + defn.ScalaPredefModule.requiredMethod(nme.valueOf), + ).flatMap(_.alternatives.map(_.symbol)).toSet + + private def isNotLocalForwardReference(sym: Symbol)(using Context): Boolean = + !sym.isLocalToBlock || + !sym.srcPos.isAfter(pos) || + sym.is(Param) + + private def computeRelevancePenalty( + completion: CompletionValue, + application: CompletionApplication, + ): Int = + import scala.meta.internal.pc.MemberOrdering.* + + def hasGetter(sym: Symbol) = try + def isModuleOrClass = sym.is(Module) || sym.isClass + // isField returns true for some classes + def isJavaClass = sym.is(JavaDefined) && isModuleOrClass + (sym.isField && !isJavaClass && !isModuleOrClass) || sym.getter != NoSymbol + catch case _ => false + + def symbolRelevance(sym: Symbol): Int = + var relevance = 0 + // symbols defined in this file are more relevant + if pos.source != sym.source || sym.is(Package) then + relevance |= IsNotDefinedInFile + + // fields are more relevant than non fields (such as method) + completion match + // For override-completion, we don't care fields or methods because + // we can override both fields and non-fields + case _: CompletionValue.Override => + relevance |= IsNotGetter + case _ if !hasGetter(sym) => + relevance |= IsNotGetter + case _ => + + // symbols whose owner is a base class are less relevant + if sym.owner == defn.AnyClass || sym.owner == defn.ObjectClass + then relevance |= IsInheritedBaseMethod + // symbols not provided via an implicit are more relevant + if sym.is(Implicit) || + sym.is(ExtensionMethod) || + application.isImplicitConversion(sym) + then relevance |= IsImplicitConversion + if application.isInherited(sym) then relevance |= IsInherited + if sym.is(Package) then relevance |= IsPackage + // accessors of case class members are more relevant + if !sym.is(CaseAccessor) then relevance |= IsNotCaseAccessor + // public symbols are more relevant + if !sym.isPublic then relevance |= IsNotCaseAccessor + // synthetic symbols are less relevant (e.g. `copy` on case classes) + if sym.is(Synthetic) && !sym.isAllOf(EnumCase) then + relevance |= IsSynthetic + if sym.isDeprecated then relevance |= IsDeprecated + if isEvilMethod(sym.name) then relevance |= IsEvilMethod + + relevance + end symbolRelevance + + completion match + case ov: CompletionValue.Override => + var penalty = symbolRelevance(ov.symbol) + // show the abstract members first + if !ov.symbol.is(Deferred) then penalty |= MemberOrdering.IsNotAbstract + penalty + case CompletionValue.Workspace(_, sym, _, _) => + symbolRelevance(sym) | (IsWorkspaceSymbol + sym.name.show.length) + case sym: CompletionValue.Symbolic => + symbolRelevance(sym.symbol) + case _ => + Int.MaxValue + end computeRelevancePenalty + + private lazy val isEvilMethod: Set[Name] = Set[Name]( + nme.notifyAll_, + nme.notify_, + nme.wait_, + nme.clone_, + nme.finalize_, + ) + + trait CompletionApplication: + def isImplicitConversion(symbol: Symbol): Boolean + def isMember(symbol: Symbol): Boolean + def isInherited(symbol: Symbol): Boolean + def postProcess(items: List[CompletionValue]): List[CompletionValue] + + object CompletionApplication: + val empty = new CompletionApplication: + def isImplicitConversion(symbol: Symbol): Boolean = false + def isMember(symbol: Symbol): Boolean = false + def isInherited(symbol: Symbol): Boolean = false + def postProcess(items: List[CompletionValue]): List[CompletionValue] = + items + + def forSelect(sel: Select): CompletionApplication = + val tpe = sel.qualifier.tpe + val members = tpe.allMembers.map(_.symbol).toSet + + new CompletionApplication: + def isImplicitConversion(symbol: Symbol): Boolean = + !isMember(symbol) + def isMember(symbol: Symbol): Boolean = members.contains(symbol) + def isInherited(symbol: Symbol): Boolean = + isMember(symbol) && symbol.owner != tpe.typeSymbol + def postProcess(items: List[CompletionValue]): List[CompletionValue] = + items.map { + case CompletionValue.Compiler(label, sym, suffix) + if isMember(sym) => + CompletionValue.Compiler( + label, + substituteTypeVars(sym), + suffix, + ) + case other => other + } + + private def substituteTypeVars(symbol: Symbol): Symbol = + val denot = symbol.asSeenFrom(tpe) + symbol.withUpdatedTpe(denot.info) + + end new + end forSelect + + def fromPath(path: List[Tree]): CompletionApplication = + path.headOption match + case Some(Select(qual @ This(_), _)) if qual.span.isSynthetic => empty + case Some(select: Select) => forSelect(select) + case _ => empty + + end CompletionApplication + + private def completionOrdering( + application: CompletionApplication + ): Ordering[CompletionValue] = + new Ordering[CompletionValue]: + val queryLower = completionPos.query.toLowerCase() + val fuzzyCache = mutable.Map.empty[CompletionValue, Int] + + def compareLocalSymbols(s1: Symbol, s2: Symbol): Int = + if s1.isLocal && s2.isLocal then + val firstIsAfter = s1.srcPos.isAfter(s2.srcPos) + if firstIsAfter then -1 else 1 + else 0 + end compareLocalSymbols + + def compareByRelevance(o1: CompletionValue, o2: CompletionValue): Int = + Integer.compare( + computeRelevancePenalty(o1, application), + computeRelevancePenalty(o2, application), + ) + + def fuzzyScore(o: CompletionValue.Symbolic): Int = + fuzzyCache.getOrElseUpdate( + o, { + val name = o.label.toLowerCase() + if name.startsWith(queryLower) then 0 + else if name.toLowerCase().contains(queryLower) then 1 + else 2 + }, + ) + + /** + * This one is used for the following case: + * ```scala + * def foo(argument: Int): Int = ??? + * val argument = 42 + * foo(arg@@) // completions should be ordered as : + * // - argument (local val) - actual value comes first + * // - argument = ... (named arg) - named arg after + * // - ... all other options + * ``` + */ + def compareInApplyParams(o1: CompletionValue, o2: CompletionValue): Int = + def priority(v: CompletionValue): Int = + v match + case _: CompletionValue.Compiler => 0 + case _ => 1 + + priority(o1) - priority(o2) + end compareInApplyParams + + /** + * Some completion values should be shown first such as CaseKeyword and + * NamedArg + */ + def compareCompletionValue( + sym1: CompletionValue.Symbolic, + sym2: CompletionValue.Symbolic, + ): Boolean = + val prioritizeCaseKeyword = + sym1.isInstanceOf[CompletionValue.CaseKeyword] && + !sym2.isInstanceOf[CompletionValue.CaseKeyword] + + // if the name is the same as the parameter name then we should show the symbolic first + val prefixMatches = + sym1.symbol.name.toString().startsWith(sym2.symbol.name.toString()) + + val prioritizeNamed = + sym1.isInstanceOf[CompletionValue.NamedArg] && + !sym2.isInstanceOf[CompletionValue.NamedArg] && + !prefixMatches + + prioritizeCaseKeyword || prioritizeNamed + end compareCompletionValue + + override def compare(o1: CompletionValue, o2: CompletionValue): Int = + (o1, o2) match + case (o1: CompletionValue.NamedArg, o2: CompletionValue.NamedArg) => + IdentifierComparator.compare( + o1.label, + o2.label, + ) + case ( + sym1: CompletionValue.Symbolic, + sym2: CompletionValue.Symbolic, + ) => + if compareCompletionValue(sym1, sym2) then 0 + else if compareCompletionValue(sym2, sym1) then 1 + else + val s1 = sym1.symbol + val s2 = sym2.symbol + val byLocalSymbol = compareLocalSymbols(s1, s2) + if byLocalSymbol != 0 then byLocalSymbol + else + val byRelevance = compareByRelevance(o1, o2) + if byRelevance != 0 then byRelevance + else + val byFuzzy = Integer.compare( + fuzzyScore(sym1), + fuzzyScore(sym2), + ) + if byFuzzy != 0 then byFuzzy + else + val byIdentifier = IdentifierComparator.compare( + s1.name.show, + s2.name.show, + ) + if byIdentifier != 0 then byIdentifier + else + val byOwner = + s1.owner.fullName.toString + .compareTo(s2.owner.fullName.toString) + if byOwner != 0 then byOwner + else + val byParamCount = Integer.compare( + s1.paramSymss.flatten.size, + s2.paramSymss.flatten.size, + ) + if byParamCount != 0 then byParamCount + else s1.detailString.compareTo(s2.detailString) + end if + end if + end if + end if + case _ => + val byApplyParams = compareInApplyParams(o1, o2) + if byApplyParams != 0 then byApplyParams + else compareByRelevance(o1, o2) + end compare + +end Completions diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/completions/FilenameCompletions.scala b/presentation-compiler/src/main/scala/meta/internal/pc/completions/FilenameCompletions.scala new file mode 100644 index 000000000000..9d20300d0ce7 --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/completions/FilenameCompletions.scala @@ -0,0 +1,33 @@ +package scala.meta.internal.pc +package completions + +import scala.meta.internal.mtags.MtagsEnrichments.decoded + +import dotty.tools.dotc.ast.tpd.TypeDef +import dotty.tools.dotc.core.Contexts.Context +import dotty.tools.dotc.core.Flags + +object FilenameCompletions: + + def contribute( + filename: String, + td: TypeDef, + )(using ctx: Context): List[CompletionValue] = + val owner = td.symbol.owner + lazy val scope = + owner.info.decls.filter(sym => sym.isType && sym.sourcePos.exists) + if owner.is(Flags.Package) && !scope.exists(sym => + sym.name.decoded == filename && (sym.is(Flags.ModuleClass) == td.symbol + .is(Flags.ModuleClass)) + ) + then + List( + CompletionValue.keyword( + s"${td.symbol.showKind} ${filename}", + filename, + ) + ) + else Nil + + end contribute +end FilenameCompletions diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/completions/InterpolatorCompletions.scala b/presentation-compiler/src/main/scala/meta/internal/pc/completions/InterpolatorCompletions.scala new file mode 100644 index 000000000000..cea1af6bcc16 --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/completions/InterpolatorCompletions.scala @@ -0,0 +1,316 @@ +package scala.meta.internal.pc.completions + +import scala.collection.mutable.ListBuffer + +import scala.meta.internal.metals.ReportContext +import scala.meta.internal.mtags.MtagsEnrichments.* +import scala.meta.internal.pc.CompilerSearchVisitor +import scala.meta.internal.pc.CompletionFuzzy +import scala.meta.internal.pc.IndexedContext +import scala.meta.internal.pc.InterpolationSplice +import scala.meta.pc.PresentationCompilerConfig +import scala.meta.pc.SymbolSearch + +import dotty.tools.dotc.ast.tpd.* +import dotty.tools.dotc.core.Contexts.Context +import dotty.tools.dotc.core.Flags +import dotty.tools.dotc.core.Flags.* +import dotty.tools.dotc.core.Symbols.Symbol +import dotty.tools.dotc.core.Types.Type +import dotty.tools.dotc.util.SourcePosition +import org.eclipse.{lsp4j as l} + +object InterpolatorCompletions: + + def contribute( + text: String, + pos: SourcePosition, + completionPos: CompletionPos, + indexedContext: IndexedContext, + lit: Literal, + path: List[Tree], + completions: Completions, + snippetsEnabled: Boolean, + search: SymbolSearch, + config: PresentationCompilerConfig, + buildTargetIdentifier: String, + )(using Context, ReportContext) = + InterpolationSplice(pos.span.point, text.toCharArray(), text) match + case Some(interpolator) => + InterpolatorCompletions.contributeScope( + text, + lit, + pos, + interpolator, + indexedContext, + completions, + snippetsEnabled, + hasStringInterpolator = + path.tail.headOption.exists(_.isInstanceOf[SeqLiteral]), + search, + buildTargetIdentifier, + ) + case None => + InterpolatorCompletions.contributeMember( + lit, + path, + text, + pos, + completionPos, + completions, + snippetsEnabled, + search, + buildTargetIdentifier, + ) + end match + end contribute + + /** + * Find the identifier that corresponds to the previous interpolation splice. + * For string `s" $Main.metho@@ "` we want to get `Main` identifier. + * The difference with Scala 2 is that we search for it through the path using + * the created partial function. + */ + private def interpolatorMemberArg( + lit: Literal, + parent: Tree, + ): PartialFunction[Tree, Option[Ident | Select]] = + case tree @ Apply( + _, + List(Typed(expr: SeqLiteral, _)), + ) if expr.elems.exists { + case _: Ident => true + case _: Select => true + case _ => false + } => + val allLiterals = parent match + case SeqLiteral(elems, _) => + elems + case _ => Nil + expr.elems.zip(allLiterals.tail).collectFirst { + case (i: (Ident | Select), literal) if literal == lit => + i + } + end interpolatorMemberArg + + /** + * A completion to select type members inside string interpolators. + * + * Example: {{{ + * // before + * s"Hello $name.len@@!" + * // after + * s"Hello ${name.length()$0}" + * }}} + */ + private def contributeMember( + lit: Literal, + path: List[Tree], + text: String, + cursor: SourcePosition, + completionPos: CompletionPos, + completions: Completions, + areSnippetsSupported: Boolean, + search: SymbolSearch, + buildTargetIdentifier: String, + )(using Context, ReportContext): List[CompletionValue] = + def newText( + name: String, + suffix: Option[String], + identOrSelect: Ident | Select, + ): String = + val snippetCursor = suffixEnding(suffix, areSnippetsSupported) + new StringBuilder() + .append('{') + .append( + text.substring(identOrSelect.span.start, identOrSelect.span.end) + ) + .append('.') + .append(name.backticked) + .append(snippetCursor) + .append('}') + .toString + end newText + + def extensionMethods(qualType: Type) = + val buffer = ListBuffer.empty[Symbol] + val visitor = new CompilerSearchVisitor(sym => + if sym.is(ExtensionMethod) && + qualType.widenDealias <:< sym.extensionParam.info.widenDealias + then + buffer.append(sym) + true + else false, + ) + search.searchMethods(completionPos.query, buildTargetIdentifier, visitor) + buffer.toList + end extensionMethods + + def completionValues( + syms: Seq[Symbol], + isExtension: Boolean, + identOrSelect: Ident | Select, + ): Seq[CompletionValue] = + syms.collect { + case sym + if CompletionFuzzy.matches( + completionPos.query, + sym.name.toString(), + ) => + val label = sym.name.decoded + completions.completionsWithSuffix( + sym, + label, + (name, s, suffix) => + CompletionValue.Interpolator( + s, + label, + Some(newText(name, suffix.toEditOpt, identOrSelect)), + Nil, + Some(cursor.withStart(identOrSelect.span.start).toLsp), + // Needed for VS Code which will not show the completion otherwise + Some(identOrSelect.name.toString() + "." + label), + s, + isExtension = isExtension, + ), + ) + }.flatten + + val qualType = for + parent <- path.tail.headOption.toList + if lit.span.exists && text.charAt(lit.span.point - 1) != '}' + identOrSelect <- path + .collectFirst(interpolatorMemberArg(lit, parent)) + .flatten + yield identOrSelect + + qualType.flatMap(identOrSelect => + val tp = identOrSelect.symbol.info + val members = tp.allMembers.map(_.symbol) + val extensionSyms = extensionMethods(tp) + completionValues(members, isExtension = false, identOrSelect) ++ + completionValues(extensionSyms, isExtension = true, identOrSelect) + ) + end contributeMember + + private def suffixEnding( + suffix: Option[String], + areSnippetsSupported: Boolean, + ): String = + suffix match + case Some(suffix) if areSnippetsSupported && suffix == "()" => + suffix + "$0" + case Some(suffix) => suffix + case None if areSnippetsSupported => "$0" + case _ => "" + + /** + * contributeScope provides completions to convert a string literal into splice, + * example `"Hello $na@@"`. + * + * When converting a string literal into an interpolator we need to ensure a few cases: + * + * - escape existing `$` characters into `$$`, which are printed as `\$\$` in order to + * escape the TextMate snippet syntax. + * - wrap completed name in curly braces `s"Hello ${name}_` when the trailing character + * can be treated as an identifier part. + * - insert the leading `s` interpolator. + * - place the cursor at the end of the completed name using TextMate `$0` snippet syntax. + */ + private def contributeScope( + text: String, + lit: Literal, + position: SourcePosition, + interpolator: InterpolationSplice, + indexedContext: IndexedContext, + completions: Completions, + areSnippetsSupported: Boolean, + hasStringInterpolator: Boolean, + search: SymbolSearch, + buildTargetIdentifier: String, + )(using ctx: Context, reportsContext: ReportContext): List[CompletionValue] = + val litStartPos = lit.span.start + val litEndPos = lit.span.end - Cursor.value.length() + val span = position.span + val nameStart = + span.withStart(span.start - interpolator.name.size) + val nameRange = position.withSpan(nameStart).toLsp + val hasClosingBrace: Boolean = text.charAt(span.point) == '}' + val hasOpeningBrace: Boolean = text.charAt( + span.start - interpolator.name.size - 1 + ) == '{' + + def additionalEdits(): List[l.TextEdit] = + val interpolatorEdit = + if !hasStringInterpolator then + val range = lit.sourcePos.withEnd(litStartPos).toLsp + List(new l.TextEdit(range, "s")) + else Nil + val dollarEdits = for + i <- litStartPos to litEndPos + if !hasStringInterpolator && + text.charAt(i) == '$' && i != interpolator.dollar + yield new l.TextEdit(lit.sourcePos.focusAt(i).toLsp, "$") + interpolatorEdit ++ dollarEdits + end additionalEdits + + def newText(symbolName: String, suffix: Option[String]): String = + val out = new StringBuilder() + val identifier = symbolName.backticked + val symbolNeedsBraces = + interpolator.needsBraces || + identifier.startsWith("`") || + suffix.isDefined + if symbolNeedsBraces && !hasOpeningBrace then out.append('{') + out.append(identifier) + out.append(suffixEnding(suffix, areSnippetsSupported)) + if symbolNeedsBraces && !hasClosingBrace then out.append('}') + out.toString + end newText + + val workspaceSymbols = ListBuffer.empty[Symbol] + val visitor = new CompilerSearchVisitor(sym => + indexedContext.lookupSym(sym) match + case IndexedContext.Result.InScope => false + case _ => + if sym.is(Flags.Module) then workspaceSymbols += sym + true, + ) + if interpolator.name.nonEmpty then + search.search(interpolator.name, buildTargetIdentifier, visitor) + + def collectCompletions( + isWorkspace: Boolean + ): PartialFunction[Symbol, List[CompletionValue]] = + case sym + if CompletionFuzzy.matches( + interpolator.name, + sym.name.decoded, + ) && !sym.isType => + val label = sym.name.decoded + completions.completionsWithSuffix( + sym, + label, + (name, s, suffix) => + CompletionValue.Interpolator( + s, + label, + Some(newText(name, suffix.toEditOpt)), + additionalEdits(), + Some(nameRange), + None, + sym, + isWorkspace, + ), + ) + end collectCompletions + + val fromWorkspace = + workspaceSymbols.toList.collect(collectCompletions(isWorkspace = true)) + val fromLocal = indexedContext.scopeSymbols.collect( + collectCompletions(isWorkspace = false) + ) + (fromLocal ++ fromWorkspace).flatten + end contributeScope + +end InterpolatorCompletions diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/completions/KeywordsCompletions.scala b/presentation-compiler/src/main/scala/meta/internal/pc/completions/KeywordsCompletions.scala new file mode 100644 index 000000000000..3e98e76cee02 --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/completions/KeywordsCompletions.scala @@ -0,0 +1,163 @@ +package scala.meta.internal.pc.completions + +import scala.meta.internal.mtags.MtagsEnrichments.given +import scala.meta.internal.pc.Keyword +import scala.meta.internal.pc.KeywordCompletionsUtils +import scala.meta.tokenizers.XtensionTokenizeInputLike +import scala.meta.tokens.Token + +import dotty.tools.dotc.ast.tpd.* +import dotty.tools.dotc.core.Contexts.Context +import dotty.tools.dotc.util.SourcePosition + +object KeywordsCompletions: + + def contribute( + path: List[Tree], + completionPos: CompletionPos, + )(using ctx: Context): List[CompletionValue] = + lazy val notInComment = checkIfNotInComment(completionPos.cursorPos, path) + path match + case Nil if completionPos.query.isEmpty => + Keyword.all.collect { + // topelevel definitions are allowed in Scala 3 + case kw if (kw.isPackage || kw.isTemplate) && notInComment => + CompletionValue.keyword(kw.name, kw.insertText) + } + case _ => + val isExpression = this.isExpression(path) + val isBlock = this.isBlock(path) + val isDefinition = + this.isDefinition(path, completionPos.query, completionPos.cursorPos) + val isMethodBody = this.isMethodBody(path) + val isTemplate = this.isTemplate(path) + val isPackage = this.isPackage(path) + val isParam = this.isParam(path) + val isSelect = this.isSelect(path) + val isImport = this.isImport(path) + lazy val text = completionPos.cursorPos.source.content.mkString + lazy val reverseTokens: Array[Token] = + // Try not to tokenize the whole file + // Maybe we should re-use the tokenize result with `notInComment` + val lineStart = + if completionPos.cursorPos.line > 0 then + completionPos.sourcePos.source.lineToOffset( + completionPos.cursorPos.line - 1 + ) + else 0 + text + .substring(lineStart, completionPos.cursorPos.start) + .tokenize + .toOption match + case Some(toks) => toks.tokens.reverse + case None => Array.empty[Token] + end reverseTokens + + val canBeExtended = KeywordCompletionsUtils.canBeExtended(reverseTokens) + val canDerive = KeywordCompletionsUtils.canDerive(reverseTokens) + val hasExtend = KeywordCompletionsUtils.hasExtend(reverseTokens) + + Keyword.all.collect { + case kw + if kw.matchesPosition( + completionPos.query, + isExpression = isExpression, + isBlock = isBlock, + isDefinition = isDefinition, + isMethodBody = isMethodBody, + isTemplate = isTemplate, + isPackage = isPackage, + isParam = isParam, + isScala3 = true, + isSelect = isSelect, + isImport = isImport, + allowToplevel = true, + canBeExtended = canBeExtended, + canDerive = canDerive, + hasExtend = hasExtend, + ) && notInComment => + CompletionValue.keyword(kw.name, kw.insertText) + } + end match + end contribute + + private def checkIfNotInComment( + pos: SourcePosition, + path: List[Tree], + ): Boolean = + val text = pos.source.content + val (treeStart, treeEnd) = path.headOption + .map(t => (t.span.start, t.span.end)) + .getOrElse((0, text.size)) + val offset = pos.start + text.mkString.checkIfNotInComment(treeStart, treeEnd, offset) + end checkIfNotInComment + + private def isPackage(enclosing: List[Tree]): Boolean = + enclosing match + case Nil => true + case _ => false + + private def isParam(enclosing: List[Tree]): Boolean = + enclosing match + case (vd: ValDef) :: (dd: DefDef) :: _ + if dd.paramss.exists(pc => pc.contains(vd) && pc.size == 1) => + true + case _ => false + + private def isTemplate(enclosing: List[Tree]): Boolean = + enclosing match + case Ident(_) :: (_: Template) :: _ => true + case Ident(_) :: (_: ValOrDefDef) :: _ => true + case (_: TypeDef) :: _ => true + case _ => false + + private def isMethodBody(enclosing: List[Tree]): Boolean = + enclosing match + case Ident(_) :: (_: DefDef) :: _ => true + case _ => false + + private def isSelect(enclosing: List[Tree]): Boolean = + enclosing match + case (_: Apply) :: (_: Select) :: _ => true + case (_: Select) :: _ => true + case _ => false + + private def isImport(enclosing: List[Tree]): Boolean = + enclosing match + case Import(_, _) :: _ => true + case _ => false + + private def isDefinition( + enclosing: List[Tree], + name: String, + pos: SourcePosition, + )(using ctx: Context): Boolean = + enclosing match + case (_: Ident) :: _ => false + case _ => + // NOTE(olafur) in positions like "implicit obje@@" the parser discards the entire + // statement and `enclosing` is not helpful. In these situations we fallback to the + // diagnostics reported by the parser to see if it expected a definition here. + // This is admittedly not a great solution, but it's the best I can think of at this point. + val point = pos.withSpan(pos.span.withPoint(pos.point - name.length())) + + val isExpectedStartOfDefinition = + ctx.reporter.allErrors.exists { info => + info.pos.focus == point && + info.message == "expected start of definition" + } + isExpectedStartOfDefinition + + private def isBlock(enclosing: List[Tree]): Boolean = + enclosing match + case Ident(_) :: Block(_, _) :: _ => true + case _ => false + + private def isExpression(enclosing: List[Tree]): Boolean = + enclosing match + case Ident(_) :: (_: Template) :: _ => true + case Ident(_) :: (_: ValOrDefDef) :: _ => true + case Ident(_) :: t :: _ if t.isTerm => true + case other => false +end KeywordsCompletions diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/completions/MatchCaseCompletions.scala b/presentation-compiler/src/main/scala/meta/internal/pc/completions/MatchCaseCompletions.scala new file mode 100644 index 000000000000..e5d94ec2f8e1 --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/completions/MatchCaseCompletions.scala @@ -0,0 +1,635 @@ +package scala.meta.internal.pc +package completions + +import java.net.URI + +import scala.collection.JavaConverters.* +import scala.collection.mutable +import scala.collection.mutable.ListBuffer + +import scala.meta.internal.mtags.MtagsEnrichments.* +import scala.meta.internal.pc.AutoImports.AutoImportsGenerator +import scala.meta.internal.pc.AutoImports.SymbolImport +import scala.meta.internal.pc.MetalsInteractive.* +import scala.meta.pc.PresentationCompilerConfig +import scala.meta.pc.SymbolSearch + +import dotty.tools.dotc.ast.tpd.* +import dotty.tools.dotc.core.Constants.Constant +import dotty.tools.dotc.core.Contexts.Context +import dotty.tools.dotc.core.Definitions +import dotty.tools.dotc.core.Flags +import dotty.tools.dotc.core.Flags.* +import dotty.tools.dotc.core.Symbols.NoSymbol +import dotty.tools.dotc.core.Symbols.Symbol +import dotty.tools.dotc.core.Types.AndType +import dotty.tools.dotc.core.Types.ClassInfo +import dotty.tools.dotc.core.Types.NoType +import dotty.tools.dotc.core.Types.OrType +import dotty.tools.dotc.core.Types.Type +import dotty.tools.dotc.core.Types.TypeRef +import dotty.tools.dotc.util.SourcePosition +import org.eclipse.{lsp4j as l} + +object CaseKeywordCompletion: + + /** + * A `case` completion showing the valid subtypes of the type being deconstructed. + * + * @param selector `selector` of `selector match { cases }` or `EmptyTree` when + * not in a match expression (for example `List(1).foreach { case@@ }`. + * @param completionPos the position of the completion + * @param typedtree typed tree of the file, used for generating auto imports + * @param indexedContext + * @param config + * @param parent the parent tree node of the pattern match, for example `Apply(_, _)` when in + * `List(1).foreach { cas@@ }`, used as fallback to compute the type of the selector when + * it's `EmptyTree`. + * @param patternOnly `None` for `case@@`, `Some(query)` for `case query@@ =>` or `case ab: query@@ =>` + * @param hasBind `true` when `case _: @@ =>`, if hasBind we don't need unapply completions + */ + def contribute( + selector: Tree, + completionPos: CompletionPos, + indexedContext: IndexedContext, + config: PresentationCompilerConfig, + search: SymbolSearch, + parent: Tree, + autoImportsGen: AutoImportsGenerator, + patternOnly: Option[String] = None, + hasBind: Boolean = false, + includeExhaustive: Option[NewLineOptions] = None, + ): List[CompletionValue] = + import indexedContext.ctx + val definitions = indexedContext.ctx.definitions + val clientSupportsSnippets = config.isCompletionSnippetsEnabled() + val completionGenerator = CompletionValueGenerator( + completionPos, + clientSupportsSnippets, + patternOnly, + hasBind, + ) + val parents: Parents = selector match + case EmptyTree => + val seenFromType = parent match + case TreeApply(fun, _) if fun.tpe != null && !fun.tpe.isErroneous => + fun.tpe + case _ => + parent.tpe + seenFromType.paramInfoss match + case (head :: Nil) :: _ + if definitions.isFunctionType(head) || head.isRef( + definitions.PartialFunctionClass + ) => + val argTypes = + head.argTypes.init + new Parents(argTypes, definitions) + case _ => + new Parents(NoType, definitions) + case sel => new Parents(sel.tpe, definitions) + + val selectorSym = parents.selector.typeSymbol + + // Special handle case when selector is a tuple or `FunctionN`. + if definitions.isTupleClass(selectorSym) || definitions.isFunctionClass( + selectorSym + ) + then + val label = + if patternOnly.isEmpty then s"case ${parents.selector.show} =>" + else parents.selector.show + List( + CompletionValue.CaseKeyword( + selectorSym, + label, + Some( + if patternOnly.isEmpty then + if config.isCompletionSnippetsEnabled() then "case ($0) =>" + else "case () =>" + else if config.isCompletionSnippetsEnabled() then "($0)" + else "()" + ), + Nil, + range = Some(completionPos.toEditRange), + command = config.parameterHintsCommand().asScala, + ) + ) + else + val result = ListBuffer.empty[SymbolImport] + val isVisited = mutable.Set.empty[Symbol] + def visit(symImport: SymbolImport): Unit = + + def recordVisit(s: Symbol): Unit = + if s != NoSymbol && !isVisited(s) then + isVisited += s + recordVisit(s.moduleClass) + recordVisit(s.sourceModule) + + val sym = symImport.sym + if !isVisited(sym) then + recordVisit(sym) + if completionGenerator.fuzzyMatches(symImport.name) then + result += symImport + end visit + + // Step 0: case for selector type + selectorSym.info match + case NoType => () + case _ => + if !(selectorSym.is(Sealed) && + (selectorSym.is(Abstract) || selectorSym.is(Trait))) + then visit((autoImportsGen.inferSymbolImport(selectorSym))) + + // Step 1: walk through scope members. + def isValid(sym: Symbol) = !parents.isParent(sym) + && (sym.is(Case) || sym.is(Flags.Module) || sym.isClass) + && parents.isSubClass(sym, false) + && (sym.isPublic || sym.isAccessibleFrom(selectorSym.info)) + indexedContext.scopeSymbols + .foreach(s => + val ts = s.info.metalsDealias.typeSymbol + if isValid(ts) then visit(autoImportsGen.inferSymbolImport(ts)) + ) + // Step 2: walk through known subclasses of sealed types. + val sealedDescs = subclassesForType(parents.selector.widen.bounds.hi) + sealedDescs.foreach { sym => + val symbolImport = autoImportsGen.inferSymbolImport(sym) + visit(symbolImport) + } + val res = result.result().flatMap { + case si @ SymbolImport(sym, name, importSel) => + completionGenerator.labelForCaseMember(sym, name.value).map { label => + (si, label) + } + } + val caseItems = res.map((si, label) => + completionGenerator.toCompletionValue( + si.sym, + label, + autoImportsGen.renderImports(si.importSel.toList), + ) + ) + includeExhaustive match + // In `List(foo).map { cas@@} we want to provide also `case (exhaustive)` completion + // which works like exhaustive match. + case Some(NewLineOptions(moveToNewLine, addNewLineAfter)) => + val sealedMembers = + val sealedMembers0 = + res.filter((si, _) => sealedDescs.contains(si.sym)) + sortSubclasses( + selectorSym.info, + sealedMembers0, + completionPos.sourceUri, + search, + ) + sealedMembers match + case Nil => caseItems + case (_, label) :: tail => + val (newLine, addIndent) = + if moveToNewLine then ("\n\t", "\t") else ("", "") + val insertText = Some( + tail + .map(_._2) + .mkString( + if clientSupportsSnippets then + s"$newLine${label} $$0\n$addIndent" + else s"$newLine${label}\n$addIndent", + s"\n$addIndent", + if addNewLineAfter then "\n" else "", + ) + ) + val allImports = + sealedMembers.flatMap(_._1.importSel).distinct + val importEdit = autoImportsGen.renderImports(allImports) + val exhaustive = CompletionValue.MatchCompletion( + s"case (exhaustive)", + insertText, + importEdit.toList, + s" ${selectorSym.decodedName} (${res.length} cases)", + ) + exhaustive :: caseItems + end match + case None => caseItems + end match + end if + + end contribute + + /** + * A `match` keyword completion to generate an exhaustive pattern match for sealed types. + * + * @param selector the match expression being deconstructed or `EmptyTree` when + * not in a match expression (for example `List(1).foreach { case@@ }`. + * @param completionPos the position of the completion + * @param typedtree typed tree of the file, used for generating auto imports + */ + def matchContribute( + selector: Tree, + completionPos: CompletionPos, + indexedContext: IndexedContext, + config: PresentationCompilerConfig, + search: SymbolSearch, + autoImportsGen: AutoImportsGenerator, + noIndent: Boolean, + ): List[CompletionValue] = + import indexedContext.ctx + val clientSupportsSnippets = config.isCompletionSnippetsEnabled() + + val completionGenerator = CompletionValueGenerator( + completionPos, + clientSupportsSnippets, + ) + val result = ListBuffer.empty[CompletionValue] + val tpe = selector.tpe.widen.bounds.hi match + case tr @ TypeRef(_, _) => tr.underlying + case t => t + + val sortedSubclasses = + val subclasses = + subclassesForType(tpe.widen.bounds.hi) + .map(autoImportsGen.inferSymbolImport) + .flatMap(si => + completionGenerator.labelForCaseMember(si.sym, si.name).map((si, _)) + ) + sortSubclasses(tpe, subclasses, completionPos.sourceUri, search) + + val (labels, imports) = + sortedSubclasses.map((si, label) => (label, si.importSel)).unzip + + val (obracket, cbracket) = if noIndent then (" {", "}") else ("", "") + val basicMatch = CompletionValue.MatchCompletion( + "match", + Some( + if clientSupportsSnippets then s"match$obracket\n\tcase$$0\n$cbracket" + else "match" + ), + Nil, + "", + ) + + val completions = labels match + case Nil => List(basicMatch) + case head :: tail => + val insertText = Some( + tail + .mkString( + if clientSupportsSnippets then + s"match$obracket\n\t${head} $$0\n\t" + else s"match$obracket\n\t${head}\n\t", + "\n\t", + s"\n$cbracket", + ) + ) + val importEdit = autoImportsGen.renderImports(imports.flatten.distinct) + val exhaustive = CompletionValue.MatchCompletion( + "match (exhaustive)", + insertText, + importEdit.toList, + s" ${tpe.typeSymbol.decodedName} (${labels.length} cases)", + ) + List(basicMatch, exhaustive) + completions + end matchContribute + + private def sortSubclasses[A]( + tpe: Type, + syms: List[(SymbolImport, String)], + uri: URI, + search: SymbolSearch, + )(using Context): List[(SymbolImport, String)] = + if syms.forall(_._1.sym.sourcePos.exists) then + syms.sortBy(_._1.sym.sourcePos.point) + else + val defnSymbols = search + .definitionSourceToplevels( + SemanticdbSymbols.symbolName(tpe.typeSymbol), + uri, + ) + .asScala + .zipWithIndex + .toMap + syms.sortBy { case (SymbolImport(sym, _, _), _) => + val semancticName = SemanticdbSymbols.symbolName(sym) + defnSymbols.getOrElse(semancticName, -1) + } + + def subclassesForType(tpe: Type)(using Context): List[Symbol] = + /** + * Split type made of & and | types to a list of simple types. + * For example, `(A | D) & (B & C)` returns `List(A, D, B, C). + * Later we use them to generate subclasses of each of these types. + */ + def getParentTypes(tpe: Type, acc: List[Symbol]): List[Symbol] = + tpe match + case AndType(tp1, tp2) => + getParentTypes(tp2, getParentTypes(tp1, acc)) + case OrType(tp1, tp2) => + getParentTypes(tp2, getParentTypes(tp1, acc)) + case t => + tpe.typeSymbol :: acc + + /** + * Check if `sym` is a subclass of type `tpe`. + * For `class A extends B with C with D` we have to construct B & C & D type, + * because `A <:< (B & C) == false`. + */ + def isExhaustiveMember(sym: Symbol): Boolean = + val symTpe = sym.info match + case cl: ClassInfo => + cl.parents + .reduceLeftOption((tp1, tp2) => tp1.&(tp2)) + .getOrElse(sym.info) + case simple => simple + symTpe <:< tpe + + val parents = getParentTypes(tpe, List.empty) + parents.toList.map { parent => + // There is an issue in Dotty, `sealedStrictDescendants` ends in an exception for java enums. https://github.com/lampepfl/dotty/issues/15908 + if parent.isAllOf(JavaEnumTrait) then parent.children + else MetalsSealedDesc.sealedStrictDescendants(parent) + } match + case Nil => Nil + case subcls :: Nil => subcls + case subcls => + val subclasses = subcls.flatten.distinct + subclasses.filter(isExhaustiveMember) + + end subclassesForType + +end CaseKeywordCompletion + +class Parents(val selector: Type, definitions: Definitions)(using Context): + def this(tpes: List[Type], definitions: Definitions)(using Context) = + this( + tpes match + case Nil => NoType + case head :: Nil => head + case _ => definitions.tupleType(tpes) + , + definitions, + ) + + val isParent: Set[Symbol] = + Set(selector.typeSymbol, selector.typeSymbol.companion) + .filterNot(_ == NoSymbol) + val isBottom: Set[Symbol] = Set[Symbol]( + definitions.NullClass, + definitions.NothingClass, + ) + def isSubClass(typeSymbol: Symbol, includeReverse: Boolean)(using + Context + ): Boolean = + !isBottom(typeSymbol) && + isParent.exists { parent => + typeSymbol.isSubClass(parent) || + (includeReverse && parent.isSubClass(typeSymbol)) + } +end Parents + +class CompletionValueGenerator( + completionPos: CompletionPos, + clientSupportsSnippets: Boolean, + patternOnly: Option[String] = None, + hasBind: Boolean = false, +): + def fuzzyMatches(name: String) = + patternOnly match + case None => true + case Some("") => true + case Some(Cursor.value) => true + case Some(query) => + CompletionFuzzy.matches( + query.replace(Cursor.value, ""), + name, + ) + + def labelForCaseMember(sym: Symbol, name: String)(using + Context + ): Option[String] = + val isModuleLike = + sym.is(Flags.Module) || sym.isOneOf(JavaEnumTrait) || sym.isOneOf( + JavaEnumValue + ) || sym.isAllOf(EnumCase) + if isModuleLike && hasBind then None + else + val pattern = + if (sym.is(Case) || isModuleLike) && !hasBind then + if sym.is(Case) && + sym.decodedName == name && + !Character.isUnicodeIdentifierStart(name.head) + then + // Deconstructing the symbol as an infix operator, for example `case head :: tail =>` + tryInfixPattern(sym, name).getOrElse( + unapplyPattern(sym, name, isModuleLike) + ) + else + unapplyPattern( + sym, + name, + isModuleLike, + ) // Apply syntax, example `case ::(head, tail) =>` + end if + else + typePattern( + sym, + name, + ) // Symbol is not a case class with unapply deconstructor so we use typed pattern, example `_: User` + end if + end pattern + + val out = + if patternOnly.isEmpty then s"case $pattern =>" + else pattern + Some(out) + end if + end labelForCaseMember + + def toCompletionValue( + sym: Symbol, + label: String, + autoImport: Option[l.TextEdit], + ): CompletionValue.CaseKeyword = + val cursorSuffix = + (if patternOnly.nonEmpty then "" else " ") + + (if clientSupportsSnippets then "$0" else "") + CompletionValue.CaseKeyword( + sym, + label, + Some(label + cursorSuffix), + autoImport.toList, + range = Some(completionPos.toEditRange), + ) + end toCompletionValue + + private def tryInfixPattern(sym: Symbol, name: String)(using + Context + ): Option[String] = + sym.primaryConstructor.paramSymss match + case (a :: b :: Nil) :: Nil => + Some( + s"${a.decodedName} $name ${b.decodedName}" + ) + case _ :: (a :: b :: Nil) :: _ => + Some( + s"${a.decodedName} $name ${b.decodedName}" + ) + case _ => None + + private def unapplyPattern( + sym: Symbol, + name: String, + isModuleLike: Boolean, + )(using Context): String = + val suffix = + if isModuleLike && !(sym.isClass && sym.is(Enum)) then "" + else + sym.primaryConstructor.paramSymss match + case Nil => "()" + case tparams :: params :: _ => + params + .map(param => param.showName) + .mkString("(", ", ", ")") + case head :: _ => + head + .map(param => param.showName) + .mkString("(", ", ", ")") + name + suffix + end unapplyPattern + + private def typePattern( + sym: Symbol, + name: String, + )(using Context): String = + val suffix = sym.typeParams match + case Nil => "" + case tparams => tparams.map(_ => "?").mkString("[", ", ", "]") + val bind = if hasBind then "" else "_: " + bind + name + suffix +end CompletionValueGenerator + +class MatchCaseExtractor( + pos: SourcePosition, + text: String, + completionPos: CompletionPos, +): + object MatchExtractor: + def unapply(path: List[Tree]) = + path match + // foo mat@@ + case (sel @ Select(qualifier, name)) :: _ + if name.toString() != Cursor.value && "match" + .startsWith( + name.toString().replace(Cursor.value, "") + ) && (text + .charAt( + completionPos.start - 1 + ) == ' ' || text.charAt(completionPos.start - 1) == '.') => + Some(qualifier) + case _ => None + end MatchExtractor + object CaseExtractor: + def unapply(path: List[Tree])(using + Context + ): Option[(Tree, Tree, Option[NewLineOptions])] = + path match + // foo match + // case None => () + // ca@@ + case (id @ Ident(name)) :: Block(stats, expr) :: parent :: _ + if "case" + .startsWith( + name.toString().replace(Cursor.value, "") + ) && stats.lastOption.exists( + _.isInstanceOf[Match] + ) && expr == id => + val selector = stats.last.asInstanceOf[Match].selector + Some((selector, parent, None)) + // List(Option(1)).collect { + // case Some(value) => () + // ca@@ + // } + case (ident @ Ident(name)) :: Block( + _, + expr, + ) :: (cd: CaseDef) :: (m: Match) :: parent :: _ + if ident == expr && "case" + .startsWith( + name.toString().replace(Cursor.value, "") + ) && + cd.sourcePos.startLine != pos.startLine => + Some((m.selector, parent, None)) + // foo match + // ca@@ + case (_: CaseDef) :: (m: Match) :: parent :: _ => + Some((m.selector, parent, None)) + // List(foo).map { ca@@ } + case (ident @ Ident(name)) :: (block @ Block(stats, expr)) :: + (apply @ Apply(fun, args)) :: _ + if stats.isEmpty && ident == expr && "case".startsWith( + name.toString().replace(Cursor.value, "") + ) => + val moveToNewLine = ident.sourcePos.line == apply.sourcePos.line + val addNewLineAfter = apply.sourcePos.endLine == ident.sourcePos.line + Some( + ( + EmptyTree, + apply, + Some(NewLineOptions(moveToNewLine, addNewLineAfter)), + ) + ) + + case _ => None + end CaseExtractor + + object CasePatternExtractor: + def unapply(path: List[Tree])(using Context) = + path match + // case @@ + case (c @ CaseDef( + Literal((Constant(null))), + _, + _, + )) :: (m: Match) :: parent :: _ + if pos.start - c.sourcePos.start > 4 => + Some((m.selector, parent, "")) + // case Som@@ + case Ident(name) :: CaseExtractor(selector, parent, _) => + Some((selector, parent, name.decoded)) + // case abc @ Som@@ + case Ident(name) :: Bind(_, _) :: CaseExtractor(selector, parent, _) => + Some((selector, parent, name.decoded)) + // case abc@@ + case Bind(name, Ident(_)) :: CaseExtractor(selector, parent, _) => + Some((selector, parent, name.decoded)) + // case abc @ @@ + case Bind(name, Literal(_)) :: CaseExtractor(selector, parent, _) => + Some((selector, parent, "")) + case _ => None + + end CasePatternExtractor + + object TypedCasePatternExtractor: + def unapply(path: List[Tree])(using Context) = + path match + // case _: Som@@ => + case Ident(name) :: Typed(_, _) :: CaseExtractor(selector, parent, _) => + Some((selector, parent, name.decoded)) + // case _: @@ => + case Typed(_, _) :: CaseExtractor(selector, parent, _) => + Some((selector, parent, "")) + // case ab: @@ => + case Bind(_, Typed(_, _)) :: CaseExtractor(selector, parent, _) => + Some((selector, parent, "")) + // case ab: Som@@ => + case Ident(name) :: Typed(_, _) :: Bind(_, _) :: CaseExtractor( + selector, + parent, + _, + ) => + Some((selector, parent, name.decoded)) + case _ => None + end TypedCasePatternExtractor + +end MatchCaseExtractor + +case class NewLineOptions(moveToNewLine: Boolean, addNewLineAfter: Boolean) diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/completions/NamedArgCompletions.scala b/presentation-compiler/src/main/scala/meta/internal/pc/completions/NamedArgCompletions.scala new file mode 100644 index 000000000000..6133ecf9081e --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/completions/NamedArgCompletions.scala @@ -0,0 +1,219 @@ +package scala.meta.internal.pc.completions + +import scala.meta.internal.mtags.MtagsEnrichments.* +import scala.meta.internal.pc.IndexedContext + +import dotty.tools.dotc.ast.Trees.ValDef +import dotty.tools.dotc.ast.tpd.* +import dotty.tools.dotc.core.Constants.Constant +import dotty.tools.dotc.core.Contexts.Context +import dotty.tools.dotc.core.Flags +import dotty.tools.dotc.core.NameKinds.DefaultGetterName +import dotty.tools.dotc.core.Names.Name +import dotty.tools.dotc.core.Symbols.Symbol +import dotty.tools.dotc.core.Types.Type +import dotty.tools.dotc.util.SourcePosition + +object NamedArgCompletions: + + def contribute( + pos: SourcePosition, + path: List[Tree], + indexedContext: IndexedContext, + clientSupportsSnippets: Boolean, + )(using ctx: Context): List[CompletionValue] = + path match + case (ident: Ident) :: ValDef(_, _, _) :: Block(_, app: Apply) :: _ + if !isInfix(pos, app) => + contribute( + Some(ident), + app, + indexedContext, + clientSupportsSnippets, + ) + case (ident: Ident) :: rest => + def getApplyForContextFunctionParam(path: List[Tree]): Option[Apply] = + path match + // fun(arg@@) + case (app: Apply) :: _ => Some(app) + // fun(arg@@), where fun(argn: Context ?=> SomeType) + // recursively matched for multiple context arguments, e.g. Context1 ?=> Context2 ?=> SomeType + case (_: DefDef) :: Block(List(_), _: Closure) :: rest => + getApplyForContextFunctionParam(rest) + case _ => None + val contribution = + for + app <- getApplyForContextFunctionParam(rest) + if !isInfix(pos, app) + yield contribute( + Some(ident), + app, + indexedContext, + clientSupportsSnippets, + ) + contribution.getOrElse(Nil) + case _ => + Nil + end match + end contribute + + private def isInfix(pos: SourcePosition, apply: Apply)(using ctx: Context) = + apply.fun match + case Select(New(_), _) => false + case Select(_, name) if name.decoded == "apply" => false + // is a select statement without a dot `qual.name` + case sel @ Select(qual, _) if !sel.symbol.is(Flags.Synthetic) => + !(qual.span.end until sel.nameSpan.start) + .map(pos.source.apply) + .contains('.') + case _ => false + + private def contribute( + ident: Option[Ident], + apply: Apply, + indexedContext: IndexedContext, + clientSupportsSnippets: Boolean, + )(using Context): List[CompletionValue] = + def isUselessLiteral(arg: Tree): Boolean = + arg match + case Literal(Constant(())) => true // unitLiteral + case Literal(Constant(null)) => true // nullLiteral + case _ => false + + def collectArgss(a: Apply): List[List[Tree]] = + def stripContextFuntionArgument(argument: Tree): List[Tree] = + argument match + case Block(List(d: DefDef), _: Closure) => + d.rhs match + case app: Apply => + app.args + case b @ Block(List(_: DefDef), _: Closure) => + stripContextFuntionArgument(b) + case _ => Nil + case v => List(v) + + val args = a.args.flatMap(stripContextFuntionArgument) + a.fun match + case app: Apply => collectArgss(app) :+ args + case _ => List(args) + end collectArgss + + val method = apply.fun + val methodSym = method.symbol + + // paramSymss contains both type params and value params + val vparamss = + methodSym.paramSymss.filter(params => params.forall(p => p.isTerm)) + val argss = collectArgss(apply) + // get params and args we are interested in + // e.g. + // in the following case, the interesting args and params are + // - params: [apple, banana] + // - args: [apple, b] + // ``` + // def curry(x; Int)(apple: String, banana: String) = ??? + // curry(1)(apple = "test", b@@) + // ``` + val (baseParams, baseArgs) = + vparamss.zip(argss).lastOption.getOrElse((Nil, Nil)) + + val args = ident + .map(i => baseArgs.filterNot(_ == i)) + .getOrElse(baseArgs) + .filterNot(isUselessLiteral) + + val isNamed: Set[Name] = args.iterator + .zip(baseParams.iterator) + // filter out synthesized args and default arg getters + .filterNot { + case (arg, _) if arg.symbol.denot.is(Flags.Synthetic) => true + case (Ident(name), _) => name.is(DefaultGetterName) // default args + case (Select(Ident(_), name), _) => + name.is(DefaultGetterName) // default args for apply method + case _ => false + } + .map { + case (NamedArg(name, _), _) => name + case (_, param) => param.name + } + .toSet + + val allParams: List[Symbol] = + baseParams.filterNot(param => + isNamed(param.name) || + param.denot.is( + Flags.Synthetic + ) // filter out synthesized param, like evidence + ) + + val prefix = + ident + .map(_.name.toString) + .getOrElse("") + .replace(Cursor.value, "") + val params: List[Symbol] = + allParams.filter(param => param.name.startsWith(prefix)) + + val completionSymbols = indexedContext.scopeSymbols + def matchingTypesInScope(paramType: Type): List[String] = + completionSymbols + .collect { + case sym + if sym.info <:< paramType && sym.isTerm && !sym.info.isErroneous && !sym.info.isNullType && !sym.info.isNothingType && !sym + .is(Flags.Method) && !sym.is(Flags.Synthetic) => + sym.decodedName + } + .filter(name => name != "Nil" && name != "None") + .sorted + + def findDefaultValue(param: Symbol): String = + val matchingType = matchingTypesInScope(param.info) + if matchingType.size == 1 then s":${matchingType.head}" + else if matchingType.size > 1 then s"|???,${matchingType.mkString(",")}|" + else ":???" + + def fillAllFields(): List[CompletionValue] = + val suffix = "autofill" + val shouldShow = + allParams.exists(param => param.name.startsWith(prefix)) + val isExplicitlyCalled = suffix.startsWith(prefix) + val hasParamsToFill = allParams.count(!_.is(Flags.HasDefault)) > 1 + if (shouldShow || isExplicitlyCalled) && hasParamsToFill && clientSupportsSnippets + then + val editText = allParams.zipWithIndex + .collect { + case (param, index) if !param.is(Flags.HasDefault) => + s"${param.nameBackticked.replace("$", "$$")} = $${${index + 1}${findDefaultValue(param)}}" + } + .mkString(", ") + List( + CompletionValue.Autofill( + editText + ) + ) + else List.empty + end if + end fillAllFields + + def findPossibleDefaults(): List[CompletionValue] = + params.flatMap { param => + val allMembers = matchingTypesInScope(param.info) + allMembers.map { memberName => + val editText = + param.nameBackticked + " = " + memberName + " " + CompletionValue.namedArg( + label = editText, + param, + ) + } + } + + params.map(p => + CompletionValue.namedArg( + s"${p.nameBackticked} = ", + p, + ) + ) ::: findPossibleDefaults() ::: fillAllFields() + end contribute + +end NamedArgCompletions diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/completions/OverrideCompletions.scala b/presentation-compiler/src/main/scala/meta/internal/pc/completions/OverrideCompletions.scala new file mode 100644 index 000000000000..818525825446 --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/completions/OverrideCompletions.scala @@ -0,0 +1,583 @@ +package scala.meta.internal.pc +package completions + +import java.{util as ju} + +import scala.collection.JavaConverters.* + +import scala.meta.internal.mtags.MtagsEnrichments.* +import scala.meta.internal.pc.AutoImports.AutoImport +import scala.meta.internal.pc.AutoImports.AutoImportsGenerator +import scala.meta.internal.pc.printer.MetalsPrinter +import scala.meta.pc.OffsetParams +import scala.meta.pc.PresentationCompilerConfig +import scala.meta.pc.PresentationCompilerConfig.OverrideDefFormat +import scala.meta.pc.SymbolSearch + +import dotty.tools.dotc.ast.tpd.Tree +import dotty.tools.dotc.ast.tpd.* +import dotty.tools.dotc.core.Contexts.Context +import dotty.tools.dotc.core.Flags +import dotty.tools.dotc.core.Flags.* +import dotty.tools.dotc.core.NameKinds.DefaultGetterName +import dotty.tools.dotc.core.Names.Name +import dotty.tools.dotc.core.StdNames +import dotty.tools.dotc.core.SymDenotations.SymDenotation +import dotty.tools.dotc.core.Symbols.Symbol +import dotty.tools.dotc.core.Types.Type +import dotty.tools.dotc.interactive.Interactive +import dotty.tools.dotc.interactive.InteractiveDriver +import dotty.tools.dotc.util.SourceFile +import dotty.tools.dotc.util.SourcePosition +import org.eclipse.{lsp4j as l} + +object OverrideCompletions: + private type TargetDef = TypeDef | DefDef + + private def defaultIndent(tabIndent: Boolean) = + if tabIndent then 1 else 2 + + /** + * @param td A surrounded type definition being complete + * @param filterName A prefix string for filtering, if None no filter + * @param start The starting point of the completion. For example, starting point is `*` + * `*override def f|` (where `|` represents the cursor position). + */ + def contribute( + td: TypeDef, + completing: Option[Symbol], + start: Int, + indexedContext: IndexedContext, + search: SymbolSearch, + config: PresentationCompilerConfig, + autoImportsGen: AutoImportsGenerator, + fallbackName: Option[String], + ): List[CompletionValue] = + import indexedContext.ctx + val clazz = td.symbol.asClass + val syntheticCoreMethods: Set[Name] = + indexedContext.ctx.definitions.syntheticCoreMethods.map(_.name).toSet + val isDecl = td.typeOpt.decls.toList.toSet + + /** Is the given symbol that we're trying to complete? */ + def isSelf(sym: Symbol) = completing.fold(false)(self => self == sym) + + def isOverrideable(sym: Symbol)(using Context): Boolean = + val overridingSymbol = sym.overridingSymbol(clazz) + !sym.is(Synthetic) && + !sym.is(Artifact) && + // not overridden in in this class, except overridden by the symbol that we're completing + (!isDecl(overridingSymbol) || isSelf(overridingSymbol)) && + !(sym.is(Mutable) && !sym.is( + Deferred + )) && // concrete var can't be override + (!syntheticCoreMethods(sym.name) || allowedList(sym.name)) && + !sym.is(Final) && + !sym.isConstructor && + !sym.isSetter && + // exclude symbols desugared by default args + !sym.name.is(DefaultGetterName) + end isOverrideable + // Given the base class `trait Foo { def foo: Int; val bar: Int; var baz: Int }` + // and typing `def @@` in the subclass of `Foo`, + // suggest `def foo` and exclude `val bar`, and `var baz` from suggestion + // because they are not method definitions (not starting from `def`). + val flags = completing.map(_.flags & interestingFlags).getOrElse(EmptyFlags) + + val name = + completing + .fold(fallbackName)(sym => Some(sym.name.show)) + .map(_.replace(Cursor.value, "")) + .filter(!_.isEmpty()) + + // not using `td.tpe.abstractTermMembers` because those members includes + // the abstract members in `td.tpe`. For example, when we type `def foo@@`, + // `td.tpe.abstractTermMembers` contains `method foo: ` and it overrides the parent `foo` method. + val overridables = td.tpe.parents + .flatMap { parent => + parent.membersBasedOnFlags( + flags, + Flags.Private, + ) + } + .distinct + .collect { + case denot + if name + .fold(true)(name => denot.name.startsWith(name)) && + !denot.symbol.isType => + denot.symbol + } + .filter(isOverrideable) + + overridables + .map(sym => + toCompletionValue( + sym.denot, + start, + td, + indexedContext, + search, + shouldMoveCursor = true, + config, + autoImportsGen, + indexedContext.ctx.compilationUnit.source.content + .startsWith("o", start), + ) + ) + .toList + end contribute + + def implementAllAt( + params: OffsetParams, + driver: InteractiveDriver, + search: SymbolSearch, + config: PresentationCompilerConfig, + ): ju.List[l.TextEdit] = + object FindTypeDef: + def unapply(path: List[Tree])(using Context): Option[TypeDef] = path match + // class <> extends ... {} + case (td: TypeDef) :: _ => Some(td) + // new Iterable[Int] {} + case (_: Ident) :: _ :: (_: Template) :: (td: TypeDef) :: _ => + Some(td) + // given Foo with {} + case (_: Ident) :: (_: Template) :: (td: TypeDef) :: _ => + Some(td) + // <> Foo {} + case (_: Template) :: (td: TypeDef) :: _ => + Some(td) + // abstract class Mutable { ... } + // new <> { } + case (_: Ident) :: (_: New) :: (_: Select) :: (_: Apply) :: (_: Template) :: (td: TypeDef) :: _ => + Some(td) + // trait Base[T]: + // extension (x: T) + // ... + // class <>[T] extends Base[Int] + case (dd: DefDef) :: (_: Template) :: (td: TypeDef) :: _ + if dd.symbol.isConstructor => + Some(td) + + // case class <>(a: Int) extends ... + // if there is no companion object Foo, td would be Foo$ + // we have to look for defininion of Foo class + case (dd: DefDef) :: (t: Template) :: (td: TypeDef) :: parent :: _ + if dd.symbol.decodedName == "apply" => + fallbackFromParent( + parent: Tree, + dd.symbol.owner.decodedName, + ) + case _ => None + end FindTypeDef + + val uri = params.uri + driver.run( + uri, + SourceFile.virtual(uri.toASCIIString, params.text), + ) + val unit = driver.currentCtx.run.units.head + val pos = driver.sourcePosition(params) + + val newctx = driver.currentCtx.fresh.setCompilationUnit(unit) + val tpdTree = newctx.compilationUnit.tpdTree + val path = + Interactive.pathTo(tpdTree, pos.span)(using newctx) match + case path @ TypeDef(_, template) :: _ => + template :: path + case path => path + + val indexedContext = IndexedContext( + MetalsInteractive.contextOfPath(path)(using newctx) + ) + import indexedContext.ctx + + lazy val autoImportsGen = AutoImports.generator( + pos, + params.text, + unit.tpdTree, + indexedContext, + config, + ) + lazy val implementAll = implementAllFor( + indexedContext, + params.text, + search, + autoImportsGen, + config, + ) + path match + // given <> + case (_: Ident) :: (dd: DefDef) :: _ => + implementAll(dd).asJava + case FindTypeDef(td) => + implementAll(td).asJava + case _ => + new ju.ArrayList[l.TextEdit]() + end implementAllAt + + private def implementAllFor( + indexedContext: IndexedContext, + text: String, + search: SymbolSearch, + autoImports: AutoImportsGenerator, + config: PresentationCompilerConfig, + )( + defn: TargetDef + )(using Context): List[l.TextEdit] = + def calcIndent( + defn: TargetDef, + decls: List[Symbol], + source: SourceFile, + text: String, + shouldCompleteBraces: Boolean, + )(using Context): (String, String, String) = + // For `FooImpl` in the below, the necessaryIndent will be 2 + // because there're 2 spaces before `class FooImpl`. + // ```scala + // |object X: + // | class FooImpl extends Foo { + // | } + // ``` + val (necessaryIndent, tabIndented) = CompletionPos.inferIndent( + source.lineToOffset(defn.sourcePos.line), + text, + ) + // infer indent for implementations + // If there's declaration in the class/object, follow its indent. + // For example, numIndent will be 8, because there're 8 spaces before + // `override def foo: Int` + // ```scala + // |object X: + // | class FooImpl extends Foo { + // | override def foo: Int = 1 + // | } + // ``` + val (numIndent, shouldTabIndent) = + decls.headOption + .map { decl => + CompletionPos.inferIndent( + source.lineToOffset(decl.sourcePos.line), + text, + ) + } + .getOrElse({ + val default = defaultIndent(tabIndented) + (necessaryIndent + default, tabIndented) + }) + val indentChar = if shouldTabIndent then "\t" else " " + val indent = indentChar * numIndent + val lastIndent = + if (defn.sourcePos.startLine == defn.sourcePos.endLine) || + shouldCompleteBraces + then "\n" + indentChar * necessaryIndent + else "" + (indent, indent, lastIndent) + end calcIndent + val abstractMembers = defn.tpe.abstractTermMembers.map(_.symbol) + + val caseClassOwners = Set("Product", "Equals") + val overridables = + if defn.symbol.is(Flags.CaseClass) then + abstractMembers.filter(sym => + sym.sourcePos.exists || !caseClassOwners(sym.owner.decodedName) + ) + else abstractMembers + + val completionValues = overridables + .map(sym => + toCompletionValue( + sym.denot, + 0, // we don't care the position of each completion value from ImplementAll + defn, + indexedContext, + search, + shouldMoveCursor = false, + config, + autoImports, + shouldAddOverrideKwd = true, + ) + ) + .toList + val (edits, imports) = toEdits(completionValues) + + if edits.isEmpty then Nil + else + // A list of declarations in the class/object to implement + val decls = defn.tpe.decls.toList + .filter(sym => + !sym.isPrimaryConstructor && + !sym.isTypeParam && + !sym.is(ParamAccessor) && // `num` of `class Foo(num: int)` + sym.span.exists && + !(sym.span.isZeroExtent && defn.symbol.is(Flags.CaseClass)) && + defn.sourcePos.contains(sym.sourcePos) + ) + .sortBy(_.sourcePos.start) + val source = indexedContext.ctx.source + + val shouldCompleteBraces = decls.isEmpty && hasBraces(text, defn).isEmpty + + val (startIndent, indent, lastIndent) = + calcIndent(defn, decls, source, text, shouldCompleteBraces) + + // If there're declarations in the class/object to implement e.g. + // ```scala + // class FooImpl extends Foo: + // override def foo(...) = ... + // ``` + // The edit position will be the beginning line of `override def foo` + // Otherwise, infer the position by `inferEditPosiiton` + val posFromDecls = + decls.headOption.map(decl => + val pos = source.lineToOffset(decl.sourcePos.line) + val span = decl.sourcePos.span.withStart(pos).withEnd(pos) + defn.sourcePos.withSpan(span) + ) + + val editPos = posFromDecls.getOrElse(inferEditPosition(text, defn)) + lazy val shouldCompleteWith = defn match + case dd: DefDef => + dd.symbol.is(Given) + case _ => false + + val (start, last) = + val (startNL, lastNL) = + if posFromDecls.nonEmpty then ("\n", "\n\n") else ("\n\n", "\n") + if shouldCompleteWith then + (s" with$startNL$indent", s"$lastNL$lastIndent") + else if shouldCompleteBraces then + (s" {$startNL$indent", s"$lastNL$lastIndent}") + else (s"$startNL$indent", s"$lastNL$lastIndent") + + val newEdit = + edits.mkString(start, s"\n\n$indent", last) + val implementAll = new l.TextEdit( + editPos.toLsp, + newEdit, + ) + implementAll +: imports.toList + end if + + end implementAllFor + + private def toEdits( + completions: List[CompletionValue.Override] + ): (List[String], Set[l.TextEdit]) = + completions.foldLeft( + (List.empty[String], Set.empty[l.TextEdit]) + ) { (editsAndImports, completion) => + val edit = + completion.value + val edits = editsAndImports._1 :+ edit + val imports = completion.additionalEdits.toSet ++ editsAndImports._2 + (edits, imports) + } + end toEdits + + private lazy val allowedList: Set[Name] = + Set[Name]( + StdNames.nme.hashCode_, + StdNames.nme.toString_, + StdNames.nme.equals_, + ) + + private def toCompletionValue( + sym: SymDenotation, + start: Int, + defn: TargetDef, + indexedContext: IndexedContext, + search: SymbolSearch, + shouldMoveCursor: Boolean, + config: PresentationCompilerConfig, + autoImportsGen: AutoImportsGenerator, + shouldAddOverrideKwd: Boolean, + )(using Context): CompletionValue.Override = + val renames = AutoImport.renameConfigMap(config) + val printer = MetalsPrinter.standard( + indexedContext, + search, + includeDefaultParam = MetalsPrinter.IncludeDefaultParam.Never, + renames, + ) + val overrideKeyword: String = + // if the overriding method is not an abstract member, add `override` keyword + if !sym.isOneOf(Deferred) || shouldAddOverrideKwd + then "override" + else "" + + val overrideDefLabel: String = config.overrideDefFormat() match + case OverrideDefFormat.Unicode => + if sym.is(Deferred) then "🔼 " + else "⏫ " + case _ => "" + + val signature = + // `iterator` method in `new Iterable[Int] { def iterato@@ }` + // should be completed as `def iterator: Iterator[Int]` instead of `Iterator[A]`. + val seenFrom = + val memInfo = defn.tpe.memberInfo(sym.symbol) + if memInfo.isErroneous || memInfo.finalResultType.isAny then + sym.info.widenTermRefExpr + else memInfo + + if sym.is(Method) then + printer.defaultMethodSignature( + sym.symbol, + seenFrom, + additionalMods = + if overrideKeyword.nonEmpty then List(overrideKeyword) else Nil, + ) + else + printer.defaultValueSignature( + sym.symbol, + seenFrom, + additionalMods = + if overrideKeyword.nonEmpty then List(overrideKeyword) else Nil, + ) + end if + end signature + + val label = s"$overrideDefLabel$signature" + val stub = + if config.isCompletionSnippetsEnabled && shouldMoveCursor then "${0:???}" + else "???" + val value = s"$signature = $stub" + val additionalEdits = + printer.shortenedNames + .sortBy(nme => nme.name) + .flatMap(name => autoImportsGen.forShortName(name)) + .flatten + CompletionValue.Override( + label, + value, + sym.symbol, + additionalEdits, + Some(signature), + Some(autoImportsGen.pos.withStart(start).toLsp), + ) + end toCompletionValue + + private val interestingFlags = Flags.Method | Flags.Mutable + + /** + * Infer the editPosition for "implement all" code action for the given TypeDef. + * + * If there're braces like `class FooImpl extends Foo {}`, + * editPosition will be inside the braces. + * Otherwise, e.g. `class FooImpl extends Foo`, editPosition will be + * after the `extends Foo`. + * + * @param text the whole text of the source file + * @param td the class/object to impement all + */ + private def inferEditPosition(text: String, defn: TargetDef)(using + Context + ): SourcePosition = + val span = hasBraces(text, defn) + .map { offset => + defn.sourcePos.span.withStart(offset + 1).withEnd(offset + 1) + } + .getOrElse({ + defn.sourcePos.span.withStart(defn.span.end) + }) + defn.sourcePos.withSpan(span) + end inferEditPosition + + private def hasBraces(text: String, defn: TargetDef): Option[Int] = + def hasSelfTypeAnnot = defn match + case td: TypeDef => + td.rhs match + case t: Template => + t.self.span.isSourceDerived + case _ => false + case _ => false + val start = defn.span.start + val offset = + if hasSelfTypeAnnot then text.indexOf("=>", start) + 1 + else text.indexOf("{", start) + if offset > 0 && offset < defn.span.end then Some(offset) + else None + end hasBraces + + private def fallbackFromParent(parent: Tree, name: String)(using Context) = + val stats = parent match + case t: Template => Some(t.body) + case pkg: PackageDef => Some(pkg.stats) + case b: Block => Some(b.stats) + case _ => None + stats.flatMap(_.collectFirst { + case td: TypeDef if td.symbol.decodedName == name => td + }) + + object OverrideExtractor: + def unapply(path: List[Tree])(using Context) = + path match + // class FooImpl extends Foo: + // def x| + case (dd: (DefDef | ValDef)) :: (t: Template) :: (td: TypeDef) :: _ + if t.parents.nonEmpty => + val completing = + if dd.symbol.name == StdNames.nme.ERROR then None + else Some(dd.symbol) + Some( + ( + td, + completing, + dd.sourcePos.start, + true, + None, + ) + ) + + // class FooImpl extends Foo: + // ov| + case (ident: Ident) :: (t: Template) :: (td: TypeDef) :: _ + if t.parents.nonEmpty && "override".startsWith( + ident.name.show.replace(Cursor.value, "") + ) => + Some( + ( + td, + None, + ident.sourcePos.start, + false, + None, + ) + ) + + // class Main extends Val: + // def @@ + case (id: Ident) :: (t: Template) :: (td: TypeDef) :: _ + if t.parents.nonEmpty && id.name.decoded.replace( + Cursor.value, + "", + ) == "def" => + Some( + ( + td, + None, + t.sourcePos.start, + true, + None, + ) + ) + // class Main extends Val: + // he@@ + case (id: Ident) :: (t: Template) :: (td: TypeDef) :: _ + if t.parents.nonEmpty => + Some( + ( + td, + None, + id.sourcePos.start, + false, + Some(id.name.show), + ) + ) + + case _ => None + + end OverrideExtractor + +end OverrideCompletions diff --git a/presentation-compiler/src/main/scala/meta/internal/pc/completions/ScalaCliCompletions.scala b/presentation-compiler/src/main/scala/meta/internal/pc/completions/ScalaCliCompletions.scala new file mode 100644 index 000000000000..8e3895d0dd8a --- /dev/null +++ b/presentation-compiler/src/main/scala/meta/internal/pc/completions/ScalaCliCompletions.scala @@ -0,0 +1,39 @@ +package scala.meta.internal.pc.completions + +import scala.meta.internal.mtags.CoursierComplete +import scala.meta.internal.mtags.MtagsEnrichments.* + +import dotty.tools.dotc.ast.tpd.* +import dotty.tools.dotc.util.SourcePosition + +class ScalaCliCompletions( + coursierComplete: CoursierComplete, + pos: SourcePosition, + text: String, +): + def unapply(path: List[Tree]) = + def scalaCliDep = CoursierComplete.isScalaCliDep( + pos.lineContent.take(pos.column).stripPrefix("/*