From 4a387247220749be4cf8d749de490c80f1ff13a2 Mon Sep 17 00:00:00 2001 From: Jozef Koval Date: Wed, 8 Jul 2026 10:12:38 +0200 Subject: [PATCH] feat: make scaladoc/javadoc wiki links navigable in hover and go-to-definition --- .../meta/internal/metals/Compilers.scala | 208 +- .../internal/metals/DefinitionProvider.scala | 40 +- .../internal/metals/MetalsLspService.scala | 67 + .../metals/ScaladocDefinitionProvider.scala | 1217 ++++++- .../meta/internal/metals/ServerCommands.scala | 20 + .../internal/metals/WorkspaceLspService.scala | 31 + .../meta/internal/metals/Docstrings.scala | 28 +- .../meta/internal/metals/JavadocIndexer.scala | 117 +- .../internal/metals/ScaladocImportScope.scala | 395 +++ .../internal/metals/ScaladocIndexer.scala | 77 +- .../internal/metals/docstrings/DocScope.scala | 46 + .../metals/docstrings/MetalsSymbolLink.scala | 336 ++ .../metals/docstrings/ScaladocParser.scala | 26 +- .../internal/metals/docstrings/WikiLink.scala | 184 ++ .../printers/MarkdownGenerator.scala | 57 +- .../meta/internal/mtags/JavacMtags.scala | 27 + project/TestGroups.scala | 20 +- .../main/scala/tests/DocstringMarkers.scala | 30 + .../mtest/src/main/scala/tests/PCSuite.scala | 4 +- .../src/main/scala/tests/TestHovers.scala | 3 +- .../src/main/scala/tests/TestInlayHints.scala | 2 +- .../test/scala/tests/DefinitionLspSuite.scala | 447 +++ .../src/test/scala/tests/HoverLspSuite.scala | 2814 +++++++++++++++++ .../src/test/scala/tests/JavadocSuite.scala | 28 +- .../scala/tests/MarkdownGeneratorSuite.scala | 177 ++ .../scala/tests/MetalsSymbolLinkSuite.scala | 176 ++ .../scala/tests/ScaladocSymbolsSuite.scala | 314 +- .../src/test/scala/tests/WikiLinkSuite.scala | 109 + 28 files changed, 6808 insertions(+), 192 deletions(-) create mode 100644 mtags/src/main/scala/scala/meta/internal/metals/ScaladocImportScope.scala create mode 100644 mtags/src/main/scala/scala/meta/internal/metals/docstrings/DocScope.scala create mode 100644 mtags/src/main/scala/scala/meta/internal/metals/docstrings/MetalsSymbolLink.scala create mode 100644 mtags/src/main/scala/scala/meta/internal/metals/docstrings/WikiLink.scala create mode 100644 tests/mtest/src/main/scala/tests/DocstringMarkers.scala create mode 100644 tests/unit/src/test/scala/tests/MarkdownGeneratorSuite.scala create mode 100644 tests/unit/src/test/scala/tests/MetalsSymbolLinkSuite.scala create mode 100644 tests/unit/src/test/scala/tests/WikiLinkSuite.scala diff --git a/metals/src/main/scala/scala/meta/internal/metals/Compilers.scala b/metals/src/main/scala/scala/meta/internal/metals/Compilers.scala index 97f9e52dd79d..d940deda22e8 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/Compilers.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/Compilers.scala @@ -17,6 +17,7 @@ import scala.meta.inputs.Input import scala.meta.inputs.Position import scala.meta.internal import scala.meta.internal.builds.SbtBuildTool +import scala.meta.internal.docstrings.MetalsSymbolLink import scala.meta.internal.metals.CompilerOffsetParamsUtils import scala.meta.internal.metals.CompilerRangeParamsUtils import scala.meta.internal.metals.Compilers.PresentationCompilerKey @@ -25,6 +26,7 @@ import scala.meta.internal.mtags.MD5 import scala.meta.internal.parsing.Trees import scala.meta.internal.pc.LogMessages import scala.meta.internal.pc.PcSymbolInformation +import scala.meta.internal.pc.ScalaHover import scala.meta.internal.worksheets.WorksheetPcData import scala.meta.internal.worksheets.WorksheetProvider import scala.meta.internal.{semanticdb => s} @@ -33,6 +35,7 @@ import scala.meta.pc.AutoImportsResult import scala.meta.pc.CancelToken import scala.meta.pc.CodeActionId import scala.meta.pc.CompletionItemPriority +import scala.meta.pc.ContentType import scala.meta.pc.HoverSignature import scala.meta.pc.OffsetParams import scala.meta.pc.PresentationCompiler @@ -51,10 +54,12 @@ import org.eclipse.lsp4j.CompletionList import org.eclipse.lsp4j.CompletionParams import org.eclipse.lsp4j.Diagnostic import org.eclipse.lsp4j.DocumentHighlight +import org.eclipse.lsp4j.Hover import org.eclipse.lsp4j.InitializeParams import org.eclipse.lsp4j.InlayHint import org.eclipse.lsp4j.InlayHintKind import org.eclipse.lsp4j.InlayHintParams +import org.eclipse.lsp4j.MarkupContent import org.eclipse.lsp4j.ReferenceParams import org.eclipse.lsp4j.RenameParams import org.eclipse.lsp4j.SelectionRange @@ -415,7 +420,11 @@ class Compilers( for { data <- item.data compiler <- buildTargetPCFromCache(new BuildTargetIdentifier(data.target)) - } yield compiler.completionItemResolve(item, data.symbol).asScala + } yield compiler.completionItemResolve(item, data.symbol).asScala.map { + resolved => + stripWikiLinkMarkers(resolved.getDocumentation()) + resolved + } }.getOrElse(Future.successful(item)) /** @@ -1126,6 +1135,7 @@ class Compilers( params: HoverExtParams, token: CancelToken, ): Future[Option[HoverSignature]] = { + val source = params.textDocument.getUri.toAbsolutePath withPCAndAdjustLsp(params) { (pc, pos, adjust) => pc.hover( CompilerRangeParamsUtils.offsetOrRange( @@ -1134,10 +1144,166 @@ class Compilers( outlineFilesProvider.getOutlineFiles(pc.buildTargetId()), ) ).asScala - .map(_.asScala.map { hover => adjust.adjustHoverResp(hover) }) + .map( + _.asScala.map { hover => + rewriteHoverWikiLinks(adjust.adjustHoverResp(hover), source) + } + ) } }.getOrElse(Future.successful(None)) + private val markerLinkOpen: String = MetalsSymbolLink.markerLinkOpen + + /** + * Replaces every `[title]([[MetalsSymbolLink.scheme]]payload)` link with + * `transform(title, payload)`, scanning back from the marker for the label's + * `[` so bracketed titles don't leak the marker (scalameta/metals#3383). + */ + private def rewriteMarkerLinks( + markdown: String, + transform: (String, String) => String, + ): String = + if (!markdown.contains(MetalsSymbolLink.scheme)) markdown + else { + val out = new java.lang.StringBuilder(markdown.length) + var i = 0 + while (i < markdown.length) { + markdown.indexOf(markerLinkOpen, i) match { + case -1 => + out.append(markdown, i, markdown.length) + i = markdown.length + case marker => + val payloadStart = marker + markerLinkOpen.length + val payloadEnd = markdown.indexOf(')', payloadStart) + val titleOpen = labelOpenBracket(markdown, marker) + if ( + payloadEnd < 0 || titleOpen < 0 || isEscaped(markdown, marker) || + // Label opening already emitted (an earlier escaped marker advanced + // `i` past it); keep verbatim to avoid a backwards span (scalameta/metals#3383). + titleOpen < i + ) { + // Not a well-formed marker link; keep it verbatim and move on. + out.append(markdown, i, marker + 1) + i = marker + 1 + } else { + out.append(markdown, i, titleOpen) + out.append( + transform( + markdown.substring(titleOpen + 1, marker), + markdown.substring(payloadStart, payloadEnd), + ) + ) + i = payloadEnd + 1 + } + } + } + out.toString + } + + /** Whether the char at `idx` is backslash-escaped (odd run of `\` before). */ + private def isEscaped(s: String, idx: Int): Boolean = { + var backslashes = 0 + var j = idx - 1 + while (j >= 0 && s.charAt(j) == '\\') { + backslashes += 1 + j -= 1 + } + backslashes % 2 == 1 + } + + /** + * Index of the unescaped `[` opening the label closed by the `]` at + * `closeBracket`, or -1. `MarkdownGenerator` escapes the label's own brackets, + * so the first unescaped `[` scanning back is the opening one regardless of + * how unbalanced the title is. + */ + private def labelOpenBracket(s: String, closeBracket: Int): Int = { + var j = closeBracket - 1 + var result = -1 + while (j >= 0 && result < 0) { + if (s.charAt(j) == '[' && !isEscaped(s, j)) result = j + j -= 1 + } + result + } + + /** + * Rewrites scaladoc wiki links marked with the [[MetalsSymbolLink.scheme]] + * sentinel: command-link clients get a clickable command, others get plain + * text, so no broken link is ever shown (scalameta/metals#3383). + */ + private def rewriteHoverWikiLinks( + hover: HoverSignature, + source: AbsolutePath, + ): HoverSignature = + hover match { + // Fast path for the in-process compiler. The presentation compiler may + // also run in an isolated classloader, in which case its `HoverSignature` + // is a different `ScalaHover` class and we fall through to rewriting the + // rendered markup via the `toLsp` interface instead. + case scalaHover: ScalaHover => + scalaHover.docstring match { + case Some(doc) if doc.contains(MetalsSymbolLink.scheme) => + scalaHover.copy(docstring = Some(rewriteWikiLinks(doc, source))) + case _ => hover + } + case _ => + val lsp = hover.toLsp() + Option(lsp.getContents()) + .filter(c => c.isRight() && c.getRight() != null) + .map(_.getRight()) + .filter(markup => + markup.getValue() != null && + markup.getValue().contains(MetalsSymbolLink.scheme) + ) match { + case Some(markup) => + new Compilers.RewrittenHover( + hover, + rewriteWikiLinks(markup.getValue(), source), + ) + case None => hover + } + } + + private def rewriteWikiLinks( + docstring: String, + source: AbsolutePath, + ): String = { + val format = config.commandInHtmlFormat() + rewriteMarkerLinks( + docstring, + (title, payload) => + // Emit a command that resolves the symbol lazily on click (keeps hover + // cheap); clients without command links get plain text (scalameta/metals#3383). + format match { + case Some(fmt) => + val link = ServerCommands.GotoScaladocLink.toCommandLink( + ScaladocLinkParams(source.toURI.toString, payload), + fmt, + ) + s"[$title]($link)" + case None => title + }, + ) + } + + /** + * Strips the [[MetalsSymbolLink.scheme]] marker from docstring markdown, + * leaving just the title. Used on non-hover surfaces (completion, signature + * help) that just need to avoid leaking a broken marker (scalameta/metals#3383). + */ + private def stripWikiLinkMarkers(markdown: String): String = + rewriteMarkerLinks(markdown, (title, _) => title) + + private def stripWikiLinkMarkers( + documentation: JEither[String, MarkupContent] + ): Unit = + if (documentation != null && documentation.isRight()) { + val markup = documentation.getRight() + if (markup != null && markup.getValue() != null) + markup.setValue(stripWikiLinkMarkers(markup.getValue())) + } + def prepareRename( params: TextDocumentPositionParams, token: CancelToken, @@ -1270,6 +1436,7 @@ class Compilers( outlineFilesProvider.getOutlineFiles(pc.buildTargetId()), ) ).asScala + .map(stripWikiLinkMarkers) }.getOrElse(Future.successful(new SignatureHelp())) def signatureHelp( @@ -1278,10 +1445,22 @@ class Compilers( ): Future[SignatureHelp] = loadCompiler(id) .map { pc => - pc.signatureHelp(offsetParams).asScala + pc.signatureHelp(offsetParams).asScala.map(stripWikiLinkMarkers) } .getOrElse(Future.successful(new SignatureHelp())) + private def stripWikiLinkMarkers(help: SignatureHelp): SignatureHelp = { + Option(help.getSignatures()).foreach(_.asScala.foreach { signature => + stripWikiLinkMarkers(signature.getDocumentation()) + Option(signature.getParameters()).foreach( + _.asScala.foreach(param => + stripWikiLinkMarkers(param.getDocumentation()) + ) + ) + }) + help + } + def selectionRange( params: SelectionRangeParams, token: CancelToken, @@ -1852,6 +2031,29 @@ class Compilers( object Compilers { + /** + * Wraps a [[HoverSignature]] (whose type may live in an isolated + * presentation-compiler classloader) to replace its rendered markup with the + * wiki-link-rewritten version (scalameta/metals#3383). + */ + private class RewrittenHover( + underlying: HoverSignature, + rewrittenMarkup: String, + ) extends HoverSignature { + override def toLsp(): Hover = { + val lsp = underlying.toLsp() + val contents = lsp.getContents() + if (contents != null && contents.isRight() && contents.getRight() != null) + contents.getRight().setValue(rewrittenMarkup) + lsp + } + override def signature(): ju.Optional[String] = underlying.signature() + override def getRange(): ju.Optional[LspRange] = underlying.getRange() + override def withRange(range: LspRange): HoverSignature = + new RewrittenHover(underlying.withRange(range), rewrittenMarkup) + override def contentType(): ContentType = underlying.contentType() + } + sealed trait PresentationCompilerKey object PresentationCompilerKey { final case class ScalaBuildTarget(id: BuildTargetIdentifier) diff --git a/metals/src/main/scala/scala/meta/internal/metals/DefinitionProvider.scala b/metals/src/main/scala/scala/meta/internal/metals/DefinitionProvider.scala index a581c8fa0a2c..a542927a0bba 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/DefinitionProvider.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/DefinitionProvider.scala @@ -140,20 +140,38 @@ final class DefinitionProvider( val shouldUseOldOrder = isScala3 && !scala3DefinitionBugFixed - val strategies: List[() => Future[Option[DefinitionResult]]] = - if (shouldUseOldOrder) - List(fromSemanticDb, fromCompiler, fromScalaDoc, fromFallback) - else List(fromCompiler, fromSemanticDb, fromScalaDoc, fromFallback) + // For `.java` files SemanticDB can resolve a doc-comment position to the + // enclosing class, pre-empting the doc-link fallback; `fromScalaDoc` returns + // None unless it's a real doc link, so run it first (scalameta/metals#3383). + val isJava = path.isJava + + // The precise strategies, in order; `fromFallback` (a heuristic search) is + // applied separately below because it must respect a stricter guard. + val coreStrategies: List[() => Future[Option[DefinitionResult]]] = + if (isJava) + List(fromScalaDoc, fromCompiler, fromSemanticDb) + else if (shouldUseOldOrder) + List(fromSemanticDb, fromCompiler, fromScalaDoc) + else List(fromCompiler, fromSemanticDb, fromScalaDoc) for { - result <- strategies.foldLeft(Future.successful(DefinitionResult.empty)) { - case (acc, next) => - acc.flatMap { - case res if res.isEmpty && !res.symbol.endsWith("/") => - next().map(_.getOrElse(res)) - case res => Future.successful(res) - } + // Each strategy runs only until something resolves. A doc-comment position + // can resolve to the enclosing package (symbol `a/`, no location), so an + // empty package result must not stop `fromScalaDoc` (scalameta/metals#3383). + core <- coreStrategies.foldLeft( + Future.successful(DefinitionResult.empty) + ) { case (acc, next) => + acc.flatMap { + case res if res.isEmpty => next().map(_.getOrElse(res)) + case res => Future.successful(res) + } } + // The fallback must not override a package-symbol result (no location), so + // it keeps the package guard the core loop dropped (scalameta/metals#3383). + result <- + if (core.isEmpty && !core.symbol.endsWith("/")) + fromFallback().map(_.getOrElse(core)) + else Future.successful(core) } yield { reportBuilder .build(scalaVersionSelector) diff --git a/metals/src/main/scala/scala/meta/internal/metals/MetalsLspService.scala b/metals/src/main/scala/scala/meta/internal/metals/MetalsLspService.scala index 98530f026e8f..37e600aa624c 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/MetalsLspService.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/MetalsLspService.scala @@ -24,6 +24,7 @@ import scala.meta.internal.bsp.BspSession import scala.meta.internal.bsp.ConnectionBspStatus import scala.meta.internal.builds.BspErrorHandler import scala.meta.internal.builds.ShellRunner +import scala.meta.internal.docstrings.MetalsSymbolLink import scala.meta.internal.implementation.ImplementationProvider import scala.meta.internal.implementation.Supermethods import scala.meta.internal.io.FileIO @@ -1394,6 +1395,72 @@ abstract class MetalsLspService( .asScala .headOption + /** + * Resolves a hover documentation link to its definition. A unique match is + * `Resolved`; a miss or ambiguous/overloaded link is `NotUnique`; an + * unexpected (logged) error is `Failed` (scalameta/metals#3383). + */ + def resolveScaladocLink( + params: ScaladocLinkParams + ): ScaladocLinkResolution = + try { + val path = params.uri.toAbsolutePath + // Build fallbacks from the marker's DocScope with the same generator as + // go-to-definition, so the two paths can't drift (scalameta/metals#3383). + val parsed = MetalsSymbolLink.parsePayload(params.payload) + val docScope = parsed.docScope + // The docstring's own dialect (from the marker), not the hovered file's, + // so a Scala 2 library's docs aren't parsed as Scala 3 (scalameta/metals#3383). + val isScala3 = docScope.docIsScala3 + val context = docScope.owner match { + case Some(owner) => + ContextSymbols.fromSymbols(owner, docScope.alternative) + case None => ContextSymbols.empty + } + val fallbacksOf = (target: String) => + MetalsSymbolLink.fallbacksForTarget( + target, + docScope.isJava, + (name, rest, bare) => + ScaladocImportScope + .fallbacksFor(docScope.imports, docScope.isJava, name, rest, bare), + ) + val locations = definitionProvider.scaladocDefinitionProvider + .resolveLinkLocations( + parsed.target, + path, + isScala3, + context, + fallbacksOf, + docScope.isJava, + // The docstring's own file, so hover keeps the same-compilation-unit + // precedence even when the owner is unresolvable (scalameta/metals#3383). + knownDocstringFile = docScope.docstringFile.map(_.toAbsolutePath), + ) + // Navigate only on a unique match; reporting ambiguity beats jumping to + // an arbitrary overload or ambiguous form (scalameta/metals#3383). + locations match { + case location :: Nil => ScaladocLinkResolution.Resolved(location) + case _ => ScaladocLinkResolution.NotUnique + } + } catch { + case NonFatal(e) => + // An exception here is a real failure (lookups are guarded), not a + // miss — report it rather than show ambiguity (scalameta/metals#3383). + reports.incognito.create(() => + Report( + "scaladoc-link-resolution", + s"Failed to resolve documentation link `${params.payload}`", + e, + ) + ) + scribe.error( + s"failed to resolve documentation link `${params.payload}`", + e, + ) + ScaladocLinkResolution.Failed + } + def gotoSupermethod( textDocumentPositionParams: TextDocumentPositionParams ): CompletableFuture[Object] = diff --git a/metals/src/main/scala/scala/meta/internal/metals/ScaladocDefinitionProvider.scala b/metals/src/main/scala/scala/meta/internal/metals/ScaladocDefinitionProvider.scala index 49d7cf288422..bb7142b77c5a 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/ScaladocDefinitionProvider.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/ScaladocDefinitionProvider.scala @@ -1,6 +1,7 @@ package scala.meta.internal.metals import scala.collection.mutable.ListBuffer +import scala.util.Failure import scala.util.Success import scala.util.Try @@ -10,12 +11,16 @@ import scala.meta.Term import scala.meta.Tree import scala.meta.inputs.Input import scala.meta.inputs.Position +import scala.meta.internal.docstrings.ImportFallbacks +import scala.meta.internal.docstrings.MetalsSymbolLink +import scala.meta.internal.docstrings.WikiLink import scala.meta.internal.metals.MetalsEnrichments._ import scala.meta.internal.mtags import scala.meta.internal.parsing.Trees import scala.meta.io.AbsolutePath import scala.meta.tokens.Token.Comment +import org.eclipse.lsp4j.Location import org.eclipse.lsp4j.TextDocumentPositionParams class ScaladocDefinitionProvider( @@ -29,33 +34,498 @@ class ScaladocDefinitionProvider( params: TextDocumentPositionParams, isScala3: Boolean, ): Option[DefinitionResult] = { + val isJava = path.toString.endsWith(".java") + // A Java doc link uses Javadoc/Scaladoc-2 precedence, never the target's Scala 3 + // source order, matching the Java hover markers so both agree (scalameta/metals#3383). + val isDocScala3 = isScala3 && !isJava for { buffer <- buffers.get(path) position <- params.getPosition().toMeta(Input.String(buffer)) - symbol <- extractScalaDocLinkAtPos(buffer, position, isScala3) - contextSymbols = getContext(path, position) - scalaMetaSymbols = symbol.toScalaMetaSymbols(contextSymbols) - _ = scribe.debug( - s"looking for definition for scaladoc symbol: $symbol considering alternatives: ${scalaMetaSymbols - .map(_.showSymbol) - .mkString(", ")}" - ) - definitionResult <- scalaMetaSymbols.collectFirst { sym => - search(sym, path) match { - case Some(value) => value - } + // The link is in this file, so its language drives Scaladoc-vs-Javadoc + // parsing (e.g. a Java `Foo$` is not a value-force) (scalameta/metals#3383). + symbol <- extractScalaDocLinkAtPos(buffer, position, isDocScala3, isJava) + rawLink = symbol.rawSymbol + // Owner context and import fallbacks resolved the way hover does, so source + // go-to-definition and hover navigate identically (scalameta/metals#3383). + (context, fallbacksOf) = + linkContext(path, position, buffer, isJava, isScala3) + definitionResult <- resolveLinkLocations( + rawLink, + path, + isDocScala3, + context, + fallbacksOf, + isJava, + knownDocstringFile = Some(path), + ) match { + case Nil => None + case locations => + Some(DefinitionResult(locations.asJava, rawLink, None, None, rawLink)) } } yield definitionResult } + /** + * The owner context and a target->fallbacks function for the link under the + * cursor. Scala parses via `trees`/`ScaladocImportScope.at`, Java via + * `JavadocIndexer.sourceContext`, so links resolve on click too (scalameta/metals#3383). + */ + private def linkContext( + path: AbsolutePath, + position: Position, + buffer: String, + isJava: Boolean, + isScala3: Boolean, + ): (ContextSymbols, String => ImportFallbacks) = + if (isJava) { + val input = Input.VirtualFile(path.toURI.toString, buffer) + val (owner, importScope) = + JavadocIndexer.sourceContext(input, position.start)(EmptyReportContext) + val context = + owner.fold(ContextSymbols.empty)(ContextSymbols.fromSymbols(_, None)) + val fallbacksOf = (target: String) => + MetalsSymbolLink.fallbacksForTarget( + target, + isJava, + (name, rest, bare) => + ScaladocImportScope.fallbacksFor( + importScope, + isJava, + name, + rest, + bare, + ), + ) + (context, fallbacksOf) + } else { + val (context, enclosingNode, enclosingPackage) = + getContext(path, position, isScala3) + val fallbacksOf = + enclosingNode.fold((_: String) => ImportFallbacks.empty) { node => + val scope = + ScaladocImportScope.at( + node, + enclosingPackage, + new ScaladocImportScope.Cache, + ) + (target: String) => + MetalsSymbolLink.fallbacksForTarget( + target, + isJava, + (name, rest, bare) => + ScaladocImportScope.fallbacksFor( + scope, + isJava, + name, + rest, + bare, + ), + ) + } + (context, fallbacksOf) + } + + /** + * Resolves an already-extracted scaladoc link to the distinct locations of every + * candidate symbol. No cursor needed, so it also turns wiki links in rendered + * docstrings into navigable ones (e.g. on hover); aggregating candidate locations + * means the caller navigates only when the link is unambiguous. + * + * A member link resolves against the type that *declares* it, not the inheritance + * chain, so an inherited member (`Child#fromParent`) fails safely via owner capping + * rather than navigating elsewhere — a known limitation (scalameta/metals#3383). + */ + def resolveLinkLocations( + rawLink: String, + fromPath: AbsolutePath, + isScala3: Boolean, + contextSymbols: => ContextSymbols = ContextSymbols.empty, + // Fallback candidates for any link target, from the docstring's import scope — + // used for the link and (for a member link) its owner cap (scalameta/metals#3383). + fallbacksOf: String => ImportFallbacks = _ => ImportFallbacks.empty, + // The documentation's OWN language, not the hovered file's — so a Java `Foo$` + // is a type, not a Scaladoc value-force (scalameta/metals#3383). + isJava: Boolean = false, + // The docstring's own file when the caller knows it, so a same-file sibling + // outranks an import even if the enclosing symbol can't resolve (scalameta/metals#3383). + knownDocstringFile: Option[AbsolutePath] = None, + ): List[Location] = { + val context = contextSymbols + def fileOf(loc: Location): Option[AbsolutePath] = + Try(loc.getUri.toAbsolutePath).toOption + // The docstring's own file drives same-compilation-unit precedence. + val docstringFile = knownDocstringFile.orElse( + context.enclosingSymbol + .flatMap(sym => + resolveSymbol(sym, fromPath).flatMap(_.locations.asScala.headOption) + ) + .flatMap(fileOf) + ) + def resolveContextFor( + link: String, + ctx: ContextSymbols, + ): List[Location] = + resolveGroups( + ScalaDocLink(link, isScala3, isJava).toScalaMetaSymbolGroups(ctx), + fromPath, + isScala3, + ) + def resolveCands(candidates: List[String]): List[Location] = + candidates + .flatMap(resolveFallback(_, fromPath, isScala3, isJava)) + .distinct + + // A member link's simple owner (`Child#m`): its binding caps where the member + // may be found, so an off-owner member fails safely (scalameta/metals#3383). + val ownerLink: Option[String] = + MetalsSymbolLink.memberLinkOwner(rawLink, isJava) + lazy val fullFallbacks = fallbacksOf(rawLink) + lazy val ownerFallbacks = ownerLink.map(fallbacksOf) + + // One rung of the binding ladder: `Some(locs)` decides (`Nil` = owner binds here + // but the member doesn't, a safe failure); `None` descends (scalameta/metals#3383). + def rung( + full: => List[Location], + owner: => List[Location], + ): Option[List[Location]] = { + val resolved = full + if (resolved.nonEmpty) Some(resolved) + else if (ownerLink.isDefined && owner.nonEmpty) Some(List.empty) + else None + } + def ownerAt(ctx: ContextSymbols): List[Location] = + ownerLink.map(resolveContextFor(_, ctx)).getOrElse(List.empty) + + // Binding precedence: enclosing/local or same-unit sibling first, then the file's + // imports, then a same-package sibling from another file, then the implicit scope. + // With the file unknown, same-package candidates count as other-file (scalameta/metals#3383). + val enclosingCtx = context.copy(enclosingPackagePath = None) + val packageCtx = + context.copy(enclosingSymbol = None, alternativeEnclosingSymbol = None) + def unitsOf(locs: List[Location]): (List[Location], List[Location]) = + docstringFile match { + case Some(file) => locs.partition(loc => fileOf(loc).contains(file)) + case None => (List.empty[Location], locs) + } + lazy val (sameUnit, otherUnit) = + unitsOf(resolveContextFor(rawLink, packageCtx)) + lazy val (ownerSameUnit, ownerOtherUnit) = unitsOf(ownerAt(packageCtx)) + + // The file's imports, scope-aware: a binding is `(scopeIndex, isExplicit, locs)`. + // A nearer scope shadows a farther one only at equal-or-higher precedence, so an + // outer explicit and inner wildcard stay mutually ambiguous (scalameta/metals#3383). + type Binding = (Int, Boolean, List[Location]) + def rankOf(b: Binding): Int = b match { + case (scope, explicit, _) => -scope * 2 + (if (explicit) 1 else 0) + } + def importBindings(fb: ImportFallbacks): List[Binding] = + fb.importScopes.zipWithIndex.flatMap { + case ((explicit, wildcard), scope) => + val e = resolveCands(explicit) + val w = resolveCands(wildcard) + (if (e.nonEmpty) List((scope, true, e)) else Nil) ++ + (if (w.nonEmpty) List((scope, false, w)) else Nil) + } + def shadows(y: Binding, x: Binding): Boolean = { + val (j, qExplicit, _) = y + val (i, pExplicit, _) = x + (j < i && (qExplicit || !pExplicit)) || + (j == i && qExplicit && !pExplicit) + } + def survivors(bindings: List[Binding]): List[Binding] = + bindings.filter(x => !bindings.exists(y => (y ne x) && shadows(y, x))) + def scalaImports: Option[List[Location]] = { + val full = survivors(importBindings(fullFallbacks)) + def fullLocs = full.flatMap(_._3).distinct + ownerFallbacks match { + case None => if (full.nonEmpty) Some(fullLocs) else None + case Some(ofb) => + val owner = survivors(importBindings(ofb)) + val ownerLocs = owner.flatMap(_._3).distinct + if (owner.isEmpty) if (full.nonEmpty) Some(fullLocs) else None + // If the owner name itself binds ambiguously it can't be referred to, so + // the member link fails safely instead of guessing (scalameta/metals#3383). + else if (ownerLocs.lengthCompare(1) > 0) Some(List.empty) + else { + val ownerRank = owner.map(rankOf).max + val accepted = full.filter(rankOf(_) >= ownerRank) + if (accepted.nonEmpty) Some(accepted.flatMap(_._3).distinct) + else Some(List.empty) + } + } + } + + // Java: one file-level scope; `import p.*` and the implicit `java.lang.*` are + // equal-precedence on-demand imports (a name in both is ambiguous), while an + // explicit single import and a same-package sibling outrank them (scalameta/metals#3383). + def explicitCands(fb: ImportFallbacks): List[String] = + fb.importScopes.flatMap(_._1) + def onDemandCands(fb: ImportFallbacks): List[String] = + fb.importScopes.flatMap(_._2) ++ fb.implicitImports + def ownerCands(select: ImportFallbacks => List[String]): List[Location] = + ownerFallbacks.map(ofb => resolveCands(select(ofb))).getOrElse(List.empty) + + val ladder: List[() => Option[List[Location]]] = + if (isJava) + List( + () => + rung( + resolveContextFor(rawLink, enclosingCtx), + ownerAt(enclosingCtx), + ), + () => rung(sameUnit, ownerSameUnit), + () => + rung( + resolveCands(explicitCands(fullFallbacks)), + ownerCands(explicitCands), + ), + () => rung(otherUnit, ownerOtherUnit), + () => + rung( + resolveCands(onDemandCands(fullFallbacks)), + ownerCands(onDemandCands), + ), + ) + else + List( + () => + rung( + resolveContextFor(rawLink, enclosingCtx), + ownerAt(enclosingCtx), + ), + () => rung(sameUnit, ownerSameUnit), + () => scalaImports, + () => rung(otherUnit, ownerOtherUnit), + () => + rung( + resolveCands(fullFallbacks.implicitImports), + ownerCands(_.implicitImports), + ), + ) + + ladder.iterator + .map(_()) + .collectFirst { case Some(locations) => locations } + .getOrElse(List.empty) + } + + private def resolveFallback( + fqn: String, + fromPath: AbsolutePath, + isScala3: Boolean, + isJava: Boolean, + ): List[Location] = + resolveGroups( + ScalaDocLink(fqn, isScala3, isJava) + .toScalaMetaSymbolGroups(ContextSymbols.empty), + fromPath, + isScala3, + ) + + private def resolveGroups( + groups: List[List[ScalaDocLinkSymbol]], + fromPath: AbsolutePath, + isScala3: Boolean, + ): List[Location] = { + val direct = resolveGroupsRaw(groups, fromPath, isScala3) + if (direct.nonEmpty) direct + else resolveBoundaryVariants(groups, fromPath) + } + + private def resolveGroupsRaw( + groups: List[List[ScalaDocLinkSymbol]], + fromPath: AbsolutePath, + isScala3: Boolean, + ): List[Location] = + groups + // Within a group, take the precedence winner; across groups, aggregate the + // distinct locations so we only navigate when the link is unambiguous. Scala + // 2 Scaladoc resolves an ambiguous `[[Name]]` type-before-value; Scala 3 + // Scaladoc instead binds the entity FIRST in source order, so there we order + // a same-file companion pair by definition position (see sourceFirstLocations). + .flatMap(group => + if (isScala3) sourceFirstLocations(group, fromPath) + else + group + .collectFirst(scala.Function.unlift(search(_, fromPath))) + .toList + .flatMap(_.locations.asScala) + ) + .distinct + + /** + * The Scala 3 "first in source order" winner of a precedence-ordered group: when + * several candidates resolve (a companion object + class), pick the earliest-defined + * one, but only when they share a file, with ties broken by the original precedence + * order (type first) (scalameta/metals#3383). + */ + private def sourceFirstLocations( + group: List[ScalaDocLinkSymbol], + fromPath: AbsolutePath, + ): List[Location] = { + val resolved = group.flatMap { sym => + search(sym, fromPath).toList + .map(_.locations.asScala.toList) + .filter(_.nonEmpty) + } + resolved match { + case Nil => Nil + case single :: Nil => single + case many => + val sameFile = many.map(_.head.getUri).distinct.lengthCompare(1) == 0 + if (sameFile) + many.minBy { locs => + val start = locs.head.getRange.getStart + (start.getLine, start.getCharacter) + } + else many.head + } + } + + /** The most boundary variants to resolve on one link, bounding the work. */ + private val maxBoundaryCandidates = 512 + + /** + * Boundary-recovery fallback when nothing resolved directly: retry the `/`<->`.` + * assignments `guessFromPath` had to commit to (`a/b/util/Tool` -> `a/b/util.Tool`). + * Variants resolve by descriptor-kind precedence (type before value before method), + * aggregating within a kind and capping the count (scalameta/metals#3383). + */ + private def resolveBoundaryVariants( + groups: List[List[ScalaDocLinkSymbol]], + fromPath: AbsolutePath, + ): List[Location] = { + val variants = groups.iterator + .flatMap(_.iterator) + .flatMap { + case StringSymbol(symbol) => + boundaryVariants(symbol).iterator.map(StringSymbol(_)) + case MethodSymbol(prefix) => + boundaryVariants(prefix).iterator.map(MethodSymbol(_)) + } + .take(maxBoundaryCandidates) + .toList + def resolvedOfKind(pred: ScalaDocLinkSymbol => Boolean): List[Location] = + variants + .filter(pred) + .flatMap(search(_, fromPath).toList.flatMap(_.locations.asScala)) + .distinct + val isType: ScalaDocLinkSymbol => Boolean = { + case StringSymbol(symbol) => symbol.endsWith("#") + case _ => false + } + val isValue: ScalaDocLinkSymbol => Boolean = { + case StringSymbol(symbol) => !symbol.endsWith("#") + case _ => false + } + val isMethod: ScalaDocLinkSymbol => Boolean = _.isInstanceOf[MethodSymbol] + List(isType, isValue, isMethod).iterator + .map(resolvedOfKind) + .find(_.nonEmpty) + .getOrElse(List.empty) + } + + /** + * The most `/`<->`.` boundary assignments to enumerate for one symbol, + * mirroring [[maxOwnershipCombinations]]: a deeper chain falls back to the two + * extremes (all-package and all-object) plus each single flip. + */ + private val maxBoundaryCombinations = 64 + + /** + * Every `/`<->`.` assignment of a symbol's package/object boundaries, not just one + * flip, so a chain of several wrong boundaries resolves. Boundaries inside a + * backtick-escaped name and a trailing `.` descriptor are left untouched, and the + * original assignment is dropped (scalameta/metals#3383). + */ + private def boundaryVariants(symbol: String): List[String] = { + val positions = boundaryPositions(symbol) + if (positions.isEmpty) Nil + else if ((1L << positions.length) > maxBoundaryCombinations) { + val allPackage = setBoundaries(symbol, positions, '/') + val allObject = setBoundaries(symbol, positions, '.') + val singles = + positions.map(p => symbol.updated(p, flipBoundary(symbol.charAt(p)))) + (allPackage :: allObject :: singles).filterNot(_ == symbol).distinct + } else + (0 until (1 << positions.length)).iterator + .map(mask => setBoundaries(symbol, positions, mask)) + .filterNot(_ == symbol) + .toList + .distinct + } + + private def flipBoundary(c: Char): Char = if (c == '/') '.' else '/' + + /** Sets every boundary at `positions` to `c`. */ + private def setBoundaries( + s: String, + positions: List[Int], + c: Char, + ): String = { + val sb = new StringBuilder(s) + positions.foreach(sb.setCharAt(_, c)) + sb.toString + } + + /** Sets each boundary to `.` where its `mask` bit is set, else `/`. */ + private def setBoundaries( + s: String, + positions: List[Int], + mask: Int, + ): String = { + val sb = new StringBuilder(s) + positions.zipWithIndex.foreach { case (pos, bit) => + sb.setCharAt(pos, if ((mask & (1 << bit)) != 0) '.' else '/') + } + sb.toString + } + + /** + * The indices of the `/` and `.` package/object boundaries in a symbol, outside + * any backtick-escaped name and excluding a trailing `.` (the value descriptor, + * not a boundary). + */ + private def boundaryPositions(symbol: String): List[Int] = { + val buf = List.newBuilder[Int] + var inBacktick = false + var i = 0 + while (i < symbol.length) { + symbol.charAt(i) match { + case '`' => inBacktick = !inBacktick + case ('/' | '.') if !inBacktick && i != symbol.length - 1 => buf += i + case _ => + } + i += 1 + } + buf.result() + } + private def search(symbol: ScalaDocLinkSymbol, path: AbsolutePath) = symbol match { case method: MethodSymbol => findAllOverLoadedMethods(method, path) case StringSymbol(symbol) => - Try(destinationProvider.fromSymbol(symbol, Some(path))).toOption.flatten - .filter(_.symbol == symbol) + resolveSymbol(symbol, path).filter(_.symbol == symbol) } + /** + * Resolves a single candidate. Many speculative forms are tried per link and most + * miss, so a failure here must never abort the others: a malformed symbol is + * filtered and an exception is logged and treated as a miss (scalameta/metals#3383). + */ + private def resolveSymbol( + symbol: String, + path: AbsolutePath, + ): Option[DefinitionResult] = + if (mtags.Symbol.validated(symbol).isLeft) None + else + Try(destinationProvider.fromSymbol(symbol, Some(path))) match { + case Success(result) => result + case Failure(error) => + scribe.debug(s"failed to resolve scaladoc candidate `$symbol`", error) + None + } + private def findAllOverLoadedMethods( method: MethodSymbol, path: AbsolutePath, @@ -65,10 +535,8 @@ class ScaladocDefinitionProvider( var ok: Boolean = true while (ok) { val currentSymbol = method.symbol(ident) - Try( - destinationProvider.fromSymbol(currentSymbol, Some(path)) - ) match { - case Success(Some(value)) if value.symbol == currentSymbol => + resolveSymbol(currentSymbol, path) match { + case Some(value) if value.symbol == currentSymbol => ident += 1 results.addOne(value) case _ => ok = false @@ -92,6 +560,7 @@ class ScaladocDefinitionProvider( buffer: String, position: Position, isScala3: Boolean, + isJava: Boolean, ) = for { tokens <- buffer.safeTokenize(Trees.defaultTokenizerDialect).toOption @@ -100,62 +569,160 @@ class ScaladocDefinitionProvider( } if comment.text.startsWith("/**") && comment.text.endsWith("*/") offset = position.start - comment.start - symbol <- ScalaDocLink.atOffset(comment.text, offset, isScala3) + symbol <- ScalaDocLink.atOffset(comment.text, offset, isScala3, isJava) } yield symbol + /** + * The owner context at `pos` plus the deepest enclosing tree node and the dotted + * enclosing package — all source go-to-definition needs to compute the same import + * fallbacks hover uses, so both paths share one analysis (scalameta/metals#3383). + */ private def getContext( path: AbsolutePath, pos: Position, - ): ContextSymbols = { + isScala3: Boolean, + ): (ContextSymbols, Option[scala.meta.Tree], String) = { + // Encode a declaration name as its SemanticDB descriptor, matching the hover + // path. SemanticDB backticks by CHARACTERS (a dotted/`` name is wrapped), + // not by keyword (`type` stays `type`), so it's dialect-independent (scalameta/metals#3383). + def descName(value: String): String = { + def plain(c: Char) = c.isLetterOrDigit || c == '_' || c == '$' + def operator(c: Char) = "!#%&*+-/:<=>?@\\^|~".contains(c) + if (value.nonEmpty && (value.forall(plain) || value.forall(operator))) + value + else s"`$value`" + } def extractName(ref: Term): String = ref match { - case Term.Select(qual, name) => s"${extractName(qual)}/${name.value}" - case Term.Name(name) => name + case Term.Select(qual, name) => + s"${extractName(qual)}/${descName(name.value)}" + case name: Term.Name => descName(name.value) case _ => "" } + // The Scala 3 synthetic object owning this file's top-level members + // (`Main.scala` → `Main$package`), matching hover (scalameta/metals#3383). + val filePackageObject: String = { + val filename = path.filename + val dot = filename.lastIndexOf('.') + val stem = if (dot > 0) filename.substring(0, dot) else filename + descName(s"$stem$$package") + } + def enclosedChild(tree: Tree): Option[Tree] = tree.children .find { child => child.pos.start <= pos.start && pos.start <= child.pos.end } + // The owner context contributed by ONE tree node: a package extends the path, a + // template/case/given becomes the enclosing symbol (companion as alternative), + // everything else is transparent, mirroring the indexer (scalameta/metals#3383). + def contextOf( + tree: Tree, + enclosingPackagePath: String, + enclosingSymbol: String, + alternativeEnclosingSymbol: Option[String], + ): (String, String, Option[String]) = + tree match { + case Pkg(name, _) => + ( + s"$enclosingPackagePath${extractName(name)}/", + enclosingSymbol, + None, + ) + case d: Pkg.Object => + // A package object extends its package by its own name and owns a + // `package.` template, matching the indexer (scalameta/metals#3383). + ( + s"$enclosingPackagePath${descName(d.name.value)}/", + s"${enclosingSymbol}package.", + None, + ) + case d: Defn.Object => + ( + enclosingPackagePath, + s"$enclosingSymbol${descName(d.name.value)}.", + None, + ) + case d: Defn.Class => + ( + enclosingPackagePath, + s"$enclosingSymbol${descName(d.name.value)}#", + None, + ) + case d: Defn.Trait => + ( + enclosingPackagePath, + s"$enclosingSymbol${descName(d.name.value)}#", + None, + ) + case d: Defn.Enum => + ( + enclosingPackagePath, + s"$enclosingSymbol${descName(d.name.value)}#", + Some(s"$enclosingSymbol${descName(d.name.value)}."), + ) + case d: Defn.EnumCase => + // Enum cases live in the enum's COMPANION, so build off its alternative + // (`E.`), not its type; a parameterized case is a case class (`Case#`), a + // bare case a value (`Case.`) (scalameta/metals#3383). + val base = alternativeEnclosingSymbol.getOrElse(enclosingSymbol) + val name = descName(d.name.value) + if (d.ctor.paramss.flatten.nonEmpty) + ( + enclosingPackagePath, + s"$base$name#", + Some(s"$base$name."), + ) + else + ( + enclosingPackagePath, + s"$base$name.", + Some(s"$base$name#"), + ) + case d: Defn.Given if d.name.value.nonEmpty => + // A NAMED given owns members under its type form (`name#`) but is a value + // (`name.`); offer both so either link resolves (scalameta/metals#3383). + ( + enclosingPackagePath, + s"$enclosingSymbol${descName(d.name.value)}#", + Some(s"$enclosingSymbol${descName(d.name.value)}."), + ) + case (_: Defn.Def | _: Defn.Val | _: Defn.Var | _: Defn.Type) + if isScala3 && enclosingSymbol.isEmpty => + // A Scala 3 top-level member compiles into the file's synthetic + // `$package` object, so its relative links resolve against that owner + // (package-relative; `ContextSymbols` prepends the package) (scalameta/metals#3383). + ( + enclosingPackagePath, + s"$filePackageObject.", + None, + ) + // An ANONYMOUS given has no syntactic name, and this tree-only path can't + // reconstruct the compiler's synthetic `given_` symbol, so fall through + // rather than build a bogus owner — a known gap (scalameta/metals#3383). + case _ => + (enclosingPackagePath, enclosingSymbol, alternativeEnclosingSymbol) + } + def loop( tree: Tree, enclosingPackagePath: String = "", enclosingSymbol: String = "", alternativeEnclosingSymbol: Option[String] = None, - ): (String, String, Option[String]) = { + ): (String, String, Option[String], Tree) = { val ( enclosingPackagePath1, enclosingSymbol1, alternativeEnclosingSymbol1, ) = - tree match { - case Pkg(name, _) => - ( - s"$enclosingPackagePath${extractName(name)}/", - enclosingSymbol, - None, - ) - case d: Defn.Object => - (enclosingPackagePath, s"$enclosingSymbol${d.name.value}.", None) - case d: Defn.Class => - (enclosingPackagePath, s"$enclosingSymbol${d.name.value}#", None) - case d: Defn.Trait => - (enclosingPackagePath, s"$enclosingSymbol${d.name.value}#", None) - case d: Defn.Enum => - ( - enclosingPackagePath, - s"$enclosingSymbol${d.name.value}#", - Some(s"$enclosingSymbol${d.name.value}."), - ) - case d: Defn.Given => - (enclosingPackagePath, s"$enclosingSymbol${d.name.value}#", None) - case _ => - (enclosingPackagePath, enclosingSymbol, alternativeEnclosingSymbol) - } - + contextOf( + tree, + enclosingPackagePath, + enclosingSymbol, + alternativeEnclosingSymbol, + ) enclosedChild(tree) .map( loop( @@ -166,7 +733,36 @@ class ScaladocDefinitionProvider( ) ) .getOrElse { - (enclosingPackagePath1, enclosingSymbol1, alternativeEnclosingSymbol1) + // The documented member is the first child following the docstring comment. + // Apply ITS OWN context (relative links resolve against the member) and + // return it as the node so the import-scope walk starts there (scalameta/metals#3383). + tree.children.find(_.pos.start >= pos.start) match { + case Some(member0) => + // Recent scalameta wraps package statements in a `Pkg.Body`; descend + // through it so a top-level member keeps its owner (scalameta/metals#3383). + val member = member0 match { + case body: Pkg.Body => + body.children + .find(_.pos.start >= pos.start) + .getOrElse(member0) + case other => other + } + val (pkg, sym, alt) = + contextOf( + member, + enclosingPackagePath1, + enclosingSymbol1, + alternativeEnclosingSymbol1, + ) + (pkg, sym, alt, member) + case None => + ( + enclosingPackagePath1, + enclosingSymbol1, + alternativeEnclosingSymbol1, + tree, + ) + } } } @@ -177,113 +773,441 @@ class ScaladocDefinitionProvider( enclosingPackagePath, enclosingSymbol, alternativeEnclosingSymbol, + enclosingNode, ) = loop(tree) - ContextSymbols( - enclosingPackagePath, - enclosingSymbol, - alternativeEnclosingSymbol, + ( + ContextSymbols( + enclosingPackagePath, + enclosingSymbol, + alternativeEnclosingSymbol, + ), + Some(enclosingNode), + enclosingPackagePath.stripSuffix("/").replace('/', '.'), ) } - .getOrElse(ContextSymbols.empty) + .getOrElse((ContextSymbols.empty, None, "")) } } -case class ScalaDocLink(rawSymbol: String, isScala3: Boolean) { +case class ScalaDocLink( + rawSymbol: String, + isScala3: Boolean, + isJava: Boolean = false, +) { + + // Normalize Javadoc syntax so the link resolves against SemanticDB: drop a leading + // `module/`, treat a `##fragment` anchor as the type, and a leading `#` (`{@link + // #foo}`) as `this.foo` (scalameta/metals#3383). + private val (symbolText: String, isLeadingHash: Boolean) = { + val withoutModule = ScalaDocLink.stripModulePrefix(rawSymbol) + val withoutFragment = ScalaDocLink.stripDocFragment(withoutModule) + if (withoutFragment.startsWith("#")) + ("this." + withoutFragment.drop(1), true) + else (withoutFragment, false) + } def toScalaMetaSymbols( contextSymbols: => ContextSymbols ): List[ScalaDocLinkSymbol] = - if (rawSymbol.isEmpty()) List.empty + toScalaMetaSymbolGroups(contextSymbols).flatten + + /** + * Candidate symbols grouped by interpretation (object member vs nested type, + * keyword-wrapped vs raw, …), precedence-ordered within a group. A resolver picks + * the winner per group, then treats differing groups as ambiguous (scalameta/metals#3383). + */ + def toScalaMetaSymbolGroups( + contextSymbols: => ContextSymbols + ): List[List[ScalaDocLinkSymbol]] = + if (symbolText.isEmpty()) List.empty else { val (symbol0, symbolType) = symbolWithType - val symbol = fixPackages(symbol0) - - val optIndexOfSlash = - symbol.findIndicesOf(List('/')).headOption - val withPrefixes: List[String] = - optIndexOfSlash match { - case Some(indexOfSlash) => - symbol.splitAt(indexOfSlash + 1) match { - // raw symbol [[this.]], e.g. [[this.someMethod]] - // we substitute `this.` for `enclosingSymbol` - case ("this/", rest) => contextSymbols.withThis(rest) - // raw symbol [[package.]], e.g. [[package.SomeObject.someMethod]] - // we substitute `package.` for `enclosingPackagePath` - case ("package/", rest) => contextSymbols.withPackage(rest) - // the symbol has some package defined e.g. [[a.b.SomeThing]] - // we search for `package.` and `` - case _ => contextSymbols.withPackage(symbol) ++ List(symbol) - } - // symbol has no package defined e.g. [[someMethod]] - // we search for [[this.]] and [[package.]] - case None => - contextSymbols.withThis(symbol) ++ - contextSymbols.withPackage(symbol) - } + fixPackages(symbol0).map(candidatesFor(_, symbolType, contextSymbols)) + } + + private def candidatesFor( + symbol: String, + symbolType: ScalaDocLink.SymbolType, + contextSymbols: => ContextSymbols, + ): List[ScalaDocLinkSymbol] = { + val optIndexOfSlash = + symbol.findIndicesOf(List('/')).headOption + val withPrefixes: List[String] = + optIndexOfSlash match { + case Some(indexOfSlash) => + symbol.splitAt(indexOfSlash + 1) match { + // raw symbol [[this.]]: substitute `this.` for `enclosingSymbol`; + // a same-class constructor (`#Foo`) wins over a like-named method (scalameta/metals#3383). + case ("this/", rest) => + constructorPrefixes(rest, contextSymbols) ++ + contextSymbols.withThis(rest) + // raw symbol [[package.]], e.g. [[package.SomeObject.someMethod]] + // we substitute `package.` for `enclosingPackagePath` + case ("package/", rest) => contextSymbols.withPackage(rest) + // the symbol has some package defined e.g. [[a.b.SomeThing]] + // we search for `package.` and `` + case _ => contextSymbols.withPackage(symbol) ++ List(symbol) + } + // symbol has no package defined e.g. [[someMethod]] + // we search for [[this.]] and [[package.]] + case None => + contextSymbols.withThis(symbol) ++ + contextSymbols.withPackage(symbol) + } + // For each prefix also try its package-object member form (`pkg/package.member`), + // type-first so a class `pkg.Target` still beats a package-object value, while a + // bare package-object member (`[[answer]]`) still resolves (scalameta/metals#3383). + def expand(prefix: String): List[ScalaDocLinkSymbol] = { + val paths = prefix :: packageObjectForms(prefix) symbolType match { - case ScalaDocLink.SymbolType.Method => - withPrefixes.flatMap(sym => List(MethodSymbol(sym))) + case ScalaDocLink.SymbolType.Method => paths.map(MethodSymbol(_)) case ScalaDocLink.SymbolType.Value => - withPrefixes.flatMap(sym => - List(StringSymbol(s"$sym."), MethodSymbol(sym)) - ) + paths.map(p => StringSymbol(s"$p.")) ++ paths.map(MethodSymbol(_)) case ScalaDocLink.SymbolType.Type => - withPrefixes.flatMap(sym => List(StringSymbol(s"$sym#"))) + paths.map(p => StringSymbol(s"$p#")) case ScalaDocLink.SymbolType.Any => - withPrefixes.flatMap(sym => - List( - StringSymbol(s"$sym#"), - StringSymbol(s"$sym."), - MethodSymbol(sym), - ) - ) + paths.map(p => StringSymbol(s"$p#")) ++ + paths.map(p => StringSymbol(s"$p.")) ++ + paths.map(MethodSymbol(_)) } } + withPrefixes.flatMap(expand) + } - private def symbolWithType: (String, ScalaDocLink.SymbolType) = - rawSymbol.findIndicesOf(List('(', '[')).headOption match { - case Some(index) => - val toDrop = rawSymbol.length() - index - (rawSymbol.dropRight(toDrop), ScalaDocLink.SymbolType.Method) - case None => - rawSymbol.last match { - // e.g. [[a.b.Foo$]] - // forces link to refer to a value (an object, a value, a given) - case '$' => (rawSymbol.dropRight(1), ScalaDocLink.SymbolType.Value) - // e.g. [[a.b.Foo!]] - // forces link to refer to a type (a class, a type alias, a type member) - case '!' => (rawSymbol.dropRight(1), ScalaDocLink.SymbolType.Type) - // no meaningful suffix, e.g. [[a.b.Foo]] - // we search for types then values - case _ => (rawSymbol, ScalaDocLink.SymbolType.Any) - } - } + private def symbolWithType: (String, ScalaDocLink.SymbolType) = { + // A `[...]` type-argument list is stripped (`Map[K, V]` → `Map`) and must not + // turn the link into a method — only a `(...)` signature does; a `foo[T]` still + // resolves via the value/method fallback of an `Any` link (scalameta/metals#3383). + val base = MetalsSymbolLink.stripTypeArgs(symbolText) + if (base.isEmpty) (symbolText, ScalaDocLink.SymbolType.Any) + else + base.findIndicesOf(List('(')).headOption match { + case Some(index) => + (base.take(index), ScalaDocLink.SymbolType.Method) + case None => + // A backslash-escaped `\!`/`\$` is a literal member name, not a force + // suffix, so it resolves as an ordinary link (scalameta/metals#3383). + val escaped = + base.length >= 2 && base.charAt(base.length - 2) == '\\' + base.last match { + // The value-force `$` is Scaladoc-only: in Javadoc `$` is an ordinary + // identifier char (`Foo$`), so it's not interpreted there. The type-force + // `!` is never a Java identifier char, so it holds in both (scalameta/metals#3383). + // e.g. [[a.b.Foo$]] + // forces link to refer to a value (an object, a value, a given) + case '$' if !escaped && !isJava => + (base.dropRight(1), ScalaDocLink.SymbolType.Value) + // e.g. [[a.b.Foo!]] + // forces link to refer to a type (a class, a type alias, a type member) + case '!' if !escaped => + (base.dropRight(1), ScalaDocLink.SymbolType.Type) + // no meaningful suffix, e.g. [[a.b.Foo]] + // we search for types then values + case _ => (base, ScalaDocLink.SymbolType.Any) + } + } + } /** * Replace `.` with `\` for packages and wrap with backticks when needed. * e.g. a.b.c.A.O to a/b/c/A.O */ - private def fixPackages(symbol: String) = - mtags.Symbol.guessFromPath(symbol, isScala3).value + private def fixPackages(symbol: String): List[String] = { + // `Type#member` (a qualified member link, e.g. the Javadoc + // `GsonBuilder#setPrettyPrinting()`): convert the type and member separately, + // then join with the `#` descriptor. Otherwise `guessFromPath` backtick-wraps + // the whole thing as a single identifier. + val hash = separatorHash(symbol) + if (hash >= 0) { + val typePart = symbol.substring(0, hash) + val rawMember = symbol.substring(hash + 1) + // Javadoc references a constructor by the class's simple name (`Foo#Foo`), + // but SemanticDB names constructors ``. + val member = + if (rawMember == simpleName(typePart)) "" else rawMember + val typePath = mtags.Symbol.guessFromPath(typePart, isScala3).value + for { + typeForm <- typeForms(typePath) + memberForm <- memberForms(member) + } yield typeForm + "#" + memberForm + } else + typeForms(mtags.Symbol.guessFromPath(symbol, isScala3).value) + } + + /** + * The descriptor forms to try for a member name. A Java member may be a Scala + * keyword (`Thread#yield`) that `guessFromPath` backtick-wraps, but Java SemanticDB + * stores it unwrapped, so try the raw name too (scalameta/metals#3383). + */ + private def memberForms(member: String): List[String] = { + val wrapped = mtags.Symbol.guessFromPath(member, isScala3).value + if (wrapped != member && isPlainIdentifier(member)) List(wrapped, member) + else List(wrapped) + } + + private def isPlainIdentifier(s: String): Boolean = + s.nonEmpty && s.forall(c => c.isLetterOrDigit || c == '_' || c == '$') + + /** + * Index of the `#` separating a type from a member, or -1. It must follow an + * identifier char (so operator members like `##` aren't split) and lie outside a + * backtick-escaped name (so `` `Foo#Bar` `` isn't split) (scalameta/metals#3383). + */ + private def separatorHash(symbol: String): Int = { + var inBacktick = false + var i = 0 + var result = -1 + while (i < symbol.length && result < 0) { + symbol.charAt(i) match { + case '`' => inBacktick = !inBacktick + case '#' + if !inBacktick && i > 0 && i + 1 < symbol.length && + isIdentifierChar(symbol(i - 1)) => + result = i + case _ => + } + i += 1 + } + result + } + + /** + * The interpretations to try for a converted type path: each object-member (`.`) + * vs nested-type (`#`) boundary assignment, and keyword-wrapped vs unwrapped (a + * Java segment may be a Scala keyword, unwrapped in Java SemanticDB) (scalameta/metals#3383). + */ + private def typeForms(path: String): List[String] = + keywordBases(path).flatMap(ownershipForms).distinct + + /** A path and, if it differs, its keyword-unwrapped form (see [[typeForms]]). */ + private def keywordBases(path: String): List[String] = { + val unwrapped = unwrapKeywords(path) + if (unwrapped == path) List(path) else List(path, unwrapped) + } + + /** + * A package-object member has the symbol `pkg/package.member`, which `guessFromPath` + * can't produce from a dotted path, so for `pkg/member` also try that form + * (resolving `List`/`Nil` etc.). A Scala 3 `File$package` top-level member stays + * unresolvable (scalameta/metals#3383). + */ + private def packageObjectForms(path: String): List[String] = { + var inBacktick = false + var lastSlash = -1 + var separatorAfterSlash = false + var i = 0 + while (i < path.length) { + path.charAt(i) match { + case '`' => inBacktick = !inBacktick + case '/' if !inBacktick => lastSlash = i; separatorAfterSlash = false + case ('.' | '#') if !inBacktick => separatorAfterSlash = true + case _ => + } + i += 1 + } + val member = if (lastSlash >= 0) path.substring(lastSlash + 1) else "" + if (lastSlash < 0 || member.isEmpty || separatorAfterSlash) Nil + else List(path.substring(0, lastSlash + 1) + "package." + member) + } + + /** + * The most object-member (`.`) vs nested-type (`#`) assignments to enumerate + * for one type path. A budget rather than a boundary count, so adding one more + * boundary doesn't fall off a cliff: every chain whose `2^boundaries` fits is + * explored fully (64 covers six boundaries), and only deeper ones fall back to + * the two extremes. + */ + private val maxOwnershipCombinations = 64 + + /** + * Every object-member (`.`) vs nested-type (`#`) assignment of `path`'s type-level + * dots, so a mixed chain (`Outer.Middle#Inner`) resolves, not just the extremes; + * beyond `maxOwnershipCombinations` only the two extremes are tried (scalameta/metals#3383). + */ + private def ownershipForms(path: String): List[String] = { + val dots = dotIndices(path) + if (dots.isEmpty) List(path) + else if ((1L << dots.length) > maxOwnershipCombinations) + List(path, replaceWithHash(path, dots.toSet)) + else + (0 until (1 << dots.length)).toList.map { mask => + val chosen = dots.zipWithIndex.collect { + case (idx, bit) if (mask & (1 << bit)) != 0 => idx + } + replaceWithHash(path, chosen.toSet) + } + } + + /** Indices of the object-member dots in a type path, ignoring backtick spans. */ + private def dotIndices(path: String): List[Int] = { + val buf = List.newBuilder[Int] + var inBacktick = false + var i = 0 + while (i < path.length) { + path.charAt(i) match { + case '`' => inBacktick = !inBacktick + case '.' if !inBacktick => buf += i + case _ => + } + i += 1 + } + buf.result() + } + + /** Replaces the characters at `indices` with the nested-type separator `#`. */ + private def replaceWithHash(path: String, indices: Set[Int]): String = { + val sb = new StringBuilder(path) + indices.foreach(sb.setCharAt(_, '#')) + sb.toString + } + + /** + * Removes the backticks `guessFromPath` added around plain-identifier segments + * (Scala keywords like `type`), since Java SemanticDB stores them unwrapped. + * Backticks around names escaped for special characters (e.g. `` `My.Type` ``) + * are kept. + */ + private def unwrapKeywords(path: String): String = { + val sb = new StringBuilder(path.length) + var i = 0 + while (i < path.length) { + if (path.charAt(i) == '`') { + val end = path.indexOf('`', i + 1) + if (end < 0) { sb.append(path.substring(i)); i = path.length } + else { + val content = path.substring(i + 1, end) + if (isPlainIdentifier(content)) sb.append(content) + else sb.append(path.substring(i, end + 1)) + i = end + 1 + } + } else { + sb.append(path.charAt(i)) + i += 1 + } + } + sb.toString + } + + /** The simple (last `.`-separated) name of a dotted type path. */ + private def simpleName(typePath: String): String = + typePath.substring(typePath.lastIndexOf('.') + 1) + + /** + * For a leading-`#` link whose member is the enclosing class name (the Javadoc + * same-class constructor `{@link #Foo(int)}`), the class's `` prefixes, so it + * resolves to the constructor, not a like-named member (scalameta/metals#3383). + */ + private def constructorPrefixes( + member: String, + contextSymbols: ContextSymbols, + ): List[String] = + if ( + isLeadingHash && + contextSymbols.enclosingSymbol.exists(enclosingClassName(_) == member) + ) + contextSymbols.withThis( + mtags.Symbol.guessFromPath("", isScala3).value + ) + else Nil + + /** + * The simple name of the type a SemanticDB symbol denotes, e.g. `a/O.Foo#` -> + * `Foo` and the nested `a/Outer#Inner#` -> `Inner`. + */ + private def enclosingClassName(symbol: String): String = { + val withoutDescriptor = symbol.stripSuffix("#").stripSuffix(".") + val boundary = List('/', '.', '#').map(withoutDescriptor.lastIndexOf(_)).max + withoutDescriptor.substring(boundary + 1) + } + + private def isIdentifierChar(c: Char): Boolean = + c.isLetterOrDigit || c == '_' || c == '$' || c == '`' } object ScalaDocLink { - private val irrelevantWhite = "[ \\n\\t\\r]" - private val regex = s"\\[\\[$irrelevantWhite*(.*?)$irrelevantWhite*\\]\\]".r + + /** + * Drops a Java module prefix (`module/...`), absent from SemanticDB symbols. The + * `/` must follow a Java identifier char and precede a package start (outside + * backticks), so operator members like `BigDecimal./` are kept while + * `foo_/java.lang.String` is stripped (scalameta/metals#3383). + */ + private[metals] def stripModulePrefix(link: String): String = { + def isModuleNameChar(c: Char): Boolean = + Character.isJavaIdentifierPart(c) + def isPackageStart(c: Char): Boolean = + Character.isJavaIdentifierStart(c) || c == '`' + var inBacktick = false + var i = 0 + var slash = -1 + while (i < link.length && slash < 0) { + link.charAt(i) match { + case '`' => inBacktick = !inBacktick + case '/' + if !inBacktick && i > 0 && i + 1 < link.length && + isModuleNameChar(link.charAt(i - 1)) && + isPackageStart(link.charAt(i + 1)) => + slash = i + case _ => + } + i += 1 + } + if (slash < 0) link else link.substring(slash + 1) + } + + /** + * Drops a Javadoc fragment anchor (`Type##fragment`), a link into rendered docs, + * so it resolves to the type. The `##` must be flanked by an identifier char and a + * fragment name, so `Any###` (member `#` + operator `##`) isn't mistaken (scalameta/metals#3383). + */ + private[metals] def stripDocFragment(link: String): String = { + def isIdentifierStart(c: Char): Boolean = c.isLetterOrDigit || c == '_' + var inBacktick = false + var i = 0 + var cut = -1 + while (i + 2 < link.length && cut < 0) { + link.charAt(i) match { + case '`' => inBacktick = !inBacktick + case '#' + if !inBacktick && i > 0 && link.charAt(i + 1) == '#' && + isIdentifierStart(link.charAt(i + 2)) && + (link.charAt(i - 1).isLetterOrDigit || link.charAt( + i - 1 + ) == '_' || + link.charAt(i - 1) == '$' || link.charAt(i - 1) == '`') => + cut = i + case _ => + } + i += 1 + } + if (cut < 0) link else link.substring(0, cut) + } def atOffset( text: String, offset: Int, isScala3: Boolean, - ): Option[ScalaDocLink] = - regex.findAllMatchIn(text).collectFirst { - case m if m.start(1) <= offset && offset <= m.end(1) => - ScalaDocLink(m.group(1), isScala3) - } + isJava: Boolean, + ): Option[ScalaDocLink] = { + // Java links with `{@link ...}`, Scala with `[[ ... ]]` — extract whichever the + // doc language uses (Java falls back to `[[ ... ]]`). Both also treat a `@see` + // block tag as a link, so a source click resolves those too (scalameta/metals#3383). + val target = + if (isJava) + WikiLink + .javadocAtOffset(text, offset) + .orElse(WikiLink.atOffset(text, offset)) + .orElse(WikiLink.seeTagAtOffset(text, offset)) + else + WikiLink + .atOffset(text, offset) + .orElse(WikiLink.seeTagAtOffset(text, offset)) + target.map(ScalaDocLink(_, isScala3, isJava)) + } sealed trait SymbolType object SymbolType { @@ -303,6 +1227,9 @@ case class ContextSymbols( enclosingSymbol.map(_ ++ sym).toList ++ alternativeEnclosingSymbol .map(_ ++ sym) .toList + // Qualified by the immediate enclosing package. Enclosing-package members rank + // below the implicit scope (in `package a.b`, `[[String]]` is `java.lang.String`), + // which the flat tiers don't model, so they're not searched (scalameta/metals#3383). def withPackage(sym: String): List[String] = enclosingPackagePath.map(_ ++ sym).toList } @@ -325,6 +1252,56 @@ object ContextSymbols { def empty: ContextSymbols = ContextSymbols(None, None, None) + /** + * Builds the context for resolving relative scaladoc links from the SemanticDB + * symbol of the enclosing template (e.g. `a/b/Outer#`) and an optional + * companion alternative (e.g. the object holding an enum's cases). The + * alternative is supplied by the indexer only where it is semantically + * correct, so plain classes do not accidentally resolve into their companion. + */ + def fromSymbols( + enclosingTemplate: String, + alternative: Option[String], + ): ContextSymbols = { + val (pkg, symbol) = splitPackage(enclosingTemplate) + ContextSymbols(pkg, symbol, alternative.map(splitPackage(_)._2)) + } + + /** Splits a SemanticDB symbol into its package prefix and remaining symbol. */ + private def splitPackage(symbol: String): (String, String) = { + // The package prefix is the run of `name/` segments before the first descriptor. + // Track backtick spans so escaped names aren't mis-split (scalameta/metals#3383). + var inBacktick = false + var lastSlash = -1 + var descriptor = -1 + var i = 0 + while (i < symbol.length && descriptor < 0) { + symbol.charAt(i) match { + case '`' => inBacktick = !inBacktick + case '/' if !inBacktick => lastSlash = i + case '.' | '#' if !inBacktick => descriptor = i + case _ => + } + i += 1 + } + if (descriptor < 0) (symbol, "") // a bare package such as `a/b/` + else if (lastSlash < 0) ("", symbol) // a top-level symbol, no package + else + (symbol.substring(0, lastSlash + 1), symbol.substring(lastSlash + 1)) + } + +} + +/** + * The outcome of resolving a documentation link on click, so the handler can tell a + * clean hit from a miss/ambiguity (worth user feedback) and a genuine failure + * (already logged) rather than collapsing the last two (scalameta/metals#3383). + */ +sealed trait ScaladocLinkResolution +object ScaladocLinkResolution { + case class Resolved(location: Location) extends ScaladocLinkResolution + case object NotUnique extends ScaladocLinkResolution + case object Failed extends ScaladocLinkResolution } sealed trait ScalaDocLinkSymbol { diff --git a/metals/src/main/scala/scala/meta/internal/metals/ServerCommands.scala b/metals/src/main/scala/scala/meta/internal/metals/ServerCommands.scala index e8aa15d71ece..1047c47462ae 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/ServerCommands.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/ServerCommands.scala @@ -550,6 +550,16 @@ object ServerCommands { "[location], where the location is a lsp location object.", ) + val GotoScaladocLink = new ParametrizedCommand[ScaladocLinkParams]( + "goto-scaladoc-link", + "Goto scaladoc link", + """|Resolve a scaladoc/javadoc documentation link (as rendered in hover) and + |move the cursor to its definition. Resolution happens on click so that + |building the hover stays cheap (scalameta/metals#3383). + |""".stripMargin, + "[ScaladocLinkParams]", + ) + val GotoSuperMethod = new ParametrizedCommand[TextDocumentPositionParams]( "goto-super-method", "Go to super method/field definition", @@ -825,6 +835,7 @@ object ServerCommands { ExtractMemberDefinition, GenerateBspConfig, GotoPosition, + GotoScaladocLink, GotoSuperMethod, GotoSymbol, GotoLog, @@ -868,6 +879,15 @@ object ServerCommands { } +/** + * A clicked scaladoc/javadoc wiki link. `payload` is the link marker, parsed + * lazily so building the hover stays cheap (scalameta/metals#3383). + */ +case class ScaladocLinkParams( + uri: String, + payload: String, +) + case class DebugUnresolvedMainClassParams( mainClass: String, @Nullable buildTarget: String = null, diff --git a/metals/src/main/scala/scala/meta/internal/metals/WorkspaceLspService.scala b/metals/src/main/scala/scala/meta/internal/metals/WorkspaceLspService.scala index 820df69c5797..5030c38e8585 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/WorkspaceLspService.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/WorkspaceLspService.scala @@ -16,6 +16,7 @@ import scala.util.control.NonFatal import scala.meta.internal.bsp.BuildChange import scala.meta.internal.builds.NewProjectProvider import scala.meta.internal.builds.ShellRunner +import scala.meta.internal.docstrings.MetalsSymbolLink import scala.meta.internal.metals.DidFocusResult import scala.meta.internal.metals.HoverExtParams import scala.meta.internal.metals.MetalsEnrichments._ @@ -1066,6 +1067,36 @@ class WorkspaceLspService( ) } }.asJavaObject + case ServerCommands.GotoScaladocLink(params) => + Future { + val linkLabel = + MetalsSymbolLink.parsePayload(params.payload).target + getServiceFor(params.uri).resolveScaladocLink(params) match { + case ScaladocLinkResolution.Resolved(location) => + languageClient.metalsExecuteClientCommand( + ClientCommands.GotoLocation.toExecuteCommandParams( + ClientCommands.WindowLocation( + location.getUri(), + location.getRange(), + ) + ) + ) + // Give feedback rather than doing nothing on click; overloaded + // links land here intentionally (scalameta/metals#3383). + case ScaladocLinkResolution.NotUnique => + languageClient.showMessage( + lsp4j.MessageType.Info, + s"Could not uniquely resolve documentation link '${linkLabel}'.", + ) + // A genuine resolver failure is already logged and reported; tell the + // user it errored rather than that it was merely ambiguous. + case ScaladocLinkResolution.Failed => + languageClient.showMessage( + lsp4j.MessageType.Warning, + s"Failed to resolve documentation link '${linkLabel}'.", + ) + } + }.asJavaObject case ServerCommands.GotoLog() => onCurrentFolder( service => diff --git a/mtags/src/main/scala/scala/meta/internal/metals/Docstrings.scala b/mtags/src/main/scala/scala/meta/internal/metals/Docstrings.scala index 169868848167..dae6eb9197a8 100644 --- a/mtags/src/main/scala/scala/meta/internal/metals/Docstrings.scala +++ b/mtags/src/main/scala/scala/meta/internal/metals/Docstrings.scala @@ -52,17 +52,18 @@ class Docstrings(index: GlobalSymbolIndex)(implicit rc: ReportContext) { cache(Content.from(symbol, contentType)) = EmptySymbolDocumentation result } - /* Fall back to parent javadocs/scaladocs if nothing is specified for the current symbol - * This way we also cache the result in order not to calculate parents again. + /* Fall back to parent javadocs/scaladocs if nothing is specified for the + * current symbol. The merged result is intentionally NOT cached under this + * symbol's key (see parentDocumentation): parents are cached individually, and + * caching the copy here would outlive an edit to the parent's own file. */ val resultWithParentDocs = result match { case Some(value: MetalsSymbolDocumentation) if value.docstring.isEmpty() => - Some(parentDocumentation(symbol, value, parents, contentType)) + Some(parentDocumentation(value, parents, contentType)) case None => Some( parentDocumentation( - symbol, MetalsSymbolDocumentation.empty(symbol), parents, contentType @@ -74,7 +75,6 @@ class Docstrings(index: GlobalSymbolIndex)(implicit rc: ReportContext) { } def parentDocumentation( - symbol: String, docs: MetalsSymbolDocumentation, parents: ParentSymbols, contentType: ContentType @@ -89,13 +89,10 @@ class Docstrings(index: GlobalSymbolIndex)(implicit rc: ReportContext) { } } .find(_.docstring().nonEmpty) - .fold { - docs - } { withDocs => - val updated = docs.copy(docstring = withDocs.docstring()) - cache(Content.from(symbol, contentType)) = updated - updated - } + // Don't cache the parent's docstring under the child's key: editing the + // parent doesn't invalidate the child's file, so the copy would go stale + // (scalameta/metals#3383). + .fold(docs)(withDocs => docs.copy(docstring = withDocs.docstring())) } private def getFromCacheWithProxy( @@ -123,6 +120,13 @@ class Docstrings(index: GlobalSymbolIndex)(implicit rc: ReportContext) { path.toLanguage match { case Language.SCALA => new Deindexer(path.toInput, dialect).indexRoot() + case Language.JAVA => + // Java docstring fallbacks embed the file's imports, so editing a Java + // import must expire the cached docstrings too (scalameta/metals#3383). + JavadocIndexer.foreach(path.toInput, PLAINTEXT) { doc => + for (contentType <- ContentType.values()) + cache.remove(Content.from(doc.symbol(), contentType)) + } case _ => } } diff --git a/mtags/src/main/scala/scala/meta/internal/metals/JavadocIndexer.scala b/mtags/src/main/scala/scala/meta/internal/metals/JavadocIndexer.scala index 17005a93c2be..bee45e0e68b1 100644 --- a/mtags/src/main/scala/scala/meta/internal/metals/JavadocIndexer.scala +++ b/mtags/src/main/scala/scala/meta/internal/metals/JavadocIndexer.scala @@ -2,13 +2,19 @@ package scala.meta.internal.metals import java.util +import scala.collection.mutable import scala.util.control.NonFatal import scala.meta.inputs.Input +import scala.meta.internal.docstrings.DocScope +import scala.meta.internal.docstrings.ImportLevel +import scala.meta.internal.docstrings.ImportScope +import scala.meta.internal.docstrings.MetalsSymbolLink import scala.meta.internal.docstrings.printers.MarkdownGenerator import scala.meta.internal.jdk.CollectionConverters._ import scala.meta.internal.mtags.JavacMtags import scala.meta.internal.semanticdb.Scala.Descriptor +import scala.meta.internal.semanticdb.Scala.ScalaSymbolOps import scala.meta.internal.semanticdb.Scala.Symbols import scala.meta.pc.ContentType import scala.meta.pc.ContentType.MARKDOWN @@ -16,6 +22,8 @@ import scala.meta.pc.ContentType.PLAINTEXT import scala.meta.pc.SymbolDocumentation import scala.meta.pc.reports.ReportContext +import com.sun.source.tree.CompilationUnitTree + /** * Extracts Javadoc from Java source code. */ @@ -31,6 +39,17 @@ class JavadocIndexer( filterPrivateConstructors = true ) { + /** + * The Java file's import scope from the parsed compilation unit, so doc-link + * resolution uses Java's implicit scope (`java.lang` only) and an unresolved + * Java `Option` doesn't navigate to `scala.Option` (scalameta/metals#3383). + */ + private var javaScope: ImportScope = + ImportScope.empty + + override protected def onCompilationUnit(cu: CompilationUnitTree): Unit = + javaScope = JavadocIndexer.importScopeOf(cu) + override protected def onClass( sym: String, name: String, @@ -59,7 +78,7 @@ class JavadocIndexer( fn(fromMethod(sym, name, params, typeParams, docComment)) } - def toContent(docComment: Option[String]): String = { + def toContent(docComment: Option[String], contextSymbol: String): String = { docComment match { case None => "" case Some(raw) if raw.trim.isEmpty => "" @@ -67,7 +86,21 @@ class JavadocIndexer( contentType match { case MARKDOWN => val scaladocReady = JavadocParser.toScaladocCompatible(raw) - try MarkdownGenerator.fromDocstring(scaladocReady, Map.empty) + try + MetalsSymbolLink.withDocScope( + MarkdownGenerator.fromDocstring(scaladocReady, Map.empty), + DocScope( + Some(contextSymbol).filter(_.nonEmpty), + None, + isJava = true, + javaScope, + // The declaration file's URI, so hover applies same-compilation- + // unit precedence like source go-to-definition (scalameta/metals#3383). + Some(input.path), + // Javadoc is never Scala 3 source-order. + docIsScala3 = false + ) + ) catch { case NonFatal(_) => // The Scaladoc parser implementation uses fragile regexp processing @@ -89,7 +122,8 @@ class JavadocIndexer( new MetalsSymbolDocumentation( symbol, name, - toContent(docComment), + // A method's relative links (`{@link #other}`) resolve against its class. + toContent(docComment, symbol.owner), "", typeParameters(symbol, typeParams, docComment), parameters(symbol, params, docComment) @@ -104,7 +138,7 @@ class JavadocIndexer( new MetalsSymbolDocumentation( symbol, name, - toContent(docComment), + toContent(docComment, symbol), "", typeParameters(symbol, typeParams, docComment), Nil.asJava @@ -119,7 +153,7 @@ class JavadocIndexer( new MetalsSymbolDocumentation( symbol, "", - toContent(docComment), + toContent(docComment, symbol.owner), "", typeParameters(symbol, typeParams, docComment), parameters(symbol, params, docComment) @@ -184,4 +218,77 @@ object JavadocIndexer { )(fn: SymbolDocumentation => Unit)(implicit rc: ReportContext): Unit = { new JavadocIndexer(input, fn, contentType).indexRoot() } + + /** + * The file-level import scope of a Java compilation unit: explicit single + * imports plus on-demand (`*`) wildcards, or empty (scalameta/metals#3383). + */ + def importScopeOf(cu: CompilationUnitTree): ImportScope = { + val explicit = mutable.Map.empty[String, List[String]] + val wildcards = List.newBuilder[(String, Set[String], Boolean)] + cu.getImports().asScala.foreach { imp => + // `getQualifiedIdentifier.toString` renders the canonical dotted form + // (`java.util.List`, `java.util.*`), with no comments or line breaks. + val fqn = imp.getQualifiedIdentifier().toString() + if (fqn.endsWith(".*")) { + val prefix = fqn.dropRight(2) + if (prefix.nonEmpty) + wildcards += ((prefix, Set.empty[String], !imp.isStatic())) + } else { + val simple = fqn.substring(fqn.lastIndexOf('.') + 1) + if (simple.nonEmpty) + explicit(simple) = explicit.getOrElse(simple, Nil) :+ fqn + } + } + val explicitMap = explicit.toMap + val wildcardList = wildcards.result() + if (explicitMap.nonEmpty || wildcardList.nonEmpty) + ImportScope(List(ImportLevel(explicitMap, wildcardList))) + else ImportScope.empty + } + + /** + * The owner symbol whose doc comment encloses `cursorOffset` (for relative + * `{@link #m}`) and the file's import scope — what go-to-definition needs + * inside a Java doc comment (scalameta/metals#3383). + */ + def sourceContext( + input: Input.VirtualFile, + cursorOffset: Int + )(implicit rc: ReportContext): (Option[String], ImportScope) = { + val ctx = new JavadocContext(input, cursorOffset) + ctx.indexRoot() + (ctx.ownerAtCursor, ctx.imports) + } + + private class JavadocContext( + input: Input.VirtualFile, + cursorOffset: Int + )(implicit rc: ReportContext) + extends JavacMtags( + input, + includeMembers = true, + keepDocComments = false, + filterPrivateConstructors = false + ) { + var imports: ImportScope = ImportScope.empty + private val declarations = List.newBuilder[(String, Int)] + + override protected def onCompilationUnit(cu: CompilationUnitTree): Unit = + imports = importScopeOf(cu) + + override protected def onDeclaration( + contextOwner: String, + startOffset: Int + ): Unit = + declarations += ((contextOwner, startOffset)) + + def ownerAtCursor: Option[String] = + declarations + .result() + .filter { case (_, start) => start >= cursorOffset } + .sortBy { case (_, start) => start } + .headOption + .map { case (owner, _) => owner } + } } diff --git a/mtags/src/main/scala/scala/meta/internal/metals/ScaladocImportScope.scala b/mtags/src/main/scala/scala/meta/internal/metals/ScaladocImportScope.scala new file mode 100644 index 000000000000..e84e99eb6bd9 --- /dev/null +++ b/mtags/src/main/scala/scala/meta/internal/metals/ScaladocImportScope.scala @@ -0,0 +1,395 @@ +package scala.meta.internal.metals + +import scala.collection.mutable + +import scala.meta._ +import scala.meta.internal.docstrings.ImportFallbacks +import scala.meta.internal.docstrings.ImportLevel +import scala.meta.internal.docstrings.ImportScope + +/** + * Shared scaladoc import-scope analysis used by both the indexer and source + * go-to-definition, so the two paths resolve links identically + * (scalameta/metals#3383). + */ +object ScaladocImportScope { + + /** + * Per-source memoization of each scope's parsed imports, so a doc-heavy file + * doesn't re-parse importees for every link (was O(n²)). One-shot lookups can + * use a throwaway cache (scalameta/metals#3383). + */ + final class Cache { + private[ScaladocImportScope] val scopes = + mutable.Map.empty[Tree, List[RawImporter]] + } + + /** + * One import clause's parsed bindings, cached per scope independently of the + * documented occurrence (scalameta/metals#3383). + */ + private case class RawImporter( + pos: Int, + isAbsolute: Boolean, + prefix: String, + explicit: List[(String, String)], + hasWildcard: Boolean, + unimports: Set[String] + ) + + private def rawImportersOf(parent: Tree, cache: Cache): List[RawImporter] = + cache.scopes.getOrElseUpdate( + parent, + parent.children.iterator + .collect { case imp: Import => imp } + .flatMap { imp => + val pos = imp.pos.start + imp.importers.map { importer => + // A `_root_`/`_root_.` prefix (not `_root_foo`) is dropped to an + // absolute path; an aliased prefix (`this`, `super`) stays verbatim, + // a known limitation. Bindings keep `original.syntax` so escaped + // imports resolve as one symbol (scalameta/metals#3383). + val rawPrefix = importer.ref.syntax + val isRootAnchored = + rawPrefix == "_root_" || rawPrefix.startsWith("_root_.") + // Bare `_root_` is the root, so its prefix is empty rather than the + // literal `_root_` that `stripPrefix("_root_.")` would leave + // (scalameta/metals#3383). + val strippedPrefix = + if (rawPrefix == "_root_") "" + else rawPrefix.stripPrefix("_root_.") + val explicit = List.newBuilder[(String, String)] + val unimports = Set.newBuilder[String] + var hasWildcard = false + importer.importees.foreach { + case Importee.Name(name) => + explicit += ((name.value, name.syntax)) + case Importee.Rename(name, rename) => + explicit += ((rename.value, name.syntax)) + // A rename also hides the original name from this importer's own + // wildcard (`import p.{X => Y, _}` binds `Y`, not `X`). + unimports += name.value + case _: Importee.Wildcard => hasWildcard = true + case Importee.Unimport(name) => unimports += name.value + case _ => + } + RawImporter( + pos, + isRootAnchored, + strippedPrefix, + explicit.result(), + hasWildcard, + unimports.result() + ) + } + } + .toList + ) + + /** + * The import scope lexically in effect at `tree`: nearer scopes shadow farther + * ones, and only imports preceding the owner count (so siblings don't bleed). + * `enclosingPackage` is the documented symbol's package, the anchor for + * relative prefixes; each enclosing scope resolves against its own package + * (`enclosingPackage` minus the intervening blocks) (scalameta/metals#3383). + */ + def at( + tree: Tree, + enclosingPackage: String, + cache: Cache + ): ImportScope = { + // Drop `rawRef`'s trailing segments from a dotted package (segment-aligned, + // so `a.b` minus `b` is `a`; a mismatch leaves it untouched). Backticks are + // stripped first since scalameta re-escapes keyword segments while the + // SemanticDB anchor is decoded (scalameta/metals#3383). + def dropPackageSuffix(pkg: String, rawRef: String): String = { + val ref = rawRef.replace("`", "") + if (pkg == ref) "" + else if (pkg.endsWith("." + ref)) + pkg.substring(0, pkg.length - ref.length - 1) + else pkg + } + + // Pass 1: in-scope importers per enclosing scope, innermost first, each paired + // with the package in effect there. Targets are materialized in pass 2 because + // a prefix may itself be an alias bound in this or an outer scope, known only + // once every scope's bindings are collected (scalameta/metals#3383). + val perScope = List.newBuilder[List[RawImporter]] + val perScopePackage = List.newBuilder[String] + var scopePackage = enclosingPackage + var child: Tree = tree + var current: Option[Tree] = tree.parent + while (current.isDefined) { + // Climbing out of a package block drops it from the lexical prefix, so an + // import in the outer scope of `package a { package b … }` resolves against + // `a`, not the symbol's `a.b` (scalameta/metals#3383). + child match { + case pkg: Pkg => + scopePackage = dropPackageSuffix(scopePackage, pkg.ref.syntax) + // A `package object q` adds `q` to the package but is a `Pkg.Object`, not + // a `Pkg`, so leaving it must also drop `q` — else an import outside it + // would resolve under `a.q` instead of `a` (scalameta/metals#3383). + case obj: Pkg.Object => + scopePackage = dropPackageSuffix(scopePackage, obj.name.syntax) + case _ => + } + val parent = current.get + val childStart = child.pos.start + perScope += rawImportersOf(parent, cache).filter(_.pos < childStart) + perScopePackage += scopePackage + child = parent + current = parent.parent + } + val importersByScope = perScope.result() + val packageByScope = perScopePackage.result().toVector + + // A relative prefix resolves against the enclosing package first, so offer + // that too (`import b.X` in `package a` → `a.b.X` and `b.X`); a `_root_` or + // empty-package prefix is absolute. `pkg` is the scope's own package, so + // outer-scope imports resolve where they are written (scalameta/metals#3383). + def relativePrefixes( + prefix: String, + isAbsolute: Boolean, + pkg: String + ): List[String] = + if (isAbsolute || pkg.isEmpty) List(prefix) + else List(s"$pkg.$prefix", prefix) + + // Append a member to a resolved prefix; an empty prefix is the root, so the + // member stands alone rather than gaining a leading dot (scalameta/metals#3383). + def joinTarget(prefix: String, member: String): String = + if (prefix.isEmpty) member else s"$prefix.$member" + + // Strip one enclosing pair of backticks from a path segment. Alias keys come + // from `name.value` (never backticked), but a prefix head from `ref.syntax` + // re-escapes keyword segments, so it must be unescaped before comparison + // (scalameta/metals#3383). + def unbacktick(segment: String): String = + if (segment.length >= 2 && segment.head == '`' && segment.last == '`') + segment.substring(1, segment.length - 1) + else segment + + // The first `.` of `s` outside a backticked segment, or -1, so `u.syntax` + // splits at `u` while a backticked segment with a dot stays whole + // (scalameta/metals#3383). + def firstUnescapedDot(s: String): Int = { + var inBacktick = false + var i = 0 + var found = -1 + while (i < s.length && found < 0) { + s.charAt(i) match { + case '`' => inBacktick = !inBacktick + case '.' if !inBacktick => found = i + case _ => + } + i += 1 + } + found + } + + // Each scope's explicit bindings as `(pos, boundName, targets)` — the alias + // table for expanding a prefix whose head is itself an alias. Positions keep + // a same-scope alias visible only to later imports (scalameta/metals#3383). + val aliasByScope: Array[List[(Int, String, List[String])]] = + Array.fill(importersByScope.length)(Nil) + + // The targets alias `head` is bound to, visible to an import at `importPos` in + // scope `from`: its own scope contributes only earlier aliases, enclosing + // scopes all; the nearest binding scope wins (scalameta/metals#3383). + def aliasTargets( + head: String, + from: Int, + importPos: Int + ): Option[List[String]] = + aliasByScope.iterator.zipWithIndex + .drop(from) + .map { case (bindings, idx) => + val visible = + if (idx == from) + bindings.filter { case (pos, _, _) => pos < importPos } + else bindings + visible.collect { + case (_, key, targets) if key == head => targets + }.flatten + } + .collectFirst { case targets if targets.nonEmpty => targets } + + // The prefixes an import resolves against: if the leading segment is an alias, + // expand it to the alias's targets and keep the rest of the path; otherwise + // the relative + literal prefix (scalameta/metals#3383). + def expandedPrefixes( + prefix: String, + isAbsolute: Boolean, + scopeIndex: Int, + importPos: Int + ): List[String] = { + val (head, rest) = firstUnescapedDot(prefix) match { + case -1 => (prefix, "") + case dot => (prefix.substring(0, dot), prefix.substring(dot)) + } + aliasTargets(unbacktick(head), scopeIndex, importPos) match { + case Some(targets) => targets.map(_ + rest) + case None => + relativePrefixes(prefix, isAbsolute, packageByScope(scopeIndex)) + } + } + + // Fill `aliasByScope` in dependency order — enclosing scopes first, then by + // source position — so each alias expands against the aliases it can see and + // chained aliases resolve fully. An alias references only earlier ones, so + // there is no cycle (scalameta/metals#3383). + importersByScope.indices.reverse.foreach { scopeIndex => + importersByScope(scopeIndex).foreach { importer => + importer.explicit.foreach { case (key, syntax) => + val targets = + expandedPrefixes( + importer.prefix, + importer.isAbsolute, + scopeIndex, + importer.pos + ).map(p => joinTarget(p, syntax)) + aliasByScope(scopeIndex) = + aliasByScope(scopeIndex) :+ ((importer.pos, key, targets)) + } + } + } + + // Pass 2: materialize explicit and wildcard targets, expanding alias prefixes. + // Scala wildcards never type-force a bare reference (scalameta/metals#3383). + val explicitScopes = importersByScope.zipWithIndex.map { + case (importers, scopeIndex) => + val scopeExplicit = mutable.Map.empty[String, List[String]] + importers.foreach { importer => + importer.explicit.foreach { case (key, syntax) => + val targets = + expandedPrefixes( + importer.prefix, + importer.isAbsolute, + scopeIndex, + importer.pos + ).map(p => joinTarget(p, syntax)) + scopeExplicit(key) = scopeExplicit.getOrElse(key, Nil) ++ targets + } + } + scopeExplicit.toMap + } + val wildcardScopes = importersByScope.zipWithIndex.map { + case (importers, scopeIndex) => + importers.filter(_.hasWildcard).flatMap { importer => + expandedPrefixes( + importer.prefix, + importer.isAbsolute, + scopeIndex, + importer.pos + ).distinct + .map(resolved => (resolved, importer.unimports, false)) + } + } + // Keep each scope's explicit and wildcard imports together (innermost first), + // dropping scopes that contribute neither, so the resolver can apply per-scope + // precedence and spot cross-scope ambiguity (scalameta/metals#3383). + val levels = explicitScopes.zip(wildcardScopes).collect { + case (explicit, wildcards) if explicit.nonEmpty || wildcards.nonEmpty => + ImportLevel(explicit, wildcards) + } + ImportScope(levels) + } + + /** + * The fully-qualified fallback tiers for a bare leading name, from the import + * scope plus the language's implicit scope (Scala: Predef, `scala`, `java.lang`; + * Java: only `java.lang`). Explicit imports always apply; wildcard and implicit + * scopes only for a bare/single-member reference. Backs both Scala and Java + * markers and source go-to-definition (scalameta/metals#3383). + */ + def fallbacksFor( + scope: ImportScope, + isJava: Boolean, + name: String, + rest: String, + bareScope: Boolean + ): ImportFallbacks = { + val plain = name.replace("`", "") + val scoped = bareScope && plain.nonEmpty + // One (explicit, wildcard) pair per scope, innermost first, empty scopes + // dropped. Wildcards apply only to a bare/single-member reference (`scoped`); + // a type-forced wildcard (Java non-static on-demand) appends `!` so a bare + // reference resolves only to a member type (scalameta/metals#3383). + val importScopes = scope.levels + .map { level => + val explicit = level.explicit.getOrElse(plain, Nil).map(_ + rest) + val wildcards = + if (scoped) + level.wildcards.collect { + case (prefix, hidden, typeForce) if !hidden(plain) => + val force = if (typeForce && rest.isEmpty) "!" else "" + // An empty prefix is the root, so the name stands alone rather + // than gaining a leading dot (scalameta/metals#3383). + val dot = if (prefix.isEmpty) "" else "." + s"$prefix$dot$name$rest$force" + } + else Nil + (explicit, wildcards) + } + .filter { case (explicit, wildcards) => + explicit.nonEmpty || wildcards.nonEmpty + } + val implicits = + if (!scoped) Nil + else if (isJava) List(s"java.lang.$name$rest") + else + // Implicit imports shadow java.lang < scala < Predef, so precedence is + // Predef, then scala, then java.lang (scalameta/metals#3383). + List( + s"scala.Predef.$name$rest", + s"scala.$name$rest", + s"java.lang.$name$rest" + ) ++ collectionAliases.get(plain).map(_ + rest).toList + ImportFallbacks(importScopes, implicits) + } + + /** + * Common `scala`/`Predef` aliases whose members live on the underlying + * collection type, so a member link resolves against + * `scala.collection.immutable.List` etc.; inherited factory members + * (`List.apply`) still can't be found by direct lookup (scalameta/metals#3383). + */ + val collectionAliases: Map[String, String] = { + val immutable = "scala.collection.immutable" + Map( + "List" -> s"$immutable.List", + "Nil" -> s"$immutable.Nil", + "Seq" -> s"$immutable.Seq", + "IndexedSeq" -> s"$immutable.IndexedSeq", + "Iterable" -> s"$immutable.Iterable", + "Vector" -> s"$immutable.Vector", + "Stream" -> s"$immutable.Stream", + "LazyList" -> s"$immutable.LazyList", + "Range" -> s"$immutable.Range", + "Map" -> s"$immutable.Map", + "Set" -> s"$immutable.Set" + ) + } + + /** + * The dotted package of a SemanticDB `owner` symbol, or "" for the empty + * package (e.g. `a/b/Foo#` and `a/b/` both yield `a.b`) (scalameta/metals#3383). + */ + def packageOf(owner: String): String = { + var inBacktick = false + var lastSlash = -1 + var descriptor = -1 + var i = 0 + while (i < owner.length && descriptor < 0) { + owner.charAt(i) match { + case '`' => inBacktick = !inBacktick + case '/' if !inBacktick => lastSlash = i + case ('.' | '#') if !inBacktick => descriptor = i + case _ => + } + i += 1 + } + val pkgPath = if (lastSlash >= 0) owner.substring(0, lastSlash) else "" + if (pkgPath == "_empty_") "" else pkgPath.replace('/', '.') + } +} diff --git a/mtags/src/main/scala/scala/meta/internal/metals/ScaladocIndexer.scala b/mtags/src/main/scala/scala/meta/internal/metals/ScaladocIndexer.scala index c30e9d603685..cbd655287488 100644 --- a/mtags/src/main/scala/scala/meta/internal/metals/ScaladocIndexer.scala +++ b/mtags/src/main/scala/scala/meta/internal/metals/ScaladocIndexer.scala @@ -27,6 +27,10 @@ class ScaladocIndexer( contentType: ContentType ) extends ScalaMtags(input, dialect) { val defines: mutable.Map[String, String] = mutable.Map.empty[String, String] + // Per-file memoization shared by every documented occurrence's import-scope + // lookup, so a documentation-heavy source isn't rescanned O(n²) times. + private val importScopeCache = new ScaladocImportScope.Cache + override def visitOccurrence( occ: SymbolOccurrence, sinfo: SymbolInformation, @@ -54,12 +58,65 @@ class ScaladocIndexer( // Register `@define` macros to use for expanding in later docstrings. defines ++= ScaladocParser.extractDefines(docstring) val comment = ScaladocParser.parseComment(docstring, defines) - val docstringContent = printer.toText(comment, docstring) + // Relative scaladoc links resolve against the enclosing template, so we bake + // its symbol into the link markers, plus a companion alternative for enums + // and givens whose members live in the companion (scalameta/metals#3383). + val (contextSymbol, contextAlternative) = currentTree match { + case _: Defn.Class | _: Defn.Trait | _: Defn.Object | _: Pkg.Object | + _: Pkg => + (occ.symbol, "") + case _: Defn.Enum => + (occ.symbol, ScaladocIndexer.companion(occ.symbol)) + case _: Defn.EnumCase => + // A parameterized enum case is a case class whose members live under its + // own symbol, so tie the docstring to the case and its companion value + // rather than the enum's companion (scalameta/metals#3383). + (occ.symbol, ScaladocIndexer.companion(occ.symbol)) + case _: Defn.Given => + // A given's occurrence symbol is method-shaped (`name().`) when it is + // parameterized and value-shaped (`name.`) otherwise, but its members + // live under the type symbol (`name#`). Derive both forms from the + // symbol so anonymous givens (`given_`) are handled too. + val name = ScaladocIndexer.descriptorName(occ.symbol, owner) + ( + Symbols.Global(owner, Descriptor.Type(name)), + Symbols.Global(owner, Descriptor.Term(name)) + ) + case _ => (owner, "") + } + // Carry the source's import fallbacks and owner context in the link markers so + // links resolve on click; the same scope analysis backs source go-to-definition + // so the two can't drift — see [[ScaladocImportScope]] (scalameta/metals#3383). + lazy val importScope = + ScaladocImportScope.at( + currentTree, + ScaladocImportScope.packageOf(owner), + importScopeCache + ) + def withImportsAndContext(rendered: String): String = + MetalsSymbolLink.withDocScope( + rendered, + DocScope( + Some(contextSymbol).filter(_.nonEmpty), + Some(contextAlternative).filter(_.nonEmpty), + isJava = false, + importScope, + // The declaration file's URI, so hover applies same-compilation-unit + // precedence like source go-to-definition (scalameta/metals#3383). + Some(input.path), + // This docstring's own dialect, so hover doesn't apply Scala 3 source- + // order rules to a Scala 2 library's docs (scalameta/metals#3383). + docIsScala3 = dialect.allowSignificantIndentation + ) + ) + val docstringContent = + withImportsAndContext(printer.toText(comment, docstring)) def param(name: String, default: String): SymbolDocumentation = { val paramDoc = comment.valueParams .get(name) .orElse(comment.typeParams.get(name)) .map(printer.toText) + .map(withImportsAndContext) .getOrElse("") MetalsSymbolDocumentation( Symbols.Global(owner, Descriptor.Parameter(name)), @@ -139,6 +196,24 @@ class ScaladocIndexer( object ScaladocIndexer { + /** The companion form of a template symbol (`Foo#` <-> `Foo.`), or "". */ + private def companion(symbol: String): String = + if (symbol.endsWith("#")) symbol.dropRight(1) + "." + else if (symbol.endsWith(".")) symbol.dropRight(1) + "#" + else "" + + /** + * The simple (descriptor) name of `symbol` given its `owner` prefix, dropping + * any method parameter list. E.g. `a/given_Foo().` with owner `a/` -> `given_Foo`. + */ + private def descriptorName(symbol: String, owner: String): String = { + val tail = symbol.stripPrefix(owner) + tail.indexOf('(') match { + case -1 => tail.stripSuffix(".").stripSuffix("#") + case i => tail.substring(0, i) + } + } + /** * Extracts Scaladoc from Scala source code. * diff --git a/mtags/src/main/scala/scala/meta/internal/metals/docstrings/DocScope.scala b/mtags/src/main/scala/scala/meta/internal/metals/docstrings/DocScope.scala new file mode 100644 index 000000000000..609518ea9b0b --- /dev/null +++ b/mtags/src/main/scala/scala/meta/internal/metals/docstrings/DocScope.scala @@ -0,0 +1,46 @@ +package scala.meta.internal.docstrings + +/** + * One lexical scope's imports (`explicit` bindings + `(prefix, hidden, + * typeForce)` wildcards), grouped so the resolver applies Scala's per-scope + * binding precedence rather than shadowing across scopes (scalameta/metals#3383). + */ +case class ImportLevel( + explicit: Map[String, List[String]], + wildcards: List[(String, Set[String], Boolean)] +) +object ImportLevel { + val empty: ImportLevel = ImportLevel(Map.empty, Nil) +} + +/** + * The lexical import scope at a documented occurrence: one [[ImportLevel]] per + * enclosing scope, innermost first, keeping only scopes that contribute an + * import (scalameta/metals#3383). + */ +case class ImportScope(levels: List[ImportLevel]) +object ImportScope { + val empty: ImportScope = ImportScope(Nil) +} + +/** + * Structured resolution context for a scaladoc/javadoc wiki link: the resolver + * materialises candidates from this once instead of baking per-link strings into + * every marker. `docstringFile` lets hover apply same-compilation-unit precedence + * even when `owner` is synthetic (a Scala 3 `Foo$package.`) (scalameta/metals#3383). + */ +case class DocScope( + owner: Option[String], + alternative: Option[String], + isJava: Boolean, + imports: ImportScope, + docstringFile: Option[String], + // The docstring's OWN Scala dialect, not the hovered file's, so hovering a + // Scala 2 library's docs applies Scala 2 (type-before-value) resolution. + // False for Java (scalameta/metals#3383). + docIsScala3: Boolean +) +object DocScope { + val empty: DocScope = + DocScope(None, None, isJava = false, ImportScope.empty, None, false) +} diff --git a/mtags/src/main/scala/scala/meta/internal/metals/docstrings/MetalsSymbolLink.scala b/mtags/src/main/scala/scala/meta/internal/metals/docstrings/MetalsSymbolLink.scala new file mode 100644 index 000000000000..96efd56978cb --- /dev/null +++ b/mtags/src/main/scala/scala/meta/internal/metals/docstrings/MetalsSymbolLink.scala @@ -0,0 +1,336 @@ +package scala.meta.internal.docstrings + +import java.net.URLDecoder +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + +/** + * Shared marker for scaladoc entity (wiki) links in rendered docstring markdown. + * + * mtags has no symbol resolver, so `MarkdownGenerator` emits each entity link with + * a [[scheme]]-prefixed target and an encoded payload + * `owner/alternative/isJava/scopes/docstringFile/docIsScala3/link-target` — the + * docstring's structured resolution context (owner template, companion, language, + * import scopes, own file and dialect), all known at index time. The server + * materialises resolution candidates from it and rewrites each marker to a command + * link on hover, or strips it elsewhere, so no broken link is ever shown. + * + * Every leaf is URL-encoded, so none contains the field separator `/` or the + * structural delimiters `|~;=,`. The versioned scheme lets a marker an older server + * can't parse degrade to plain text instead of mis-resolving (scalameta/metals#3383). + */ +object MetalsSymbolLink { + // The U+E000 (private-use) prefix keeps user-typed links from colliding with this + // marker: it can't appear in source, unlike bare `metals-wiki-link2`, a valid URL + // scheme `MarkdownGenerator.isUrl` would treat as external (scalameta/metals#3383). + val scheme: String = 0xe000.toChar.toString + "metals-wiki-link2:" + + /** + * The markdown sequence `](` + [[scheme]] that opens a marker's target. + * `MarkdownGenerator` escapes a label's own `]`, so this only occurs at a real + * target; producer (`withDocScope`) and consumer (`Compilers.rewriteMarkerLinks`) + * both anchor on it to stay in lockstep (scalameta/metals#3383). + */ + val markerLinkOpen: String = "](" + scheme + + private val separator: String = "/" + private val groupSep: Char = '|' + private val levelSep: Char = '~' + private val entrySep: Char = ';' + private val fieldSep: Char = '=' + private val listSep: Char = ',' + + def encode(value: String): String = + URLEncoder.encode(value, StandardCharsets.UTF_8.name()) + + def decode(value: String): String = + URLDecoder.decode(value, StandardCharsets.UTF_8.name()) + + /** + * Prepends the structured [[DocScope]] (owner, language, imports) to every + * entity-link marker `MarkdownGenerator` emitted with only its link target, so the + * server can resolve each link. Runs at index time (scalameta/metals#3383). + */ + def withDocScope(rendered: String, docScope: DocScope): String = + if (!rendered.contains(scheme)) rendered + else { + def opt(o: Option[String]): String = o.map(encode).getOrElse("") + val prefix = + opt(docScope.owner) + separator + + opt(docScope.alternative) + separator + + (if (docScope.isJava) "1" else "0") + separator + + encodeScopes(docScope.imports.levels) + separator + + opt(docScope.docstringFile) + separator + + (if (docScope.docIsScala3) "1" else "0") + separator + // Inject only at a real marker target ([[markerLinkOpen]]), never at a bare + // `scheme` in label text; anchoring on `](`+scheme keeps this producer in + // lockstep with the server consumer (scalameta/metals#3383). + rendered.replace(markerLinkOpen, markerLinkOpen + prefix) + } + + /** + * `level|level`, each `explicit~wildcard`; explicit is `name=target,target;...`, + * wildcard is `prefix=hidden,hidden=1;...` (all leaves encoded, so delimiter-free) + * (scalameta/metals#3383). + */ + private def encodeScopes(levels: List[ImportLevel]): String = + levels + .map(level => + encodeExplicitPart(level.explicit) + + levelSep + encodeWildcardPart(level.wildcards) + ) + .mkString(groupSep.toString) + + private def encodeExplicitPart(explicit: Map[String, List[String]]): String = + explicit.iterator + .map { case (name, targets) => + s"${encode(name)}$fieldSep${targets.map(encode).mkString(listSep.toString)}" + } + .mkString(entrySep.toString) + + private def encodeWildcardPart( + wildcards: List[(String, Set[String], Boolean)] + ): String = + wildcards + .map { case (prefix, hidden, typeForce) => + s"${encode(prefix)}$fieldSep${hidden.map(encode).mkString(listSep.toString)}$fieldSep${if (typeForce) "1" else "0"}" + } + .mkString(entrySep.toString) + + /** + * The fallback tiers for a link target, shared by the resolver and go-to-definition + * so both split the leading name identically. The explicit import is always tried; + * the wildcard/implicit scopes only for a bare type (`Name`, `Name#m`) or a single + * member access (`util.Tool`) — the `bareScope` flag (scalameta/metals#3383). + */ + def fallbacksForTarget( + target: String, + isJava: Boolean, + fallbacksFor: (String, String, Boolean) => ImportFallbacks + ): ImportFallbacks = + leadingIdentifier(target, isJava) match { + case Some(name) => + val rest = target.substring(name.length) + val bare = isTypeBoundary(target, name.length) + // A single member access (one top-level `.segment`, e.g. `util.Tool`) is an + // object member, not a package path; dots inside backticks (`` util.`a.b` ``) + // or a signature (`util.tool(p.q.R)`) don't count (scalameta/metals#3383). + val singleMember = + rest.startsWith(".") && !hasTopLevelDot(rest, 1) + fallbacksFor(name, rest, bare || singleMember) + case None => ImportFallbacks.empty + } + + /** + * The simple owner name of a member-selecting link (`Child#m`, `Future.x`) when + * that owner is a single bare name an import could rebind — used to confine member + * selection to the owner's own binding, so a local `Foo` isn't lost to `import p._`. + * Type arguments are stripped first (`Widget[Int]#paint`). A bare type, a force + * suffix, an already-qualified path (`a.b.Foo#m`) or a signature yields None + * (scalameta/metals#3383). + */ + def memberLinkOwner(target: String, isJava: Boolean): Option[String] = + leadingIdentifier(target, isJava).flatMap { name => + val rest = stripTypeArgs(target.substring(name.length)) + val isMemberSelect = + rest.startsWith("#") || + (rest.startsWith(".") && !hasTopLevelDot(rest, 1)) + if (isMemberSelect) Some(name) else None + } + + /** + * Removes top-level type-argument groups `[...]` so a type application resolves + * against its base type (`Map[K, V]` → `Map`); a `[`/`]` inside backticks is kept. + * Shared by the resolver and the owner-cap so both strip identically + * (scalameta/metals#3383). + */ + def stripTypeArgs(s: String): String = { + val out = new StringBuilder + var inBacktick = false + var depth = 0 + var i = 0 + while (i < s.length) { + s.charAt(i) match { + case '`' => + inBacktick = !inBacktick + if (depth == 0) out.append('`') + case '[' if !inBacktick => depth += 1 + case ']' if !inBacktick && depth > 0 => depth -= 1 + case c => if (depth == 0) out.append(c) + } + i += 1 + } + out.toString + } + + /** + * Whether `s` has a `.` at or after `from` that is top-level — not inside backticks + * (`` `a.b` ``) nor a balanced `(...)`/`[...]` signature (`bar(p.q.R)`) + * (scalameta/metals#3383). + */ + private def hasTopLevelDot(s: String, from: Int): Boolean = { + var inBacktick = false + var depth = 0 + var i = from + var found = false + while (i < s.length && !found) { + s.charAt(i) match { + case '`' => inBacktick = !inBacktick + case '(' | '[' if !inBacktick => depth += 1 + case ')' | ']' if !inBacktick && depth > 0 => depth -= 1 + case '.' if !inBacktick && depth == 0 => found = true + case _ => + } + i += 1 + } + found + } + + /** + * Whether the char after the leading name marks a bare type reference — a member + * separator `#`, a signature `(`/`[`, or a force suffix `$`/`!`, so `[[Future!]]` + * qualifies via the wildcard scope like `[[Future]]` (scalameta/metals#3383). + */ + private def isTypeBoundary(s: String, i: Int): Boolean = + i >= s.length || { + val c = s.charAt(i) + c == '#' || c == '(' || c == '[' || c == '$' || c == '!' + } + + /** + * The maximal leading identifier of `s`, if it starts with one; a backtick-escaped + * name (`` `type` ``) is returned whole so a keyword import can still be qualified + * (scalameta/metals#3383). + */ + private def leadingIdentifier(s: String, isJava: Boolean): Option[String] = { + def isStart(c: Char): Boolean = c.isLetter || c == '_' || c == '$' + def isPart(c: Char): Boolean = c.isLetterOrDigit || c == '_' || c == '$' + if (s.isEmpty) None + else if (s.charAt(0) == '`') { + val end = s.indexOf('`', 1) + if (end < 0) None else Some(s.substring(0, end + 1)) + } else if (!isStart(s.charAt(0))) None + else { + var j = 1 + while (j < s.length && isPart(s.charAt(j))) j += 1 + // In Scala a trailing `$` on the whole target is the value-force suffix and is + // dropped; in Java `$` is an ordinary identifier char, so `{@link Money$}` keeps + // it or the explicit import key `Money$` would be missed (scalameta/metals#3383). + if (!isJava && j == s.length && j > 1 && s.charAt(j - 1) == '$') j -= 1 + Some(s.substring(0, j)) + } + } + + /** + * Splits a marker payload into the structured [[DocScope]] and the (always + * present) link target. + */ + def parsePayload(payload: String): MarkerPayload = { + def opt(s: String): Option[String] = Some(decode(s)).filter(_.nonEmpty) + payload.split(separator, -1) match { + case Array( + owner, + alternative, + isJava, + scopes, + docstringFile, + docIsScala3, + target + ) => + MarkerPayload( + DocScope( + opt(owner), + opt(alternative), + isJava == "1", + ImportScope(decodeScopes(scopes)), + opt(docstringFile), + docIsScala3 == "1" + ), + decode(target) + ) + case Array(target) => + MarkerPayload(DocScope.empty, decode(target)) + case _ => + MarkerPayload(DocScope.empty, decode(payload)) + } + } + + private def decodeScopes(s: String): List[ImportLevel] = + if (s.isEmpty) Nil + else + s.split(groupSep).toList.map { level => + level.split(levelSep.toString, -1) match { + case Array(explicit, wildcards) => + ImportLevel( + decodeExplicitPart(explicit), + decodeWildcardPart(wildcards) + ) + case _ => ImportLevel.empty + } + } + + private def decodeExplicitPart(s: String): Map[String, List[String]] = + s.split(entrySep) + .iterator + .filter(_.nonEmpty) + .map { entry => + val eq = entry.indexOf(fieldSep.toInt) + val name = decode(entry.substring(0, eq)) + val targets = entry + .substring(eq + 1) + .split(listSep) + .iterator + .filter(_.nonEmpty) + .map(decode) + .toList + name -> targets + } + .toMap + + private def decodeWildcardPart( + s: String + ): List[(String, Set[String], Boolean)] = + s.split(entrySep) + .iterator + .filter(_.nonEmpty) + .map { entry => + entry.split(fieldSep.toString, -1) match { + case Array(prefix, hidden, force) => + ( + decode(prefix), + hidden + .split(listSep) + .iterator + .filter(_.nonEmpty) + .map(decode) + .toSet, + force == "1" + ) + case _ => (decode(entry), Set.empty[String], false) + } + } + .toList +} + +/** + * The fully-qualified fallback candidates for a link. `importScopes` holds one + * `(explicit, wildcard)` pair per lexical scope that binds the name, innermost + * first; keeping a scope's explicit and wildcard together lets the resolver apply + * per-scope precedence (an explicit import beats a wildcard within a scope, but an + * outer explicit and inner wildcard don't shadow each other). `implicitImports` is + * the lowest-precedence implicit scope (`scala`, `Predef`, `java.lang`) + * (scalameta/metals#3383). + */ +case class ImportFallbacks( + importScopes: List[(List[String], List[String])], + implicitImports: List[String] +) +object ImportFallbacks { + val empty: ImportFallbacks = ImportFallbacks(Nil, Nil) +} + +/** A decoded marker payload (see [[MetalsSymbolLink]]). */ +case class MarkerPayload( + docScope: DocScope, + target: String +) diff --git a/mtags/src/main/scala/scala/meta/internal/metals/docstrings/ScaladocParser.scala b/mtags/src/main/scala/scala/meta/internal/metals/docstrings/ScaladocParser.scala index dce8ce22c6b7..03633e744a30 100644 --- a/mtags/src/main/scala/scala/meta/internal/metals/docstrings/ScaladocParser.scala +++ b/mtags/src/main/scala/scala/meta/internal/metals/docstrings/ScaladocParser.scala @@ -17,9 +17,6 @@ import scala.meta.Position */ object ScaladocParser { - // Used in link() and for converting Javadoc @see to links - private val LinkPattern = """(.+?#?.+?(\([^\)]*\)?)?)((?:\s+)(.*))?""".r - /* Creates comments with necessary arguments */ def createComment( body0: Option[Body] = None, @@ -56,16 +53,13 @@ object ScaladocParser { case Summary(inline) => // Make sure Javadoc text is converted into links Summary(inline match { + // A quoted `@see "..."` string is plain text per the Javadoc + // spec, not a link (scalameta/metals#3383). + case Text(text) if text.trim.startsWith("\"") => + Text(text) case Text(text) => - text match { - case LinkPattern(link, _, _, title) => - Link( - link, - Option(title) map (Text.apply) getOrElse Text(link) - ) - case text => - Link(text, Text(text)) - } + val (target, title) = WikiLink.splitTargetTitle(text) + Link(target, Text(title.getOrElse(target))) case x => x }) case x => x @@ -1282,12 +1276,8 @@ object ScaladocParser { val link = readUntil { check(stop) } jump(stop) - link match { - case LinkPattern(link, _, _, title) => - Link(link, Option(title) map (Text.apply) getOrElse Text(link)) - case text => - Link(text, Text(text)) - } + val (target, title) = WikiLink.splitTargetTitle(link) + Link(target, Text(title.getOrElse(target))) } /* UTILITY */ diff --git a/mtags/src/main/scala/scala/meta/internal/metals/docstrings/WikiLink.scala b/mtags/src/main/scala/scala/meta/internal/metals/docstrings/WikiLink.scala new file mode 100644 index 000000000000..4e913ce39711 --- /dev/null +++ b/mtags/src/main/scala/scala/meta/internal/metals/docstrings/WikiLink.scala @@ -0,0 +1,184 @@ +package scala.meta.internal.docstrings + +/** + * Shared parsing of scaladoc/javadoc entity (wiki) links `[[ ... ]]` so the + * renderer, go-to-definition and resolver agree on a link's boundaries and its + * target/title split (scalameta/metals#3383). + */ +object WikiLink { + + /** + * Splits a link's inner content into target and optional title at the first + * whitespace that lies outside a backtick-escaped name and outside a + * parenthesised signature or type-argument list (scalameta/metals#3383). + */ + def splitTargetTitle(content: String): (String, Option[String]) = { + val n = content.length + var i = 0 + while (i < n && content.charAt(i).isWhitespace) i += 1 + val start = i + var inBacktick = false + var depth = 0 + var split = -1 + while (i < n && split < 0) { + content.charAt(i) match { + case '`' => inBacktick = !inBacktick + case '(' | '[' if !inBacktick => depth += 1 + case ')' | ']' if !inBacktick && depth > 0 => depth -= 1 + case c if !inBacktick && depth == 0 && c.isWhitespace => split = i + case _ => + } + i += 1 + } + if (split < 0) (content.substring(start).trim, None) + else { + val title = content.substring(split).trim + val titleOpt = if (title.isEmpty) None else Some(title) + (content.substring(start, split), titleOpt) + } + } + + /** + * The target of the entity link whose brackets (`[[ ... ]]`, or any `n >= 2` + * matching brackets, mirroring the renderer) enclose `offset`, so source + * go-to-definition navigates exactly the links the renderer makes clickable + * (scalameta/metals#3383). + */ + /** + * The target of the Javadoc inline link (`{@link ... }` / `{@linkplain ... }`) + * whose braces enclose `offset`, matching what the renderer extracts so source + * clicks navigate the same links. The first token is the target; the rest is + * the label (scalameta/metals#3383). + */ + def javadocAtOffset(text: String, offset: Int): Option[String] = { + val tags = List("{@linkplain", "{@link") + val n = text.length + var i = 0 + var result: Option[String] = None + while (i < n && result.isEmpty) { + if (text.charAt(i) == '{') { + tags.find(text.startsWith(_, i)) match { + case Some(tag) => + val end = text.indexOf('}', i + tag.length) + if (end < 0) i += 1 + else { + if (offset >= i && offset <= end) { + val target = + splitTargetTitle(text.substring(i + tag.length, end))._1 + if (target.nonEmpty) result = Some(target) + } + i = end + 1 + } + case None => i += 1 + } + } else i += 1 + } + result + } + + /** + * The symbol reference of a `@see` BLOCK tag whose target encloses `offset`. + * `@see` isn't an inline `{@link}` / `[[ ... ]]`, so without this its reference + * is clickable on hover yet dead from the source. Only the first token is the + * target; a quoted string or HTML anchor is plain text, not a symbol + * (scalameta/metals#3383). + */ + def seeTagAtOffset(text: String, offset: Int): Option[String] = { + val tag = "@see" + val n = text.length + var i = 0 + var result: Option[String] = None + while (i < n && result.isEmpty) { + val boundary = i + tag.length + val isTag = + text.startsWith(tag, i) && + isBlockTagStart(text, i) && + (boundary >= n || text.charAt(boundary).isWhitespace) + if (isTag) { + // A bare `@see`'s reference may sit on a continuation line, so skip + // whitespace, newlines and the continuation `*` to reach it + // (scalameta/metals#3383). + var refStart = boundary + var skipping = true + while (refStart < n && skipping) { + text.charAt(refStart) match { + case ' ' | '\t' | '\n' | '\r' | '*' => refStart += 1 + case _ => skipping = false + } + } + var lineEnd = refStart + while ( + lineEnd < n && + text.charAt(lineEnd) != '\n' && text.charAt(lineEnd) != '\r' + ) lineEnd += 1 + val reference = text.substring(refStart, lineEnd) + if (reference.nonEmpty) { + val head = reference.charAt(0) + // A quoted string or HTML anchor is plain text, a `/` starts the + // comment's closing marker, and `@` starts the next block tag — none + // is a `@see` reference (scalameta/metals#3383). + if (head != '"' && head != '<' && head != '/' && head != '@') { + val target = splitTargetTitle(reference)._1 + if ( + target.nonEmpty && offset >= refStart && + offset <= refStart + target.length + ) result = Some(target) + } + } + // A new block tag (`@`) ends this `@see`'s body, so rewind to it and let + // it be scanned as its own tag (scalameta/metals#3383). + i = + if (refStart < n && text.charAt(refStart) == '@') refStart + else lineEnd + } else i += 1 + } + result + } + + /** + * Whether `i` begins a BLOCK tag: everything back to the line start is only + * comment scaffolding (whitespace, a `*`, or the opening marker), so a `@see` + * in prose or a `{@link}` body isn't mistaken for one (scalameta/metals#3383). + */ + private def isBlockTagStart(text: String, i: Int): Boolean = { + var j = i - 1 + var ok = true + var atLineStart = false + while (j >= 0 && !atLineStart && ok) { + text.charAt(j) match { + case '\n' | '\r' => atLineStart = true + case ' ' | '\t' | '*' | '/' => j -= 1 + case _ => ok = false + } + } + ok + } + + def atOffset(text: String, offset: Int): Option[String] = { + val n = text.length + var i = 0 + var result: Option[String] = None + while (i < n && result.isEmpty) { + if (text.charAt(i) == '[') { + var open = 0 + while (i + open < n && text.charAt(i + open) == '[') open += 1 + if (open >= 2) { + val contentStart = i + open + val end = text.indexOf("]" * open, contentStart) + if (end < 0) i += open + else { + // The link spans the half-open range `[i, end + open)`; an inclusive + // upper bound would let the char right after the closing brackets + // resolve to this link (scalameta/metals#3383). + if (offset >= i && offset < end + open) + result = Some( + splitTargetTitle(text.substring(contentStart, end))._1 + ) + i = end + open + } + } else i += 1 + } else i += 1 + } + result + } +} diff --git a/mtags/src/main/scala/scala/meta/internal/metals/docstrings/printers/MarkdownGenerator.scala b/mtags/src/main/scala/scala/meta/internal/metals/docstrings/printers/MarkdownGenerator.scala index 19b18ad65200..cf13be21ff60 100644 --- a/mtags/src/main/scala/scala/meta/internal/metals/docstrings/printers/MarkdownGenerator.scala +++ b/mtags/src/main/scala/scala/meta/internal/metals/docstrings/printers/MarkdownGenerator.scala @@ -67,12 +67,67 @@ object MarkdownGenerator extends ScalaDocPrinter { case Bold(text) => s"**${inlineToText(text)}**" case Link(target, title) => - s"[${inlineToText(title)}]($target)" + val label = inlineToText(title) + if (isUrl(target)) s"[$label]($target)" + // Entity (wiki) links point at a symbol, not a URL; mark them so the + // server can resolve them. Escape the label so brackets in a title like + // `Option[A]` can't break the marker (scalameta/metals#3383). + else + s"[${escapeLinkLabel(label)}](${MetalsSymbolLink.scheme}${MetalsSymbolLink + .encode(target)})" case _ => "" } } + /** + * Whether a link target is an external URL rather than a symbol reference. + * Only a leading `scheme:` marks a URL; a bare `/` can't, since entity paths + * may contain it (e.g. `BigDecimal./`) (scalameta/metals#3383). + */ + private def isUrl(target: String): Boolean = + target.indexOf(':') match { + case -1 => false + case colon => isScheme(target.substring(0, colon)) + } + + /** + * Whether `s` is an RFC 3986 scheme: `ALPHA *( ALPHA / DIGIT / "+" / "-" / + * "." )`. Dots are allowed (a custom scheme such as `com.example`) but the + * scheme must begin and end with a letter or digit and contain no doubled dot. + * No real scheme ends in `+`/`-`/`.`, whereas a dotted symbol path leading into + * an operator member always does — so requiring an alphanumeric last character + * keeps `List.+:` & friends symbols. The grammar is inherently case-insensitive, + * so no locale-sensitive lower-casing is needed. + */ + private def isScheme(s: String): Boolean = + s.nonEmpty && s.charAt(0).isLetter && + s.charAt(s.length - 1).isLetterOrDigit && { + var i = 0 + var prevDot = false + var ok = true + while (i < s.length && ok) { + val c = s.charAt(i) + if (c == '.') { ok = !prevDot; prevDot = true } + else { + ok = c.isLetterOrDigit || c == '+' || c == '-' + prevDot = false + } + i += 1 + } + ok + } + + /** Backslash-escapes the markdown link-label delimiters `\`, `[` and `]`. */ + private def escapeLinkLabel(label: String): String = { + val sb = new StringBuilder(label.length) + label.foreach { c => + if (c == '\\' || c == '[' || c == ']') sb.append('\\') + sb.append(c) + } + sb.toString + } + protected def wrapParam(param: String): String = s"`$param`" protected def constructor: String = "**Constructor:**" protected def deprecated: String = "**Deprecated:**" diff --git a/mtags/src/main/scala/scala/meta/internal/mtags/JavacMtags.scala b/mtags/src/main/scala/scala/meta/internal/mtags/JavacMtags.scala index 57af853281cf..6301205b3b18 100644 --- a/mtags/src/main/scala/scala/meta/internal/mtags/JavacMtags.scala +++ b/mtags/src/main/scala/scala/meta/internal/mtags/JavacMtags.scala @@ -138,6 +138,7 @@ class JavacMtags( ) val cu = parser.parseCompilationUnit() cu.sourcefile = source + onCompilationUnit(cu) val trees = JavacTrees.instance(context) val visitor = new Visitor(cu, trees) visitor.scan(cu, ()) @@ -149,6 +150,10 @@ class JavacMtags( } } + // Hook for subclasses (e.g. JavadocIndexer) to inspect the parsed compilation + // unit (e.g. its import declarations) once, before any member is visited. + protected def onCompilationUnit(cu: CompilationUnitTree): Unit = {} + // Hook for subclasses (e.g. JavadocIndexer). // `sym` is the SemanticDB symbol for the declaration. protected def onClass( @@ -173,6 +178,11 @@ class JavacMtags( docComment: Option[String] ): Unit = {} + // Reports each declaration's doc-comment owner (what its relative links + // resolve against) and start offset, so go-to-definition can map a cursor in a + // leading doc comment to the declaration it documents (scalameta/metals#3383). + protected def onDeclaration(contextOwner: String, startOffset: Int): Unit = {} + private class Visitor(cu: CompilationUnitTree, trees: Trees) extends TreePathScanner[TreePath, Unit] { private val sourcePositions = trees.getSourcePositions() @@ -379,6 +389,11 @@ class JavacMtags( val typeParams = extractTypeParamNames(node) onClass(currentOwner, name, typeParams, getDocComment(node)) + // A class's own doc resolves relative links against the class itself. + onDeclaration( + currentOwner, + sourcePositions.getStartPosition(cu, node).intValue() + ) lazy val constructorCount = node.getMembers().asScala.count { case method: MethodTree => @@ -424,6 +439,8 @@ class JavacMtags( return null } mtags.withOwner() { + // The enclosing class, captured before `method` pushes the method as owner. + val enclosingClass = currentOwner val sym = mtags.method( name = name, disambiguator = disambiguators.getOrDefault(node, 0) match { @@ -472,6 +489,10 @@ class JavacMtags( getDocComment(node) ) } + onDeclaration( + enclosingClass, + sourcePositions.getStartPosition(cu, node).intValue() + ) null // don't scan method body } } @@ -491,6 +512,8 @@ class JavacMtags( mtags.withOwner() { // Skip local variables (inside method bodies) if (!mtags.currentOwner.endsWith(").")) { + // The enclosing class — a field's doc resolves relative links against it. + val enclosingClass = mtags.currentOwner val pos = findNameRange( start = Option(node.getType()) match { case Some(value) @@ -524,6 +547,10 @@ class JavacMtags( else 0 ) } + onDeclaration( + enclosingClass, + sourcePositions.getStartPosition(cu, node).intValue() + ) } } null // don't scan variable initializers diff --git a/project/TestGroups.scala b/project/TestGroups.scala index 26e51975438e..b185ee7d6b3a 100644 --- a/project/TestGroups.scala +++ b/project/TestGroups.scala @@ -15,12 +15,13 @@ object TestGroups { "tests.DiagnosticsLspSuite", "tests.worksheets.WorksheetNoDecorationsLspSuite", "tests.CompletionLspSuite", "tests.TreeViewLspSuite", "tests.HoverLspSuite", - "tests.SuperHierarchyLspSuite", "tests.DidFocusLspSuite", - "tests.BuildServerConnectionLspSuite", "tests.BuildTargetsLspSuite", - "tests.FileWatcherLspSuite", "tests.CurrentProjectCompileLspSuite", - "tests.WindowStateDidChangeLspSuite", "tests.DocumentSymbolLspSuite", - "tests.WorkspaceSymbolExpectSuite", "tests.digest.DigestsSuite", - "tests.MtagsSuite", "tests.ChosenBuildServerSuite", "tests.SemanticdbSuite", + "tests.HoverWikiLinkLspSuite", "tests.SuperHierarchyLspSuite", + "tests.DidFocusLspSuite", "tests.BuildServerConnectionLspSuite", + "tests.BuildTargetsLspSuite", "tests.FileWatcherLspSuite", + "tests.CurrentProjectCompileLspSuite", "tests.WindowStateDidChangeLspSuite", + "tests.DocumentSymbolLspSuite", "tests.WorkspaceSymbolExpectSuite", + "tests.digest.DigestsSuite", "tests.MtagsSuite", + "tests.ChosenBuildServerSuite", "tests.SemanticdbSuite", "tests.digest.MillDigestSuite", "tests.DocumentSymbolSuite", "tests.FoldingRangeSuite", "tests.JavadocSuite", "tests.MtagsEnrichmentsSuite", "tests.MtagsResolverSuite", @@ -44,9 +45,10 @@ object TestGroups { "tests.debug.BreakpointScalaCliDapSuite", "tests.CallHierarchyLspSuite", "tests.BspStatusSuite", "tests.ServerLivenessMonitorLspSuite", "tests.ToplevelWithInnerScala2Suite", "tests.ScaladocSymbolsSuite", - "tests.SkipCommentsSuite", "tests.JarSourcesProviderSuite", - "tests.inlayHints.InlayHintsHoverSuite", "tests.Java8Suite", - "tests.RequestRegistrySuite", "tests.inlayHints.InlayHintsExpectSuite", + "tests.MarkdownGeneratorSuite", "tests.SkipCommentsSuite", + "tests.JarSourcesProviderSuite", "tests.inlayHints.InlayHintsHoverSuite", + "tests.Java8Suite", "tests.RequestRegistrySuite", + "tests.inlayHints.InlayHintsExpectSuite", "tests.worksheets.WorksheetInfiniteLoopSuite", "tests.TimeoutSuite", "tests.SingleFileSuite", "tests.SupportedScalaSuite", "tests.bestEffort.BestEffortCompilationSuite", diff --git a/tests/mtest/src/main/scala/tests/DocstringMarkers.scala b/tests/mtest/src/main/scala/tests/DocstringMarkers.scala new file mode 100644 index 000000000000..e853cc1692ee --- /dev/null +++ b/tests/mtest/src/main/scala/tests/DocstringMarkers.scala @@ -0,0 +1,30 @@ +package tests + +import scala.util.matching.Regex + +import scala.meta.internal.docstrings.MetalsSymbolLink + +/** + * `MarkdownGenerator` marks scaladoc entity (wiki) links with + * `MetalsSymbolLink.scheme` so the server can later resolve them into navigable + * command links. Tests that assert on the raw presentation-compiler docstring + * rendering (hover/completion/signature help, without going through the server) + * decode the markers back to their plain link target so the assertions stay + * readable (scalameta/metals#3383). + */ +object DocstringMarkers { + private val markerRegex: Regex = + (Regex.quote(MetalsSymbolLink.scheme) + "([^)]*)").r + + def decode(rendered: String): String = + if (rendered == null || !rendered.contains(MetalsSymbolLink.scheme)) + rendered + else + markerRegex.replaceAllIn( + rendered, + m => + Regex.quoteReplacement( + MetalsSymbolLink.parsePayload(m.group(1)).target + ) + ) +} diff --git a/tests/mtest/src/main/scala/tests/PCSuite.scala b/tests/mtest/src/main/scala/tests/PCSuite.scala index da89a70dae7c..7dbf23f06751 100644 --- a/tests/mtest/src/main/scala/tests/PCSuite.scala +++ b/tests/mtest/src/main/scala/tests/PCSuite.scala @@ -130,9 +130,9 @@ trait PCSuite { def doc(e: JEither[String, MarkupContent]): String = { if (e == null) "" else if (e.isLeft) { - " " + e.getLeft + " " + DocstringMarkers.decode(e.getLeft) } else { - " " + e.getRight.getValue + " " + DocstringMarkers.decode(e.getRight.getValue) } }.trim } diff --git a/tests/mtest/src/main/scala/tests/TestHovers.scala b/tests/mtest/src/main/scala/tests/TestHovers.scala index 2e92d32b2190..7cdc0cdd1f50 100644 --- a/tests/mtest/src/main/scala/tests/TestHovers.scala +++ b/tests/mtest/src/main/scala/tests/TestHovers.scala @@ -64,7 +64,8 @@ trait TestHovers { ): String = { hover match { case Some(value) => - val types = value.getContents.getRight.getValue() + val types = + DocstringMarkers.decode(value.getContents.getRight.getValue()) val range = Option(value.getRange) match { case Some(value) if includeRange => diff --git a/tests/mtest/src/main/scala/tests/TestInlayHints.scala b/tests/mtest/src/main/scala/tests/TestInlayHints.scala index 217d7a1cd67f..c7ff3e357d41 100644 --- a/tests/mtest/src/main/scala/tests/TestInlayHints.scala +++ b/tests/mtest/src/main/scala/tests/TestInlayHints.scala @@ -37,7 +37,7 @@ object TestInlayHints { val tooltip = Option(inlayHint.getTooltip()).map(t => t.asScala match { case Left(tooltip) => tooltip - case Right(markdown) => markdown.getValue() + case Right(markdown) => DocstringMarkers.decode(markdown.getValue()) } ) val data = inlayHint.getData() diff --git a/tests/unit/src/test/scala/tests/DefinitionLspSuite.scala b/tests/unit/src/test/scala/tests/DefinitionLspSuite.scala index dd5c923f6e79..08bf310151b2 100644 --- a/tests/unit/src/test/scala/tests/DefinitionLspSuite.scala +++ b/tests/unit/src/test/scala/tests/DefinitionLspSuite.scala @@ -578,6 +578,453 @@ class DefinitionLspSuite } yield () } + // Source go-to-definition resolves an imported link the same way hover does: + // `[[Future]]` after `import scala.concurrent.Future` navigates even though the + // link is unqualified (the source path previously had no import fallbacks) + // (scalameta/metals#3383). + test("scaladoc-definition-import") { + val testCase = + """|package a + | + |import scala.concurrent.Future + | + |object O { + | /** + | * Returns a [[Fut@@ure]]. + | */ + | def f: Future[Int] = ??? + |} + |""".stripMargin + for { + _ <- initialize( + s""" + |/metals.json + |{ + | "a": { } + |} + |/a/src/main/scala/a/Main.scala + |${testCase.replace("@@", "")} + |""".stripMargin + ) + _ <- server.didOpen("a/src/main/scala/a/Main.scala") + locations <- server.definition( + "a/src/main/scala/a/Main.scala", + testCase, + workspace, + ) + _ = assert(locations.nonEmpty) + _ = assert( + locations.head.getUri().endsWith("concurrent/Future.scala"), + locations.toString, + ) + } yield () + } + + // An import declared INSIDE the enclosing object (a sibling of the documented + // member) is in scope for source go-to-definition too, not only file-top-level + // imports (scalameta/metals#3383). + test("scaladoc-definition-nested-import") { + val testCase = + """|package a + | + |object O { + | import scala.concurrent.Future + | /** + | * Returns a [[Fut@@ure]]. + | */ + | def f: Future[Int] = ??? + |} + |""".stripMargin + for { + _ <- initialize( + s""" + |/metals.json + |{ + | "a": { } + |} + |/a/src/main/scala/a/Main.scala + |${testCase.replace("@@", "")} + |""".stripMargin + ) + _ <- server.didOpen("a/src/main/scala/a/Main.scala") + locations <- server.definition( + "a/src/main/scala/a/Main.scala", + testCase, + workspace, + ) + _ = assert(locations.nonEmpty, "nested import not in scope") + _ = assert( + locations.head.getUri().endsWith("concurrent/Future.scala"), + locations.toString, + ) + } yield () + } + + // A wildcard import inside the enclosing class is likewise in scope for source + // go-to-definition (scalameta/metals#3383). + test("scaladoc-definition-nested-wildcard-import") { + val testCase = + """|package a + | + |class O { + | import scala.concurrent._ + | /** + | * Returns a [[Fut@@ure]]. + | */ + | def f: Future[Int] = ??? + |} + |""".stripMargin + for { + _ <- initialize( + s""" + |/metals.json + |{ + | "a": { } + |} + |/a/src/main/scala/a/Main.scala + |${testCase.replace("@@", "")} + |""".stripMargin + ) + _ <- server.didOpen("a/src/main/scala/a/Main.scala") + locations <- server.definition( + "a/src/main/scala/a/Main.scala", + testCase, + workspace, + ) + _ = assert(locations.nonEmpty, "nested wildcard import not in scope") + _ = assert( + locations.head.getUri().endsWith("concurrent/Future.scala"), + locations.toString, + ) + } yield () + } + + test("scaladoc-definition-triple-bracket") { + val testCase = + """|package a + | + |object O { + | /** + | * Returns a [[[scala.Do@@uble]]] representing yada yada yada... + | */ + | def f: Double = ??? + |} + |""".stripMargin + for { + _ <- initialize( + s""" + |/metals.json + |{ + | "a": { } + |} + |/a/src/main/scala/a/Main.scala + |${testCase.replace("@@", "")} + |""".stripMargin + ) + _ <- server.didOpen("a/src/main/scala/a/Main.scala") + locations <- server.definition( + "a/src/main/scala/a/Main.scala", + testCase, + workspace, + ) + _ = assert(locations.nonEmpty) + _ = assert(locations.head.getUri().endsWith("scala/Double.scala")) + } yield () + } + + // Scala 3 Scaladoc binds an ambiguous `[[Name]]` to the entity FIRST in source + // order: with `object Target` declared before `class Target`, the link opens the + // object (line 1), not the class (line 2) — Scala 2 picks the type + // (scalameta/metals#3383). + test("scaladoc-definition-scala3-source-order") { + val testCase = + """|package a + |object Target { def x: Int = 1 } + |class Target + |object O { + | /** See [[Tar@@get]]. */ + | def f: Int = 1 + |} + |""".stripMargin + for { + _ <- initialize( + s""" + |/metals.json + |{ "a": { "scalaVersion": "${V.scala3}" } } + |/a/src/main/scala/a/Main.scala + |${testCase.replace("@@", "")} + |""".stripMargin + ) + _ <- server.didOpen("a/src/main/scala/a/Main.scala") + locations <- server.definition( + "a/src/main/scala/a/Main.scala", + testCase, + workspace, + ) + _ = assert(locations.nonEmpty, "ambiguous companion link did not resolve") + // `object Target` is on line 1 (0-based); `class Target` on line 2. + _ = assertEquals( + locations.head.getRange().getStart().getLine(), + 1, + s"expected the source-first object Target (line 1), got $locations", + ) + } yield () + } + + // A Scala 3 TOP-LEVEL member lives in the file's synthetic `$package` + // object, so a relative `[[helper]]` in `def entry`'s doc must resolve against + // `a/Main$package.helper` — the source path synthesizes that owner, matching the + // SemanticDB owner the indexer keeps for hover (scalameta/metals#3383). + test("scaladoc-definition-scala3-toplevel-member") { + val testCase = + """|package a + |/** See [[hel@@per]]. */ + |def entry = 1 + |def helper = 2 + |""".stripMargin + for { + _ <- initialize( + s""" + |/metals.json + |{ "a": { "scalaVersion": "${V.scala3}" } } + |/a/src/main/scala/a/Main.scala + |${testCase.replace("@@", "")} + |""".stripMargin + ) + _ <- server.didOpen("a/src/main/scala/a/Main.scala") + locations <- server.definition( + "a/src/main/scala/a/Main.scala", + testCase, + workspace, + ) + _ = assert(locations.nonEmpty, "top-level [[helper]] did not resolve") + // `def helper` is on line 3 (0-based). + _ = assertEquals( + locations.head.getRange().getStart().getLine(), + 3, + s"expected top-level def helper (line 3), got $locations", + ) + } yield () + } + + // Source go-to-definition mirrors the indexer for an enum case: `[[r]]` in the + // doc of `case Mix(r: Int)` resolves against the CASE (Mix), not the enclosing + // enum (scalameta/metals#3383). + test("scaladoc-definition-enum-case-param") { + val testCase = + """|package a + |enum E { + | /** The mix [[r@@]]. */ + | case Mix(r: Int) + |} + |""".stripMargin + for { + _ <- initialize( + s""" + |/metals.json + |{ "a": { "scalaVersion": "${V.scala3}" } } + |/a/src/main/scala/a/E.scala + |${testCase.replace("@@", "")} + |""".stripMargin + ) + _ <- server.didOpen("a/src/main/scala/a/E.scala") + locations <- server.definition( + "a/src/main/scala/a/E.scala", + testCase, + workspace, + ) + _ = assert(locations.nonEmpty, "enum case param link did not resolve") + _ = assert( + locations.head.getUri().endsWith("/a/E.scala"), + locations.toString, + ) + } yield () + } + + // The source context encodes an owner name as its SemanticDB descriptor: inside + // a keyword-named `class `type``, a relative `[[field]]` resolves against the + // escaped owner `a/`type`#`, matching the hover/indexed path (scalameta/metals#3383). + test("scaladoc-definition-escaped-owner") { + val testCase = + """|package a + |class `type` { + | /** See [[fie@@ld]]. */ + | def m: Int = field + | def field: Int = 1 + |} + |""".stripMargin + for { + _ <- initialize( + s""" + |/metals.json + |{ "a": { } } + |/a/src/main/scala/a/Esc.scala + |${testCase.replace("@@", "")} + |""".stripMargin + ) + _ <- server.didOpen("a/src/main/scala/a/Esc.scala") + locations <- server.definition( + "a/src/main/scala/a/Esc.scala", + testCase, + workspace, + ) + _ = assert( + locations.nonEmpty, + "escaped-owner relative link did not resolve", + ) + _ = assert( + locations.head.getUri().endsWith("/a/Esc.scala"), + locations.toString, + ) + } yield () + } + + // Source go-to-definition inside a JAVA doc comment resolves an IMPORTED class + // ({@link ArrayList} via `import java.util.ArrayList`) and a RELATIVE member + // ({@link #bar} against the enclosing class) — the source path now builds the + // Java owner + import scope instead of falling back to empty context, matching + // hover (scalameta/metals#3383). + test("scaladoc-definition-java") { + val source = + """|package a; + |import java.util.ArrayList; + |public class Foo { + | /** See {@link ArrayList} and {@link #bar}. */ + | void m() {} + | void bar() {} + |} + |""".stripMargin + val importedCursor = + source.replace("{@link ArrayList}", "{@link Array@@List}") + val relativeCursor = source.replace("{@link #bar}", "{@link #ba@@r}") + for { + _ <- initialize( + s""" + |/metals.json + |{ "a": { } } + |/a/src/main/java/a/Foo.java + |$source + |""".stripMargin + ) + _ <- server.didOpen("a/src/main/java/a/Foo.java") + imported <- server.definition( + "a/src/main/java/a/Foo.java", + importedCursor, + workspace, + ) + _ = assert( + imported.nonEmpty, + "imported {@link ArrayList} did not resolve", + ) + _ = assert( + imported.head.getUri().contains("ArrayList"), + imported.toString, + ) + relative <- server.definition( + "a/src/main/java/a/Foo.java", + relativeCursor, + workspace, + ) + _ = assert(relative.nonEmpty, "relative {@link #bar} did not resolve") + _ = assert( + relative.head.getUri().endsWith("/a/Foo.java"), + relative.toString, + ) + } yield () + } + + // A Javadoc `@see` BLOCK tag is source-clickable, like hover renders it: an + // imported `@see ArrayList` and a relative `@see #bar` both navigate, matching + // the inline `{@link}` behaviour (scalameta/metals#3383). + test("scaladoc-definition-java-see-tag") { + val source = + """|package a; + |import java.util.ArrayList; + |public class Foo { + | /** + | * @see ArrayList + | * @see #bar + | */ + | void m() {} + | void bar() {} + |} + |""".stripMargin + val importedCursor = source.replace("@see ArrayList", "@see Array@@List") + val relativeCursor = source.replace("@see #bar", "@see #ba@@r") + for { + _ <- initialize( + s""" + |/metals.json + |{ "a": { } } + |/a/src/main/java/a/Foo.java + |$source + |""".stripMargin + ) + _ <- server.didOpen("a/src/main/java/a/Foo.java") + imported <- server.definition( + "a/src/main/java/a/Foo.java", + importedCursor, + workspace, + ) + _ = assert(imported.nonEmpty, "@see ArrayList did not resolve") + _ = assert( + imported.head.getUri().contains("ArrayList"), + imported.toString, + ) + relative <- server.definition( + "a/src/main/java/a/Foo.java", + relativeCursor, + workspace, + ) + _ = assert(relative.nonEmpty, "@see #bar did not resolve") + _ = assert( + relative.head.getUri().endsWith("/a/Foo.java"), + relative.toString, + ) + } yield () + } + + // A Javadoc `{@link}` resolves in a Scala 3 build target too: the Java doc path + // carries `isScala3 = false` (Javadoc is never Scala 3 source), matching the + // `docIsScala3 = false` the indexer bakes into Java hover markers, so source + // navigation and hover agree regardless of the target's Scala version + // (scalameta/metals#3383). + test("scaladoc-definition-java-in-scala3-target") { + val source = + """|package a; + |public class Foo { + | /** See {@link Target}. */ + | void m() {} + |} + |""".stripMargin + val cursor = source.replace("{@link Target}", "{@link Tar@@get}") + for { + _ <- initialize( + s""" + |/metals.json + |{ "a": { "scalaVersion": "${V.scala3}" } } + |/a/src/main/scala/a/Target.scala + |package a + |object Target { def x: Int = 1 } + |class Target { def y: Int = 2 } + |/a/src/main/java/a/Foo.java + |$source + |""".stripMargin + ) + _ <- server.didOpen("a/src/main/java/a/Foo.java") + locations <- server.definition( + "a/src/main/java/a/Foo.java", + cursor, + workspace, + ) + _ = assert(locations.nonEmpty, "Java {@link Target} did not resolve") + _ = assert( + locations.head.getUri().endsWith("/a/Target.scala"), + locations.toString, + ) + } yield () + } + test("weird-symbol") { val testCase = """|package a diff --git a/tests/unit/src/test/scala/tests/HoverLspSuite.scala b/tests/unit/src/test/scala/tests/HoverLspSuite.scala index 74905b85d5c4..bc5d93044519 100644 --- a/tests/unit/src/test/scala/tests/HoverLspSuite.scala +++ b/tests/unit/src/test/scala/tests/HoverLspSuite.scala @@ -1,7 +1,18 @@ package tests +import java.net.URLDecoder + +import scala.concurrent.Future + +import scala.meta.internal.docstrings.MetalsSymbolLink +import scala.meta.internal.metals.ClientCommands +import scala.meta.internal.metals.CommandHTMLFormat import scala.meta.internal.metals.Directories import scala.meta.internal.metals.InitializationOptions +import scala.meta.internal.metals.JsonParser._ +import scala.meta.internal.metals.MetalsEnrichments._ +import scala.meta.internal.metals.ScaladocLinkParams +import scala.meta.internal.metals.ServerCommands import scala.meta.internal.metals.{BuildInfo => V} class HoverLspSuite extends BaseLspSuite("hover-") with TestHovers { @@ -161,6 +172,42 @@ class HoverLspSuite extends BaseLspSuite("hover-") with TestHovers { } yield () } + // When the client doesn't support command links, scaladoc wiki links such as + // `[[a.Bar]]` must be rendered as plain text rather than a broken markdown + // link with an unresolvable target (scalameta/metals#3383). + test("wiki-link-plaintext-fallback".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/Def.scala + |package a + |class Bar + |object Def { + | /** + | * See [[a.Bar]] for details. + | */ + | def foo(x: Int): Int = ??? + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + _ <- server.assertHover( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + """|```scala + |def foo(x: Int): Int + |``` + |See a.Bar for details. + |""".stripMargin.hover, + ) + } yield () + } + test("update-docstrings".tag(FlakyWindows)) { for { _ <- initialize( @@ -346,3 +393,2770 @@ class HoverLspSuite extends BaseLspSuite("hover-") with TestHovers { } } + +class HoverWikiLinkLspSuite + extends BaseLspSuite("hover-wiki-link-") + with TestHovers { + + override protected def initializationOptions: Option[InitializationOptions] = + Some( + TestingServer.TestDefault.copy( + commandInHtmlFormat = Some(CommandHTMLFormat.VSCode) + ) + ) + + private val docWithWikiLinks = + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/Def.scala + |package a + |object Bar { + | def baz(x: Int): Int = x + |} + |object Def { + | /** + | * See [[a.Bar]] and [[a.Bar.baz]]. + | */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + + private val linkCommand = + raw"command:metals\.goto-scaladoc-link\?([^)]+)".r + + /** + * Hovers, then "clicks" the first rendered scaladoc-link command and returns + * the uri it navigated to (if any). Resolution happens lazily in the command + * handler, so hovering itself does no symbol resolution (scalameta/metals#3383). + */ + private def clickFirstLink( + filename: String, + query: String, + ): Future[Option[String]] = { + // `clientCommands` accumulates across clicks, so clear it first to read the + // location produced by *this* click rather than an earlier one. + client.clientCommands.clear() + for { + hover <- server.hover(filename, query, workspace) + params = linkCommand.findFirstMatchIn(hover).map { m => + val json = URLDecoder.decode(m.group(1), "UTF-8") + json.parseJson.getAsJsonArray().get(0).as[ScaladocLinkParams].get + } + _ <- params.fold(Future.unit)(p => + server.executeCommand(ServerCommands.GotoScaladocLink, p).map(_ => ()) + ) + } yield client.clientCommands.asScala.collectFirst { + case ClientCommands.GotoLocation(location) => location.uri + } + } + + // When the client supports command links, a scaladoc wiki link (both a type + // and a method link) is rewritten into a clickable command link that resolves + // lazily on click (scalameta/metals#3383). + test("wiki-link-command".tag(FlakyWindows)) { + for { + _ <- initialize(docWithWikiLinks) + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + hover <- server.hover( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + workspace, + ) + } yield { + assert( + hover.contains("[a.Bar](command:metals.goto-scaladoc-link"), + hover, + ) + assert( + hover.contains("[a.Bar.baz](command:metals.goto-scaladoc-link"), + hover, + ) + assert(!hover.contains(MetalsSymbolLink.scheme), hover) + } + } + + // The marker must not leak to other docstring surfaces: completion item + // documentation strips it to plain text (scalameta/metals#3383). + test("wiki-link-completion-strip".tag(FlakyWindows)) { + for { + _ <- initialize(docWithWikiLinks) + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + _ <- server.didOpen("a/src/main/scala/a/Main.scala") + _ <- server.didChange("a/src/main/scala/a/Main.scala")(_ => + "package a\nobject Main {\n Def.fo\n}\n" + ) + items <- server.completionList( + "a/src/main/scala/a/Main.scala", + "Def.fo@@", + ) + foo = items.getItems.asScala.find(_.getLabel().startsWith("foo")).get + resolved <- server.completionItemResolve(foo) + } yield { + val doc = Option(resolved.getDocumentation()) match { + case Some(d) if d.isRight() => d.getRight().getValue() + case Some(d) if d.isLeft() => d.getLeft() + case _ => "" + } + assert(doc.contains("a.Bar"), doc) + assert(!doc.contains(MetalsSymbolLink.scheme), doc) + assert(!doc.contains("command:"), doc) + } + } + + // A title that itself contains brackets (e.g. a type like `Bar[X]`) must still + // be rewritten into a command link and never leak. The label's brackets are + // escaped so the marker stays parseable (scalameta/metals#3383). + test("wiki-link-bracket-title".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/Def.scala + |package a + |object Bar + |object Def { + | /** + | * See [[a.Bar Bar[X]ref]]. + | */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + hover <- server.hover( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + workspace, + ) + } yield { + assert( + hover.contains("""[Bar\[X\]ref](command:metals.goto-scaladoc-link"""), + hover, + ) + assert(!hover.contains(MetalsSymbolLink.scheme), hover) + } + } + + // Scaladoc's `[[[ ]]]` syntax permits a title with an unmatched bracket; the + // marker must still be rewritten and never leak (scalameta/metals#3383). + test("wiki-link-unmatched-bracket-title".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/Def.scala + |package a + |object Bar + |object Def { + | /** + | * See [[[a.Bar title ] suffix]]]. + | */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + hover <- server.hover( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + workspace, + ) + } yield { + assert(hover.contains("command:metals.goto-scaladoc-link"), hover) + assert(!hover.contains(MetalsSymbolLink.scheme), hover) + } + } + + // Clicking an absolute link navigates to the symbol's definition. + test("wiki-link-navigate-absolute".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/Bar.scala + |package a + |object Bar + |/a/src/main/scala/a/Def.scala + |package a + |object Def { + | /** See [[a.Bar]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/Bar.scala")), uri.toString) + } + + // Clicking a relative link resolves it against the docstring owner's context. + test("wiki-link-navigate-relative".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/Def.scala + |package a + |object Def { + | /** See [[helper]]. */ + | def foo(x: Int): Int = ??? + | def helper: Int = 0 + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/Def.scala")), uri.toString) + } + + // A relative link is qualified at index time with the defining source's + // imports, so `[[Target]]` after `import a.b.Target` navigates to the imported + // type even though it lives in another package (scalameta/metals#3383). + test("wiki-link-navigate-scala-import".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/b/Target.scala + |package a.b + |class Target + |/a/src/main/scala/a/Def.scala + |package a + |import a.b.Target + |object Def { + | /** See [[Target]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/b/Target.scala")), uri.toString) + } + + // The same index-time qualification applies to Java imports, so a Javadoc + // `{@link Helper}` after `import a.b.Helper;` navigates to the imported class + // (scalameta/metals#3383). + test("wiki-link-navigate-java-import".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/java/a/b/Helper.java + |package a.b; + |public class Helper {} + |/a/src/main/java/a/Foo.java + |package a; + |import a.b.Helper; + |public class Foo { + | /** See {@link Helper}. */ + | public void foo() {} + |} + """.stripMargin + ) + _ <- server.didOpen("a/src/main/java/a/Foo.java") + uri <- clickFirstLink( + "a/src/main/java/a/Foo.java", + """package a; + |import a.b.Helper; + |public class Foo { + | /** See {@link Helper}. */ + | public void fo@@o() {} + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/b/Helper.java")), uri.toString) + } + + // A local definition shadows an imported name: the import is only a fallback, + // so `[[Target]]` resolves to the object's own `Target`, not the imported one + // (scalameta/metals#3383). + test("wiki-link-navigate-shadows-import".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/b/Target.scala + |package a.b + |class Target + |/a/src/main/scala/a/Def.scala + |package a + |import a.b.Target + |object Def { + | class Target + | /** See [[Target]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + // The local `Def.Target` lives in Def.scala, the import in Target.scala. + } yield assert(uri.exists(_.endsWith("/a/Def.scala")), uri.toString) + } + + // Imports are lexically scoped: two sibling objects importing a different + // `Target` each resolve to their own import, not whichever was last seen in + // the file (scalameta/metals#3383). + test("wiki-link-navigate-lexical-import-scope".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/b/Target.scala + |package a.b + |class Target + |/a/src/main/scala/a/c/Target.scala + |package a.c + |class Target + |/a/src/main/scala/a/Def.scala + |package a + |object One { + | import a.b.Target + | /** See [[Target]]. */ + | def fa(x: Int): Int = ??? + |} + |object Two { + | import a.c.Target + | /** See [[Target]]. */ + | def fb(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + fromOne <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = One.f@@a(1) + |}""".stripMargin, + ) + fromTwo <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Two.f@@b(1) + |}""".stripMargin, + ) + } yield { + assert(fromOne.exists(_.endsWith("/a/b/Target.scala")), fromOne.toString) + assert(fromTwo.exists(_.endsWith("/a/c/Target.scala")), fromTwo.toString) + } + } + + // A nearer import shadows a farther one of the same name: the inner object's + // `import a.c.Target` wins over the outer `import a.b.Target`, so `[[Target]]` + // in the inner scope resolves to `a.c.Target` (scalameta/metals#3383). + test("wiki-link-navigate-nested-import-shadowing".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/b/Target.scala + |package a.b + |class Target + |/a/src/main/scala/a/c/Target.scala + |package a.c + |class Target + |/a/src/main/scala/a/Def.scala + |package a + |import a.b.Target + |object Outer { + | import a.c.Target + | /** See [[Target]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Outer.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/c/Target.scala")), uri.toString) + } + + // A Java wildcard import (`import a.b.*;`) qualifies a relative Javadoc link + // via its prefix, so `{@link Helper}` resolves to `a.b.Helper` + // (scalameta/metals#3383). + // A docstring whose link label itself contains the marker scheme (an escaped + // `](metals-wiki-link2:…)`) must not crash hover/completion rendering — it + // degrades gracefully instead (scalameta/metals#3383). + test("wiki-link-scheme-in-label".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/Def.scala + |package a + |object Bar + |object Def { + | /** See [[a.Bar click ](metals-wiki-link2:x) here]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + hover <- server.hover( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + workspace, + ) + } yield assert(hover.contains("here"), hover) + } + + // A type-forced bare link (`[[Future!]]`) qualifies via the wildcard/implicit + // scope just like `[[Future]]`, so the `!` suffix doesn't suppress its import + // fallbacks (scalameta/metals#3383). + test("wiki-link-type-forced-wildcard".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/Def.scala + |package a + |import scala.concurrent._ + |object Def { + | /** See [[Future!]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert( + uri.exists(_.endsWith("concurrent/Future.scala")), + uri.toString, + ) + } + + // Two single-static imports of the same name are ambiguous (a Java compile + // error), so the link does not navigate to whichever was imported last + // (scalameta/metals#3383). + test("wiki-link-java-duplicate-static-import".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/java/a/b/A.java + |package a.b; + |public class A { public static int max(int x) { return x; } } + |/a/src/main/java/a/c/B.java + |package a.c; + |public class B { public static int max(int x) { return x; } } + |/a/src/main/java/a/Foo.java + |package a; + |import static a.b.A.max; + |import static a.c.B.max; + |public class Foo { + | /** See {@link max}. */ + | public void foo() {} + |} + """.stripMargin + ) + _ <- server.didOpen("a/src/main/java/a/Foo.java") + uri <- clickFirstLink( + "a/src/main/java/a/Foo.java", + """package a; + |import static a.b.A.max; + |import static a.c.B.max; + |public class Foo { + | /** See {@link max}. */ + | public void fo@@o() {} + |}""".stripMargin, + ) + } yield { + assert(uri.isEmpty, uri.toString) + assert( + client.showMessages.asScala.exists( + _.getMessage().contains("Could not uniquely resolve") + ), + client.showMessages.toString, + ) + } + } + + test("wiki-link-navigate-java-wildcard-import".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/java/a/b/Helper.java + |package a.b; + |public class Helper {} + |/a/src/main/java/a/Foo.java + |package a; + |import a.b.*; + |public class Foo { + | /** See {@link Helper}. */ + | public void foo() {} + |} + """.stripMargin + ) + _ <- server.didOpen("a/src/main/java/a/Foo.java") + uri <- clickFirstLink( + "a/src/main/java/a/Foo.java", + """package a; + |import a.b.*; + |public class Foo { + | /** See {@link Helper}. */ + | public void fo@@o() {} + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/b/Helper.java")), uri.toString) + } + + // A wildcard import (`import a.b._`) qualifies a relative link via its prefix, + // so `[[Target]]` resolves to `a.b.Target` (scalameta/metals#3383). + test("wiki-link-navigate-wildcard-import".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/b/Target.scala + |package a.b + |class Target + |/a/src/main/scala/a/Def.scala + |package a + |import a.b._ + |object Def { + | /** See [[Target]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/b/Target.scala")), uri.toString) + } + + // The leading name of a member/nested link is qualified by imports too, so + // `[[Outer.Inner]]` after `import a.b.Outer` resolves to the nested type even + // though the target is dotted (scalameta/metals#3383). + test("wiki-link-navigate-member-on-import".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/b/Outer.scala + |package a.b + |object Outer { + | class Inner + |} + |/a/src/main/scala/a/Def.scala + |package a + |import a.b.Outer + |object Def { + | /** See [[Outer.Inner]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/b/Outer.scala")), uri.toString) + } + + // Two wildcard imports binding the same name to different definitions are + // ambiguous, so the link does not navigate rather than picking one + // arbitrarily (scalameta/metals#3383). + test("wiki-link-ambiguous-wildcard-no-navigation".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/b/Target.scala + |package a.b + |class Target + |/a/src/main/scala/a/c/Target.scala + |package a.c + |class Target + |/a/src/main/scala/a/Def.scala + |package a + |import a.b._ + |import a.c._ + |object Def { + | /** See [[Target]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.isEmpty, uri.toString) + } + + // A member access through a lowercase imported object resolves: `[[util.Tool]]` + // after `import a.b.util` navigates to `a.b.util.Tool`, even though `util` is + // not a capitalized type (scalameta/metals#3383). + test("wiki-link-navigate-lowercase-object-import".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/b/util.scala + |package a.b + |object util { + | class Tool + |} + |/a/src/main/scala/a/Def.scala + |package a + |import a.b.util + |object Def { + | /** See [[util.Tool]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/b/util.scala")), uri.toString) + } + + // A member access through a lowercase object brought in by a wildcard import + // resolves: after `import p._`, `[[util.Tool]]` navigates to `p.util.Tool` + // even though `util` is a lowercase object (scalameta/metals#3383). + test("wiki-link-navigate-wildcard-lowercase-object".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/p/util.scala + |package p + |object util { + | class Tool + |} + |/a/src/main/scala/a/Def.scala + |package a + |import p._ + |object Def { + | /** See [[util.Tool]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/p/util.scala")), uri.toString) + } + + // A nearer wildcard import shadows a farther one: the inner `import a.c._` + // wins over the outer `import a.b._`, so `[[Target]]` resolves to `a.c.Target` + // rather than reporting (false) ambiguity (scalameta/metals#3383). + test("wiki-link-nested-wildcard-shadowing".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/b/Target.scala + |package a.b + |class Target + |/a/src/main/scala/a/c/Target.scala + |package a.c + |class Target + |/a/src/main/scala/a/Def.scala + |package a + |import a.b._ + |object Def { + | import a.c._ + | /** See [[Target]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/c/Target.scala")), uri.toString) + } + + // An explicit import outranks a same-package sibling defined in another file: + // `[[Target]]` resolves to the imported `a.x.Target`, not the sibling + // `a.Target`, matching Scala's binding precedence (scalameta/metals#3383). + test("wiki-link-import-beats-package-sibling".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/x/Target.scala + |package a.x + |class Target + |/a/src/main/scala/a/Sibling.scala + |package a + |class Target + |/a/src/main/scala/a/Def.scala + |package a + |import a.x.Target + |object Def { + | /** See [[Target]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/x/Target.scala")), uri.toString) + } + + // An unimport hides a name from its wildcard, so `[[Target]]` after + // `import a.b.{Target => _, _}` does not resolve through that wildcard and the + // link does not navigate (scalameta/metals#3383). + test("wiki-link-unimport-no-navigation".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/b/Target.scala + |package a.b + |class Target + |/a/src/main/scala/a/Def.scala + |package a + |import a.b.{Target => _, _} + |object Def { + | /** See [[Target]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.isEmpty, uri.toString) + } + + // A same-compilation-unit definition outranks an import: a sibling `Target` + // in the same file beats `import b.Target`, matching Scala precedence + // (scalameta/metals#3383). + test("wiki-link-same-file-beats-import".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/b/Target.scala + |package b + |class Target + |/a/src/main/scala/a/Def.scala + |package a + |import b.Target + |class Target + |object Def { + | /** See [[Target]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/Def.scala")), uri.toString) + } + + // Two explicit imports of the same name in one scope are ambiguous, so the + // link does not navigate to an arbitrary one (scalameta/metals#3383). + test("wiki-link-same-scope-import-ambiguity".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/x/Target.scala + |package x + |class Target + |/a/src/main/scala/y/Target.scala + |package y + |class Target + |/a/src/main/scala/a/Def.scala + |package a + |import x.Target + |import y.Target + |object Def { + | /** See [[Target]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.isEmpty, uri.toString) + } + + // A capitalized package segment is unconventional but legal; `guessFromPath` + // assumes it is a type, so the resolver retries the package interpretation and + // `[[com.Example.Widget]]` still resolves (scalameta/metals#3383). + test("wiki-link-uppercase-package-segment".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/com/Example/Widget.scala + |package com.Example + |class Widget + |/a/src/main/scala/a/Def.scala + |package a + |object Def { + | /** See [[com.Example.Widget]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert( + uri.exists(_.endsWith("/com/Example/Widget.scala")), + uri.toString, + ) + } + + // Clicking an enum-case link relies on the companion-context alternative, as + // the cases live in the enum's companion object (scalameta/metals#3383). + test("wiki-link-navigate-enum-case".tag(FlakyWindows)) { + for { + _ <- initialize( + s"""/metals.json + |{"a":{"scalaVersion":"${V.scala3}"}} + |/a/src/main/scala/a/Color.scala + |package a + |/** See [[Red]]. */ + |enum Color: + | case Red, Green + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Color.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val c: Col@@or = Color.Red + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/Color.scala")), uri.toString) + } + + // An overloaded method link can't be disambiguated (the parameter types are + // dropped while parsing), so clicking it does nothing rather than navigating + // to an arbitrary overload (scalameta/metals#3383). + test("wiki-link-overload-no-navigation".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/Def.scala + |package a + |object Def { + | /** See [[foo]]. */ + | def bar: Int = 0 + | def foo(x: Int): Int = x + | def foo(x: String): String = x + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.ba@@r + |}""".stripMargin, + ) + } yield { + assert(uri.isEmpty, uri.toString) + assert( + client.showMessages.asScala.exists( + _.getMessage().contains("Could not uniquely resolve") + ), + client.showMessages.toString, + ) + } + } + + // A class link must not fall back to its companion object: clicking `[[foo]]` + // in a class that has no `foo` does nothing rather than navigating to the + // companion's `foo` (scalameta/metals#3383). + test("wiki-link-no-companion-navigation".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/Foo.scala + |package a + |/** See [[foo]]. */ + |class Foo + |object Foo { + | def foo: Int = 0 + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Foo.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val x: F@@oo = new Foo + |}""".stripMargin, + ) + } yield { + assert(uri.isEmpty, uri.toString) + assert( + client.showMessages.asScala.exists( + _.getMessage().contains("Could not uniquely resolve") + ), + client.showMessages.toString, + ) + } + } + + // A Javadoc local-member link `{@link #bar}` becomes `[[#bar]]`; the leading + // `#` must be resolved as a member of the enclosing class rather than an + // identifier named `#bar` (scalameta/metals#3383). + test("wiki-link-navigate-java-member".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/java/a/Foo.java + |package a; + |public class Foo { + | /** See {@link #bar}. */ + | public void foo() {} + | public void bar() {} + |} + """.stripMargin + ) + _ <- server.didOpen("a/src/main/java/a/Foo.java") + uri <- clickFirstLink( + "a/src/main/java/a/Foo.java", + """package a; + |public class Foo { + | /** See {@link #bar}. */ + | public void fo@@o() {} + | public void bar() {} + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/Foo.java")), uri.toString) + } + + // A qualified Javadoc member link `{@link Bar#baz}` becomes `[[Bar#baz]]`; the + // `#` is a type/member separator, not part of an identifier, so it must + // resolve to the member of `Bar` (scalameta/metals#3383). + test("wiki-link-navigate-java-qualified-member".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/java/a/Bar.java + |package a; + |public class Bar { + | public void baz() {} + |} + |/a/src/main/java/a/Foo.java + |package a; + |public class Foo { + | /** See {@link Bar#baz}. */ + | public void foo() {} + |} + """.stripMargin + ) + _ <- server.didOpen("a/src/main/java/a/Foo.java") + uri <- clickFirstLink( + "a/src/main/java/a/Foo.java", + """package a; + |public class Foo { + | /** See {@link Bar#baz}. */ + | public void fo@@o() {} + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/Bar.java")), uri.toString) + } + + // A Javadoc constructor link `{@link Bar#Bar(int)}` names the constructor after + // the class; it must resolve to the constructor (``) of `Bar` + // (scalameta/metals#3383). + test("wiki-link-navigate-java-constructor".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/java/a/Bar.java + |package a; + |public class Bar { + | public Bar(int x) {} + |} + |/a/src/main/java/a/Foo.java + |package a; + |public class Foo { + | /** See {@link Bar#Bar(int)}. */ + | public void foo() {} + |} + """.stripMargin + ) + _ <- server.didOpen("a/src/main/java/a/Foo.java") + uri <- clickFirstLink( + "a/src/main/java/a/Foo.java", + """package a; + |public class Foo { + | /** See {@link Bar#Bar(int)}. */ + | public void fo@@o() {} + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/Bar.java")), uri.toString) + } + + // A qualified link to a nested Java type `{@link Outer.Inner#method()}` must + // resolve the nested type as `Outer#Inner` rather than the object-member form + // `Outer.Inner` (scalameta/metals#3383). + test("wiki-link-navigate-java-nested-type".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/java/a/Outer.java + |package a; + |public class Outer { + | public static class Inner { + | public void method() {} + | } + |} + |/a/src/main/java/a/Foo.java + |package a; + |public class Foo { + | /** See {@link Outer.Inner#method()}. */ + | public void foo() {} + |} + """.stripMargin + ) + _ <- server.didOpen("a/src/main/java/a/Foo.java") + uri <- clickFirstLink( + "a/src/main/java/a/Foo.java", + """package a; + |public class Foo { + | /** See {@link Outer.Inner#method()}. */ + | public void fo@@o() {} + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/Outer.java")), uri.toString) + } + + // A bare link to a nested Java type `{@link Outer.Inner}` (no member) must + // resolve the nested type as `Outer#Inner` (scalameta/metals#3383). + test("wiki-link-navigate-java-nested-type-bare".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/java/a/Outer.java + |package a; + |public class Outer { + | public static class Inner {} + |} + |/a/src/main/java/a/Foo.java + |package a; + |public class Foo { + | /** See {@link Outer.Inner}. */ + | public void foo() {} + |} + """.stripMargin + ) + _ <- server.didOpen("a/src/main/java/a/Foo.java") + uri <- clickFirstLink( + "a/src/main/java/a/Foo.java", + """package a; + |public class Foo { + | /** See {@link Outer.Inner}. */ + | public void fo@@o() {} + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/Outer.java")), uri.toString) + } + + // A Javadoc link to a Java member named after a Scala keyword (`Bar#match()`) + // must resolve: Java SemanticDB stores it unwrapped, not backtick-wrapped by + // Scala rules (scalameta/metals#3383). + test("wiki-link-navigate-java-keyword-member".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/java/a/Bar.java + |package a; + |public class Bar { + | public void match() {} + |} + |/a/src/main/java/a/Foo.java + |package a; + |public class Foo { + | /** See {@link Bar#match()}. */ + | public void foo() {} + |} + """.stripMargin + ) + _ <- server.didOpen("a/src/main/java/a/Foo.java") + uri <- clickFirstLink( + "a/src/main/java/a/Foo.java", + """package a; + |public class Foo { + | /** See {@link Bar#match()}. */ + | public void fo@@o() {} + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/Bar.java")), uri.toString) + } + + // Documentation text that happens to mention the sentinel scheme must be left + // untouched: the real marker is prefixed by a private-use code point, so prose + // can never collide with it (scalameta/metals#3383). + test("wiki-link-prose-sentinel-preserved".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/Def.scala + |package a + |object Def { + | /** + | * Rendered as [x](metals-wiki-link:y) internally. + | */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + hover <- server.hover( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + workspace, + ) + } yield assert( + hover.contains("[x](metals-wiki-link:y)"), + hover, + ) + } + + // A Javadoc same-class constructor link `{@link #Foo(int)}` resolves to the + // enclosing class's constructor (scalameta/metals#3383). + test("wiki-link-navigate-java-same-class-constructor".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/java/a/Foo.java + |package a; + |public class Foo { + | public Foo(int x) {} + | /** See {@link #Foo(int)}. */ + | public void method() {} + |} + """.stripMargin + ) + _ <- server.didOpen("a/src/main/java/a/Foo.java") + uri <- clickFirstLink( + "a/src/main/java/a/Foo.java", + """package a; + |public class Foo { + | public Foo(int x) {} + | /** See {@link #Foo(int)}. */ + | public void meth@@od() {} + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/Foo.java")), uri.toString) + } + + // When a link resolves ambiguously (here `[[Outer.Inner]]` matches both the + // companion object's member and the class's nested type), clicking reports + // ambiguity rather than jumping to an arbitrary one (scalameta/metals#3383). + test("wiki-link-ambiguous-no-navigation".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/Outer.scala + |package a + |class Outer { class Inner } + |object Outer { object Inner } + |/a/src/main/scala/a/Def.scala + |package a + |object Def { + | /** See [[Outer.Inner]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Outer.scala") + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield { + assert(uri.isEmpty, uri.toString) + assert( + client.showMessages.asScala.exists( + _.getMessage().contains("Could not uniquely resolve") + ), + client.showMessages.toString, + ) + } + } + + // Per the JLS, a single-type import shadows a same-named type declared in + // another compilation unit of the package, so `{@link Helper}` resolves to the + // imported `a.b.Helper`, not the same-package sibling `a.Helper` + // (scalameta/metals#3383). + test("wiki-link-java-import-beats-package-sibling".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/java/a/b/Helper.java + |package a.b; + |public class Helper {} + |/a/src/main/java/a/Helper.java + |package a; + |public class Helper {} + |/a/src/main/java/a/Foo.java + |package a; + |import a.b.Helper; + |public class Foo { + | /** See {@link Helper}. */ + | public void foo() {} + |} + """.stripMargin + ) + _ <- server.didOpen("a/src/main/java/a/Foo.java") + uri <- clickFirstLink( + "a/src/main/java/a/Foo.java", + """package a; + |import a.b.Helper; + |public class Foo { + | /** See {@link Helper}. */ + | public void fo@@o() {} + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/b/Helper.java")), uri.toString) + } + + // Imports are positionally scoped within a template: an `import` placed between + // two documented members applies only to the member after it, so the earlier + // member does not see it (this exercises the per-source memoized scope snapshot) + // (scalameta/metals#3383). + test("wiki-link-mid-scope-import".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/x/Target.scala + |package a.x + |class Target + |/a/src/main/scala/a/Def.scala + |package a + |object Def { + | /** See [[Target]]. */ + | def early(i: Int): Int = ??? + | import a.x.Target + | /** See [[Target]]. */ + | def late(i: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + lateUri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.la@@te(1) + |}""".stripMargin, + ) + earlyUri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.ea@@rly(1) + |}""".stripMargin, + ) + } yield { + assert(lateUri.exists(_.endsWith("/a/x/Target.scala")), lateUri.toString) + // `early` precedes the import, so it must not resolve through it. + assert(earlyUri.isEmpty, earlyUri.toString) + } + } + + // A relative import prefix is resolved against the enclosing package: in + // `package a`, `import b.Target` means `a.b.Target`, so `[[Target]]` navigates + // there even though the literal prefix is just `b` (scalameta/metals#3383). + test("wiki-link-navigate-relative-import".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/b/Target.scala + |package a.b + |class Target + |/a/src/main/scala/a/Def.scala + |package a + |import b.Target + |object Def { + | /** See [[Target]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/b/Target.scala")), uri.toString) + } + + // A member of a package object resolves from a bare link in another file of the + // package, since the `pkg/package.member` form is synthesized after the + // enclosing package qualifies the name (scalameta/metals#3383). + test("wiki-link-navigate-bare-package-object-member".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/p/pkg.scala + |package a + |package object p { + | val answer: Int = 42 + |} + |/a/src/main/scala/a/p/Other.scala + |package a.p + |object Other { + | /** See [[answer]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/p/pkg.scala") + _ <- server.didSave("a/src/main/scala/a/p/Other.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = a.p.Other.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/p/pkg.scala")), uri.toString) + } + + // A Javadoc link parses by the documentation's own language, not the hovered + // file's: `$` is an ordinary Java identifier character, so `{@link Money$}` + // resolves to the Java class rather than being read as a value-force suffix + // (scalameta/metals#3383). + test("wiki-link-navigate-java-dollar-type".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/java/a/Money$.java + |package a; + |public class Money$ {} + |/a/src/main/java/a/Foo.java + |package a; + |public class Foo { + | /** See {@link Money$}. */ + | public void foo() {} + |} + """.stripMargin + ) + _ <- server.didOpen("a/src/main/java/a/Foo.java") + uri <- clickFirstLink( + "a/src/main/java/a/Foo.java", + """package a; + |public class Foo { + | /** See {@link Money$}. */ + | public void fo@@o() {} + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/Money$.java")), uri.toString) + } + + // A symbolic-or-dotted escaped identifier brought in by a wildcard import is + // still a single name, so `` [[`Foo.Bar`]] `` after `import a.p.syntax._` + // navigates to it (scalameta/metals#3383). + test("wiki-link-escaped-dotted-wildcard".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/p/syntax.scala + |package a.p + |object syntax { + | class `Foo.Bar` + |} + |/a/src/main/scala/a/Def.scala + |package a + |import a.p.syntax._ + |object Def { + | /** See [[`Foo.Bar`]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/p/syntax.scala") + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/p/syntax.scala")), uri.toString) + } + + // A static on-demand import (`import static Util.*`) brings in static members, + // so `{@link helper}` resolves to the static method (scalameta/metals#3383). + test("wiki-link-navigate-java-static-wildcard".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/java/a/b/Util.java + |package a.b; + |public class Util { + | public static int helper() { return 0; } + |} + |/a/src/main/java/a/Foo.java + |package a; + |import static a.b.Util.*; + |public class Foo { + | /** See {@link helper}. */ + | public void foo() {} + |} + """.stripMargin + ) + _ <- server.didOpen("a/src/main/java/a/Foo.java") + uri <- clickFirstLink( + "a/src/main/java/a/Foo.java", + """package a; + |import static a.b.Util.*; + |public class Foo { + | /** See {@link helper}. */ + | public void fo@@o() {} + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/b/Util.java")), uri.toString) + } + + // Per the JLS, an on-demand import (`import a.b.*`) and the implicit + // `java.lang.*` are equal precedence, so a name in both (`String`) is ambiguous + // and does not navigate (scalameta/metals#3383). + test("wiki-link-java-wildcard-vs-java-lang-ambiguity".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/java/a/b/String.java + |package a.b; + |public class String {} + |/a/src/main/java/a/Foo.java + |package a; + |import a.b.*; + |public class Foo { + | /** See {@link String}. */ + | public void foo() {} + |} + """.stripMargin + ) + _ <- server.didOpen("a/src/main/java/a/Foo.java") + uri <- clickFirstLink( + "a/src/main/java/a/Foo.java", + """package a; + |import a.b.*; + |public class Foo { + | /** See {@link String}. */ + | public void fo@@o() {} + |}""".stripMargin, + ) + } yield assert(uri.isEmpty, uri.toString) + } + + // A same-package definition from another file shadows the implicit/compiler + // scope, so a same-package `class String` beats `java.lang.String` + // (scalameta/metals#3383). + test("wiki-link-same-package-beats-root".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/MyString.scala + |package a + |class String + |/a/src/main/scala/a/Def.scala + |package a + |object Def { + | /** See [[String]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/MyString.scala")), uri.toString) + } + + // A relative wildcard import is resolved against the enclosing package: in + // `package a`, `import b._` brings in `a.b`, so `[[Target]]` navigates to + // `a.b.Target` (scalameta/metals#3383). + test("wiki-link-relative-wildcard-import".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/b/Target.scala + |package a.b + |class Target + |/a/src/main/scala/a/Def.scala + |package a + |import b._ + |object Def { + | /** See [[Target]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/b/Target.scala")), uri.toString) + } + + // A wildcard on a prefix alias resolves through the rename: after + // `import a.p.{util => u}; import u._`, `[[Tool]]` navigates to `a.p.util.Tool` + // (scalameta/metals#3383). + // An EXPLICIT import through a rename-alias is expanded too: `import a.p.{util => + // u}; import u.Tool` makes `[[Tool]]` resolve to `a.p.util.Tool` (not the bogus + // `u.Tool`) — alias expansion is no longer wildcard-only (scalameta/metals#3383). + test("wiki-link-aliased-explicit-import".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/p/util.scala + |package a.p + |object util { + | class Tool + |} + |/a/src/main/scala/a/Def.scala + |package a + |import a.p.{util => u} + |import u.Tool + |object Def { + | /** See [[Tool]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/p/util.scala") + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/p/util.scala")), uri.toString) + } + + // Only the LEADING segment of an import prefix needs to be the alias: with + // `import a.p.{util => u}; import u.syntax.Tool`, `[[Tool]]` resolves to + // `a.p.util.syntax.Tool` (the `u` segment is expanded, the rest of the path + // kept), not the bogus `u.syntax.Tool` (scalameta/metals#3383). + test("wiki-link-aliased-nested-explicit-import".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/p/util.scala + |package a.p + |object util { + | object syntax { + | class Tool + | } + |} + |/a/src/main/scala/a/Def.scala + |package a + |import a.p.{util => u} + |import u.syntax.Tool + |object Def { + | /** See [[Tool]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/p/util.scala") + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/p/util.scala")), uri.toString) + } + + // A CHAINED alias resolves through every hop: `import a.p.{util => u}` then + // `import u.{syntax => s}` then `import s.Tool` — `s` must expand to + // `a.p.util.syntax`, not the unexpanded `u.syntax`, so `[[Tool]]` resolves + // (scalameta/metals#3383). + test("wiki-link-chained-alias-import".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/p/util.scala + |package a.p + |object util { + | object syntax { + | class Tool + | } + |} + |/a/src/main/scala/a/Def.scala + |package a + |import a.p.{util => u} + |import u.{syntax => s} + |import s.Tool + |object Def { + | /** See [[Tool]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/p/util.scala") + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/p/util.scala")), uri.toString) + } + + // A BACKTICKED alias prefix expands: `` import a.p.{util => `type`} `` binds the + // alias `type` (a keyword, so it must be escaped), and `` import `type`.Tool `` + // uses it — the head `` `type` `` must be unescaped before it is matched against + // the alias key `type`, so `[[Tool]]` resolves to `a.p.util.Tool` + // (scalameta/metals#3383). + test("wiki-link-backticked-alias-import".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/p/util.scala + |package a.p + |object util { + | class Tool + |} + |/a/src/main/scala/a/Def.scala + |package a + |import a.p.{util => `type`} + |import `type`.Tool + |object Def { + | /** See [[Tool]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/p/util.scala") + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/p/util.scala")), uri.toString) + } + + // An import inherited from an OUTER package scope resolves against the package + // where IT is written, not the documented symbol's package: `import p.Target` + // sits in `package a`, so it expands to `a.p.Target` even though the doc using + // `[[Target]]` lives in the nested `package b` (`a.b`) — NOT the bogus + // `a.b.p.Target` (scalameta/metals#3383). + test("wiki-link-outer-package-relative-import".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/p/Target.scala + |package a.p + |class Target + |/a/src/main/scala/a/Def.scala + |package a + |import p.Target + |package b { + | object Def { + | /** See [[Target]]. */ + | def foo(x: Int): Int = ??? + | } + |} + |/a/src/main/scala/a/Main.scala + |package a.b + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/p/Target.scala") + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a.b + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/p/Target.scala")), uri.toString) + } + + // `_root_` detection is the real root selector, not any name that merely starts + // with those characters: `import _root_foo.Target` inside `package a` is an + // ordinary RELATIVE import, so `[[Target]]` resolves to `a._root_foo.Target` + // (scalameta/metals#3383). + test("wiki-link-root-lookalike-relative-import".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/_root_foo/Target.scala + |package a._root_foo + |class Target + |/a/src/main/scala/a/Def.scala + |package a + |import _root_foo.Target + |object Def { + | /** See [[Target]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/_root_foo/Target.scala") + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert( + uri.exists(_.endsWith("/a/_root_foo/Target.scala")), + uri.toString, + ) + } + + // An import written OUTSIDE a `package object` resolves against the enclosing + // package, not the package object's: `import p.Target` sits in `package a`, so it + // expands to `a.p.Target` even though the doc using `[[Target]]` lives in the + // `package object q` (whose members are in `a.q`) — NOT `a.q.p.Target`. Guards + // that the climb strips the `Pkg.Object` segment (scalameta/metals#3383). + test("wiki-link-outer-import-in-package-object".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/p/Target.scala + |package a.p + |class Target + |/a/src/main/scala/a/q/package.scala + |package a + |import p.Target + |package object q { + | /** See [[Target]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a.q + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/p/Target.scala") + _ <- server.didSave("a/src/main/scala/a/q/package.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a.q + |object Main { + | val res = fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/p/Target.scala")), uri.toString) + } + + // A keyword-named package block (`` package `type` ``) is stripped from the scope + // package when the climb leaves it, even though scalameta re-escapes it in + // `ref.syntax`: the root-level `import q.Widget` resolves against the ROOT (only + // `q.Widget`), NOT a spurious `` `type`.q.Widget `` — which would be a false match + // here since `type.q.Widget` also exists (scalameta/metals#3383). + test("wiki-link-keyword-package-outer-import".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{"scalaVersion":"3.3.8"}} + |/a/src/main/scala/q/Widget.scala + |package q + |class Widget + |/a/src/main/scala/kw/Widget.scala + |package `type`.q + |class Widget + |/a/src/main/scala/a/Def.scala + |import q.Widget + |package `type` { + | object Def { + | /** See [[Widget]]. */ + | def foo(x: Int): Int = ??? + | } + |} + |/a/src/main/scala/a/Main.scala + |package `type` + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/q/Widget.scala") + _ <- server.didSave("a/src/main/scala/kw/Widget.scala") + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package `type` + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/q/Widget.scala")), uri.toString) + } + + // A grouped/bare `_root_` import binds at the root: `` import _root_.{p => q} `` + // makes `q` the package `p`, so `[[q.Target]]` resolves to `p.Target`, NOT the + // nonexistent `_root_.p.Target` (scalameta/metals#3383). + test("wiki-link-root-grouped-alias-import".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/p/Target.scala + |package p + |class Target + |/a/src/main/scala/a/Def.scala + |package a + |import _root_.{p => q} + |object Def { + | /** See [[q.Target]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/p/Target.scala") + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/p/Target.scala")), uri.toString) + } + + // Same-scope alias expansion respects source order: `import u.Tool` (where `u` is + // the package `a.u`) is NOT re-bound by a LATER same-scope `import a.p.{util => + // u}` that wasn't visible when it was parsed, so `[[Tool]]` resolves to + // `a.u.Tool`, not the bogus `a.p.util.Tool` (scalameta/metals#3383). + test("wiki-link-alias-respects-source-order".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/u/Tool.scala + |package a.u + |class Tool + |/a/src/main/scala/a/p/util.scala + |package a.p + |object util { + | class Other + |} + |/a/src/main/scala/a/Def.scala + |package a + |import u.Tool + |import a.p.{util => u} + |object Def { + | /** See [[Tool]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/u/Tool.scala") + _ <- server.didSave("a/src/main/scala/a/p/util.scala") + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/u/Tool.scala")), uri.toString) + } + + test("wiki-link-aliased-wildcard-import".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/p/util.scala + |package a.p + |object util { + | class Tool + |} + |/a/src/main/scala/a/Def.scala + |package a + |import a.p.{util => u} + |import u._ + |object Def { + | /** See [[Tool]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/p/util.scala") + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/p/util.scala")), uri.toString) + } + + // An OUTER-scope alias is visible to an INNER-scope wildcard of it: with + // `import a.p.{util => u}` at the file and `import u._` inside the object, a bare + // `[[Tool]]` still resolves to `a.p.util.Tool` (scalameta/metals#3383). + test("wiki-link-outer-alias-inner-wildcard".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/p/util.scala + |package a.p + |object util { + | class Tool + |} + |/a/src/main/scala/a/Def.scala + |package a + |import a.p.{util => u} + |object Def { + | import u._ + | /** See [[Tool]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/p/util.scala") + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/p/util.scala")), uri.toString) + } + + // An inherited member link fails SAFELY: `[[Widget#paint]]`, where the same-file + // `Widget` only INHERITS `paint` (so it can't be resolved here), must NOT fall + // through to an unrelated `import other.Widget` that declares its own `paint` + // (scalameta/metals#3383). + test("wiki-link-inherited-member-safe-failure".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/other/Widget.scala + |package other + |class Widget { + | def paint(): Unit = () + |} + |/a/src/main/scala/a/Def.scala + |package a + |import other.Widget + |class Base { + | def paint(): Unit = () + |} + |class Widget extends Base + |object Def { + | /** See [[Widget#paint]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/other/Widget.scala") + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert( + !uri.exists(_.contains("/other/Widget.scala")), + s"inherited member link wrongly navigated to an unrelated import: $uri", + ) + } + + // An inherited docstring must refresh when the PARENT's file is edited, even + // though the child's own file is unchanged. The merged parent doc is no longer + // cached under the child's key, so it is re-derived from the re-indexed parent — + // without this, the child kept showing the parent's stale text (and stale encoded + // link scope) until restart (scalameta/metals#3383). + test("wiki-link-inherited-doc-refresh".tag(FlakyWindows)) { + val query = + """ + |package a + |object Main { + | val res = new Child().fo@@o(1) + |}""".stripMargin + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/Parent.scala + |package a + |class Parent { + | /** parent-docs-v1 */ + | def foo(x: Int): Int = x + |} + |/a/src/main/scala/a/Child.scala + |package a + |class Child extends Parent { + | override def foo(x: Int): Int = x + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Parent.scala") + _ <- server.didSave("a/src/main/scala/a/Child.scala") + before <- server.hover("a/src/main/scala/a/Main.scala", query, workspace) + _ = assert(before.contains("parent-docs-v1"), before) + // Edit ONLY the parent's file. + _ <- server.didChange("a/src/main/scala/a/Parent.scala")( + _.replace("parent-docs-v1", "parent-docs-v2") + ) + _ <- server.didSave("a/src/main/scala/a/Parent.scala") + after <- server.hover("a/src/main/scala/a/Main.scala", query, workspace) + _ = assert(after.contains("parent-docs-v2"), s"stale child doc: $after") + _ = assert(!after.contains("parent-docs-v1"), s"stale child doc: $after") + } yield () + } + + // Per Scala, an OUTER explicit import and an INNER wildcard import that both bind + // a name do NOT shadow each other, so the name is AMBIGUOUS — `[[Target]]` here + // navigates nowhere instead of guessing the explicit `x.Target` + // (scalameta/metals#3383). + test("wiki-link-outer-explicit-inner-wildcard-ambiguous".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/x/Target.scala + |package x + |class Target + |/a/src/main/scala/y/Target.scala + |package y + |class Target + |/a/src/main/scala/a/Def.scala + |package a + |import x.Target + |object Def { + | import y._ + | /** See [[Target]]. */ + | def foo(z: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/x/Target.scala") + _ <- server.didSave("a/src/main/scala/y/Target.scala") + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.isEmpty, s"ambiguous link should not navigate: $uri") + } + + // An owner bound by an EXPLICIT import caps the member: `[[Widget#paint]]` where + // `import p.Widget` only INHERITS `paint` (so it misses) must NOT fall through to + // a same-scope `import q._` whose `q.Widget` declares its own `paint` + // (scalameta/metals#3383). + test("wiki-link-imported-inherited-member-capped".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/p/Widget.scala + |package p + |class Base { def paint(): Unit = () } + |class Widget extends Base + |/a/src/main/scala/q/Widget.scala + |package q + |class Widget { def paint(): Unit = () } + |/a/src/main/scala/a/Def.scala + |package a + |import p.Widget + |import q._ + |object Def { + | /** See [[Widget#paint]]. */ + | def foo(z: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/p/Widget.scala") + _ <- server.didSave("a/src/main/scala/q/Widget.scala") + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert( + !uri.exists(_.contains("/q/Widget.scala")), + s"capped owner wrongly navigated to an unrelated import: $uri", + ) + } + + // When the owner NAME is itself ambiguous (an OUTER explicit and an INNER + // wildcard both bind `Widget`, neither shadowing the other), a member link fails + // SAFELY — `[[Widget#paint]]` does NOT jump to whichever Widget happens to + // declare `paint` (scalameta/metals#3383). + test("wiki-link-ambiguous-owner-member-safe-failure".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/x/Widget.scala + |package x + |class Widget + |/a/src/main/scala/y/Widget.scala + |package y + |class Widget { def paint(): Unit = () } + |/a/src/main/scala/a/Def.scala + |package a + |import x.Widget + |object Def { + | import y._ + | /** See [[Widget#paint]]. */ + | def foo(z: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/x/Widget.scala") + _ <- server.didSave("a/src/main/scala/y/Widget.scala") + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert( + uri.isEmpty, + s"ambiguous owner should not navigate for a member link: $uri", + ) + } + + // A `_root_`-anchored import is absolute: its prefix is stripped so + // `import _root_.a.b.Target` resolves like `import a.b.Target` + // (scalameta/metals#3383). + test("wiki-link-navigate-root-import".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/b/Target.scala + |package a.b + |class Target + |/a/src/main/scala/a/Def.scala + |package a + |import _root_.a.b.Target + |object Def { + | /** See [[Target]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/b/Target.scala")), uri.toString) + } + + // A renamed selector hides the original name even from a trailing wildcard: + // `import a.b.{Target => Renamed, _}` binds `Renamed`, not `Target`, so + // `[[Target]]` does not navigate (scalameta/metals#3383). + test("wiki-link-rename-hides-original-wildcard".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/b/Target.scala + |package a.b + |class Target + |/a/src/main/scala/a/Def.scala + |package a + |import a.b.{Target => Renamed, _} + |object Def { + | /** See [[Target]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.isEmpty, uri.toString) + } + + // A member of a package object resolves through the synthesized + // `pkg/package.member` form, generalized beyond the `scala` package object + // (scalameta/metals#3383). + test("wiki-link-navigate-package-object-member".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/util/util.scala + |package a + |package object util { + | val answer: Int = 42 + |} + |/a/src/main/scala/a/Def.scala + |package a + |object Def { + | /** See [[a.util.answer]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/util/util.scala") + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/util/util.scala")), uri.toString) + } + + // A parameterized enum case is a case class; its docstring's relative links + // resolve against the case itself, so `[[r]]` finds the case parameter rather + // than falling through to the enum's companion (scalameta/metals#3383). + test("wiki-link-navigate-enum-case-param".tag(FlakyWindows)) { + for { + _ <- initialize( + s"""/metals.json + |{"a":{"scalaVersion":"${V.scala3}"}} + |/a/src/main/scala/a/Color.scala + |package a + |enum Color: + | /** A component [[r]]. */ + | case Mix(r: Int) + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Color.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val m = Color.Mi@@x(1) + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/Color.scala")), uri.toString) + } + + // Boundary recovery flips several `/`<->`.` boundaries at once, so a chain of + // nested lowercase objects (`outer.inner.Box`, which `guessFromPath` commits to + // packages) still resolves (scalameta/metals#3383). + test("wiki-link-multi-boundary-recovery".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/Nested.scala + |package a + |object outer { + | object inner { + | class Box + | } + |} + |/a/src/main/scala/a/Def.scala + |package a + |object Def { + | /** See [[outer.inner.Box]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/Nested.scala") + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/Nested.scala")), uri.toString) + } + + // A symbolic operator brought in by a wildcard import is qualifiable too, so + // `` [[`===`]] `` after `import a.ops.syntax._` navigates to the operator + // (scalameta/metals#3383). + test("wiki-link-operator-wildcard-import".tag(FlakyWindows)) { + for { + _ <- initialize( + """/metals.json + |{"a":{}} + |/a/src/main/scala/a/ops/syntax.scala + |package a.ops + |object syntax { + | def ===(x: Int): Boolean = true + |} + |/a/src/main/scala/a/Def.scala + |package a + |import a.ops.syntax._ + |object Def { + | /** See [[`===`]]. */ + | def foo(x: Int): Int = ??? + |} + |/a/src/main/scala/a/Main.scala + |package a + |object Main { + |} + """.stripMargin + ) + _ <- server.didSave("a/src/main/scala/a/ops/syntax.scala") + _ <- server.didSave("a/src/main/scala/a/Def.scala") // index docs + uri <- clickFirstLink( + "a/src/main/scala/a/Main.scala", + """ + |package a + |object Main { + | val res = Def.fo@@o(1) + |}""".stripMargin, + ) + } yield assert(uri.exists(_.endsWith("/a/ops/syntax.scala")), uri.toString) + } + +} diff --git a/tests/unit/src/test/scala/tests/JavadocSuite.scala b/tests/unit/src/test/scala/tests/JavadocSuite.scala index b0ba87445d4d..b921cec76bb5 100644 --- a/tests/unit/src/test/scala/tests/JavadocSuite.scala +++ b/tests/unit/src/test/scala/tests/JavadocSuite.scala @@ -10,7 +10,10 @@ class JavadocSuite extends BaseSuite { loc: Location ): Unit = { test(name) { - val obtained = MarkdownGenerator.toText(original).mkString + // `MarkdownGenerator` marks entity links for the server to resolve; decode + // them back to their plain target to assert the logical rendering here. + val obtained = + DocstringMarkers.decode(MarkdownGenerator.toText(original).mkString) assertNoDiff(obtained, expected) } } @@ -36,6 +39,29 @@ class JavadocSuite extends BaseSuite { |- [previousProblemsFromSuccessfulCompilation](previousProblemsFromSuccessfulCompilation)""".stripMargin, ) + // URI schemes are case-insensitive, so an uppercase URL stays an external link + // rather than becoming a (broken) symbol reference (scalameta/metals#3383). + check( + "uppercase-url-scheme", + """/** + |* See [[HTTPS://example.com Example]]. + |*/ + """.stripMargin, + "See [Example](HTTPS://example.com).", + ) + + // A quoted `@see "..."` is plain text per the Javadoc spec, not a link + // (scalameta/metals#3383). + check( + "see-quoted-text", + """/** + |* @see "Effective Java" + |*/ + """.stripMargin, + """|**See** + |- "Effective Java"""".stripMargin, + ) + check( "escapee", """/** diff --git a/tests/unit/src/test/scala/tests/MarkdownGeneratorSuite.scala b/tests/unit/src/test/scala/tests/MarkdownGeneratorSuite.scala new file mode 100644 index 000000000000..4e9f3782bd34 --- /dev/null +++ b/tests/unit/src/test/scala/tests/MarkdownGeneratorSuite.scala @@ -0,0 +1,177 @@ +package tests + +import java.util.Locale + +import scala.meta.internal.docstrings.ImportFallbacks +import scala.meta.internal.docstrings.MetalsSymbolLink +import scala.meta.internal.docstrings.printers.MarkdownGenerator + +class MarkdownGeneratorSuite extends BaseSuite { + + // An uppercase URL scheme (`FILE:`/`MAILTO:`) must be recognised as a URL + // regardless of the default locale. Under the Turkish locale a naive + // `"FILE".toLowerCase` yields `"fıle"` (dotless i), which used to be + // misclassified as a symbol link and wrapped with the resolution sentinel + // (scalameta/metals#3383). + test("uppercase-url-scheme-locale-insensitive") { + val previous = Locale.getDefault() + try { + Locale.setDefault(new Locale("tr")) + val rendered = + MarkdownGenerator.fromDocstring( + "/** [[FILE:/etc/hosts]] and [[MAILTO:a@b.com]] */", + Map.empty, + ) + assert( + !rendered.contains(MetalsSymbolLink.scheme), + s"URL scheme was treated as a symbol link: $rendered", + ) + assert( + rendered.contains("(FILE:/etc/hosts)"), + s"expected a plain URL link, got: $rendered", + ) + } finally Locale.setDefault(previous) + } + + // A URL is recognised only by a leading `scheme:` (any RFC 3986 scheme, + // including dotted custom ones), never by a bare `/` — because a `/` also + // appears in Scala operator members (`BigDecimal./`, `divide_/`) and Javadoc + // module prefixes (`java.base/...`), which must stay symbols + // (scalameta/metals#3383). + test("external-uri-schemes-vs-symbol-links") { + def render(link: String): String = + MarkdownGenerator.fromDocstring(s"/** $link */", Map.empty) + def assertUrl(target: String): Unit = { + val rendered = render(s"[[$target]]") + assert( + !rendered.contains(MetalsSymbolLink.scheme), + s"external link treated as a symbol: $rendered", + ) + assert( + rendered.contains(s"($target)"), + s"expected a verbatim link to $target, got: $rendered", + ) + } + def assertSymbol(target: String): Unit = { + val rendered = render(s"[[$target]]") + assert( + rendered.contains(MetalsSymbolLink.scheme), + s"symbol link was not marked: $rendered", + ) + } + // Schemed URLs (incl. non-allowlisted, slash-bearing, and dotted schemes). + assertUrl("urn:isbn:0451450523") + assertUrl("jar:file:///lib.jar!/p/C.class") + assertUrl("jrt:/java.base/java/lang/String.class") + assertUrl("news:comp.lang.scala") + assertUrl("x-doc:custom/Page") + assertUrl("com.example:page") + // The marker scheme typed by a user stays an EXTERNAL url — the real marker + // carries a private-use prefix the user can't write, so it is never hijacked + // into a goto command (scalameta/metals#3383). + assertUrl("metals-wiki-link2:foo") + // Symbol links that contain `/` or `:` but are not URLs — including + // right-associative operator members whose path ends in `+`/`-` before the + // `:` (a scheme-legal char), which must still be symbols. + assertSymbol("BigDecimal./") + assertSymbol("divide_/") + assertSymbol("java.base/java.lang.String") + assertSymbol("scala.collection.immutable.:: cons") + assertSymbol("List.+:") + assertSymbol("Seq.++:") + assertSymbol("List.-:") + assertSymbol("Vector.:+") + } + + // `fallbacksForTarget` splits a link into the leading import-qualifiable name, + // its remaining suffix, and whether the wildcard/implicit scopes apply + // (`bareScope`): a bare name or a single member access qualifies, an + // already-qualified package path does not. A value-force `$` is kept as a + // suffix while an embedded `$` and a backtick-escaped name stay whole + // (scalameta/metals#3383). + test("fallbacks-for-target-split") { + def split( + target: String, + isJava: Boolean = false, + ): (String, String, Boolean) = { + var captured: (String, String, Boolean) = ("", "", false) + MetalsSymbolLink.fallbacksForTarget( + target, + isJava, + (name, rest, bare) => { + captured = (name, rest, bare) + ImportFallbacks.empty + }, + ) + captured + } + // A bare type qualifies. + assertEquals(split("Future"), ("Future", "", true)) + // An already-qualified package path does not. + assertEquals( + split("scala.concurrent.Future"), + ("scala", ".concurrent.Future", false), + ) + // A single member access qualifies (`[[Option.empty]]`-style). + assertEquals(split("Future.successful"), ("Future", ".successful", true)) + assertEquals(split("util.Helper"), ("util", ".Helper", true)) + // Only TOP-LEVEL dots make a package path, so a single member whose name is a + // backticked dotted string or whose signature carries dots still qualifies — + // it is NOT mistaken for `scala.concurrent.Future` (scalameta/metals#3383). + assertEquals(split("util.`a.b`"), ("util", ".`a.b`", true)) + assertEquals( + split("util.tool(p.q.R)"), + ("util", ".tool(p.q.R)", true), + ) + // In SCALA a value-force `$` and a type-force `!` are kept as the suffix; the + // bare name still qualifies via the wildcard/implicit scope. + assertEquals(split("Future$"), ("Future", "$", true)) + assertEquals(split("Future!"), ("Future", "!", true)) + // In JAVA `$` is an identifier char with no value-force, so `{@link Money$}` + // keeps it (else the explicit import key `Money$` is missed). + assertEquals(split("Money$", isJava = true), ("Money$", "", true)) + assertEquals(split("Money$", isJava = false), ("Money", "$", true)) + // An embedded `$` stays in the name. + assertEquals(split("Foo$Bar"), ("Foo$Bar", "", true)) + // A `$` before a member access is kept in the name, not a force suffix. + assertEquals(split("Foo$#bar"), ("Foo$", "#bar", true)) + // A backtick-escaped name (with a space) is returned whole. + assertEquals(split("`my type`"), ("`my type`", "", true)) + } + + // `memberLinkOwner` identifies the SIMPLE owner an import could rebind, so the + // resolver can confine member selection to it. A bare type, an already-qualified + // path, or a signature yields None (scalameta/metals#3383). + test("member-link-owner") { + def owner(target: String, isJava: Boolean = false): Option[String] = + MetalsSymbolLink.memberLinkOwner(target, isJava) + // A member selected off a simple bare name. + assertEquals(owner("Child#fromParent"), Some("Child")) + assertEquals(owner("Child#fromParent(x)"), Some("Child")) + assertEquals(owner("Future.successful"), Some("Future")) + assertEquals(owner("`my type`#m"), Some("`my type`")) + // In Java the owner keeps a trailing `$` (`Money$#field`). + assertEquals(owner("Money$#field", isJava = true), Some("Money$")) + // Only TOP-LEVEL dots qualify a path, so a single member whose name is a + // backticked dotted string or whose signature carries dots still resolves the + // bare owner `Foo`, letting a local `Foo` cap it (scalameta/metals#3383). + assertEquals(owner("Foo.`a.b`"), Some("Foo")) + assertEquals(owner("Foo.bar(java.lang.String)"), Some("Foo")) + // A type-argument list is stripped, so a GENERIC owner is still capped exactly + // as the resolver (which also drops `[Int]`) resolves it (scalameta/metals#3383). + assertEquals(owner("Widget[Int]#paint"), Some("Widget")) + assertEquals(owner("Widget[Int].paint"), Some("Widget")) + // A bare type, an object value-force, or a signature: nothing to confine. + assertEquals(owner("Future"), None) + assertEquals(owner("Future$"), None) + assertEquals(owner("Foo(x)"), None) + // An already-qualified path — a leading package segment an import can't rebind, + // whether a member (`a.b.Foo#m`) or a plain multi-segment path + // (`scala.concurrent.Future`, indistinguishable from an object chain + // `Foo.bar.baz`, so conservatively left uncapped) — yields None. + assertEquals(owner("a.b.Foo#m"), None) + assertEquals(owner("scala.concurrent.Future"), None) + assertEquals(owner("Foo.bar.baz"), None) + } + +} diff --git a/tests/unit/src/test/scala/tests/MetalsSymbolLinkSuite.scala b/tests/unit/src/test/scala/tests/MetalsSymbolLinkSuite.scala new file mode 100644 index 000000000000..23e84d4131b5 --- /dev/null +++ b/tests/unit/src/test/scala/tests/MetalsSymbolLinkSuite.scala @@ -0,0 +1,176 @@ +package tests + +import scala.meta.internal.docstrings.DocScope +import scala.meta.internal.docstrings.ImportLevel +import scala.meta.internal.docstrings.ImportScope +import scala.meta.internal.docstrings.MetalsSymbolLink + +/** + * Round-trips the structured wiki-link marker (`DocScope` + target) through + * `withDocScope` (embed) and `parsePayload` (extract), since a codec bug would + * silently break hover navigation (scalameta/metals#3383). + */ +class MetalsSymbolLinkSuite extends BaseSuite { + + private def roundTrip(docScope: DocScope, target: String): Unit = { + val rendered = + s"see [x](${MetalsSymbolLink.scheme}${MetalsSymbolLink.encode(target)}) end" + val embedded = MetalsSymbolLink.withDocScope(rendered, docScope) + val start = + embedded.indexOf(MetalsSymbolLink.scheme) + MetalsSymbolLink.scheme.length + val end = embedded.indexOf(')', start) + val parsed = MetalsSymbolLink.parsePayload(embedded.substring(start, end)) + assertEquals(parsed.target, target, s"target for $docScope") + assertEquals(parsed.docScope, docScope, s"docScope for $target") + } + + test("empty-scope")(roundTrip(DocScope.empty, "scala.Foo")) + + // The scope payload is injected only at a real marker TARGET (`](`+scheme), never + // at a `scheme` occurrence inside the visible label, which would corrupt the + // rendered hover text (scalameta/metals#3383). + test("scheme-in-label-not-corrupted") { + val target = "scala.Foo" + val label = s"see ${MetalsSymbolLink.scheme} here" + val rendered = + s"[$label](${MetalsSymbolLink.scheme}${MetalsSymbolLink.encode(target)})" + val out = MetalsSymbolLink.withDocScope( + rendered, + DocScope( + Some("a/O."), + None, + isJava = false, + ImportScope.empty, + None, + docIsScala3 = false, + ), + ) + // The label (including its scheme substring) is preserved verbatim. + assert(out.contains(s"[$label]"), out) + // The target still round-trips through the (prefixed) marker. + val payloadStart = + out.indexOf(MetalsSymbolLink.markerLinkOpen) + + MetalsSymbolLink.markerLinkOpen.length + val payloadEnd = out.indexOf(')', payloadStart) + val parsed = + MetalsSymbolLink.parsePayload(out.substring(payloadStart, payloadEnd)) + assertEquals(parsed.target, target) + assertEquals(parsed.docScope.owner, Some("a/O.")) + } + + test("owner-and-alternative")( + roundTrip( + DocScope( + Some("a/O."), + Some("a/O#"), + isJava = false, + ImportScope.empty, + Some("file:///a/O.scala"), + docIsScala3 = true, + ), + "foo", + ) + ) + + test("explicit-imports")( + roundTrip( + DocScope( + Some("a/O."), + None, + isJava = false, + ImportScope( + List( + ImportLevel( + Map( + "Future" -> List("scala.concurrent.Future", "a.b.Future"), + "X" -> List("p.X"), + ), + Nil, + ) + ) + ), + None, + docIsScala3 = false, + ), + "Future", + ) + ) + + test("wildcards-with-type-force")( + roundTrip( + DocScope( + None, + None, + isJava = true, + ImportScope( + List( + ImportLevel( + Map.empty, + List( + ("a.b", Set("X", "Y"), false), + ("c", Set.empty[String], true), + ), + ) + ) + ), + None, + docIsScala3 = false, + ), + "Foo", + ) + ) + + // Two nested scopes: each level keeps its explicit and wildcard imports together + // and round-trips in order (innermost first). + test("multiple-scopes")( + roundTrip( + DocScope( + Some("a/O."), + None, + isJava = false, + ImportScope( + List( + ImportLevel( + Map("A" -> List("p.A")), + List(("inner", Set.empty[String], false)), + ), + ImportLevel( + Map("B" -> List("q.B")), + List(("outer", Set("Z"), true)), + ), + ) + ), + Some("file:///pkg/A.scala"), + docIsScala3 = true, + ), + "A.member", + ) + ) + + // Names, targets, prefixes, hidden names and owner symbols may contain spaces, + // dots, operators, backticks and the structural delimiters (including the `~` + // level separator); URL-encoding must keep them whole. + test("special-characters")( + roundTrip( + DocScope( + Some("`a.b`/O."), + Some("x=y&z|w;v,u~t"), + isJava = false, + ImportScope( + List( + ImportLevel( + Map( + "`my type`" -> List("`pkg name`.`my type`"), + "===" -> List("cats.syntax.eq.==="), + ), + List(("a b", Set("X.Y", "Z|W~V"), true)), + ) + ) + ), + Some("file:///a b/x=y;z,w|v~u.scala"), + docIsScala3 = false, + ), + "`my type`", + ) + ) +} diff --git a/tests/unit/src/test/scala/tests/ScaladocSymbolsSuite.scala b/tests/unit/src/test/scala/tests/ScaladocSymbolsSuite.scala index 5d3ca7c21d78..9e3c7e2f597e 100644 --- a/tests/unit/src/test/scala/tests/ScaladocSymbolsSuite.scala +++ b/tests/unit/src/test/scala/tests/ScaladocSymbolsSuite.scala @@ -10,25 +10,33 @@ class ScaladocSymbolsSuite extends BaseSuite { check( "class-scaladoc-link", "b.O.B!", - List("a/b/O.B#", "b/O.B#"), + List("a/b/O.B#", "b/O.B#", "a/b/O#B#", "b/O#B#"), ) check( "local-scaladoc-link", "O.B!", - List("a/A.O.B#", "a/O.B#"), + List("a/A.O.B#", "a/O.B#", "a/A.O#B#", "a/O#B#"), ) check( "object-scaladoc-link", "c.b.O.B$", - List("a/c/b/O.B.", "a/c/b/O.B(+n).", "c/b/O.B.", "c/b/O.B(+n)."), + List( + "a/c/b/O.B.", "a/c/b/O.B(+n).", "c/b/O.B.", "c/b/O.B(+n).", "a/c/b/O#B.", + "a/c/b/O#B(+n).", "c/b/O#B.", "c/b/O#B(+n).", + ), ) check( "method-scaladoc-link", "c.b.O.foo(a : Int): String", - List("a/c/b/O.foo(+n).", "c/b/O.foo(+n)."), + List( + "a/c/b/O.foo(+n).", + "c/b/O.foo(+n).", + "a/c/b/O#foo(+n).", + "c/b/O#foo(+n).", + ), ) check( @@ -41,8 +49,11 @@ class ScaladocSymbolsSuite extends BaseSuite { "escape-scaladoc-link", "`this.b`.`B.B`", List( - "a/`this.b`/`B.B`#", "a/`this.b`/`B.B`.", "a/`this.b`/`B.B`(+n).", - "`this.b`/`B.B`#", "`this.b`/`B.B`.", "`this.b`/`B.B`(+n).", + "a/`this.b`/`B.B`#", "a/`this.b`/package.`B.B`#", "a/`this.b`/`B.B`.", + "a/`this.b`/package.`B.B`.", "a/`this.b`/`B.B`(+n).", + "a/`this.b`/package.`B.B`(+n).", "`this.b`/`B.B`#", + "`this.b`/package.`B.B`#", "`this.b`/`B.B`.", "`this.b`/package.`B.B`.", + "`this.b`/`B.B`(+n).", "`this.b`/package.`B.B`(+n).", ), ) @@ -51,10 +62,299 @@ class ScaladocSymbolsSuite extends BaseSuite { "this\\.B", List( "a/A.`this.B`#", "a/A.`this.B`.", "a/A.`this.B`(+n).", "a/`this.B`#", - "a/`this.B`.", "a/`this.B`(+n).", + "a/package.`this.B`#", "a/`this.B`.", "a/package.`this.B`.", + "a/`this.B`(+n).", "a/package.`this.B`(+n).", + ), + ) + + // `Type#member` keeps the `#` as a type/member separator instead of being + // backtick-wrapped as one identifier (scalameta/metals#3383). + check( + "qualified-member-scaladoc-link", + "b.O#foo", + List( + "a/b/O#foo#", "a/b/O#foo.", "a/b/O#foo(+n).", "b/O#foo#", "b/O#foo.", + "b/O#foo(+n).", + ), + ) + + check( + "qualified-method-scaladoc-link", + "b.O#foo(i: Int)", + List("a/b/O#foo(+n).", "b/O#foo(+n)."), + ) + + // A Javadoc local-member link `#foo` (`{@link #foo}`) resolves against the + // enclosing class context (scalameta/metals#3383). + check( + "local-member-scaladoc-link", + "#foo", + List("a/A.foo#", "a/A.foo.", "a/A.foo(+n)."), + ) + + // A trailing operator like `##` is part of the member name, not a `#` + // separator, so it must not be split (scalameta/metals#3383). + check( + "operator-member-scaladoc-link", + "scala.Any.##", + List( + "a/scala/Any.`##`#", "a/scala/Any.`##`.", "a/scala/Any.`##`(+n).", + "scala/Any.`##`#", "scala/Any.`##`.", "scala/Any.`##`(+n).", + "a/scala/Any#`##`#", "a/scala/Any#`##`.", "a/scala/Any#`##`(+n).", + "scala/Any#`##`#", "scala/Any#`##`.", "scala/Any#`##`(+n).", + ), + ) + + // A Javadoc constructor link `Foo#Foo(...)` names the constructor after the + // class, but SemanticDB stores it as `` (scalameta/metals#3383). + check( + "constructor-scaladoc-link", + "b.Foo#Foo(i: Int)", + List("a/b/Foo#``(+n).", "b/Foo#``(+n)."), + ) + + // The type of a qualified member link may be a nested type (`Outer#Inner`, + // e.g. a nested Java class), so both descriptor forms are tried for the inner + // boundary (scalameta/metals#3383). + check( + "nested-type-member-scaladoc-link", + "Outer.Inner#method()", + List( + "a/A.Outer.Inner#method(+n).", + "a/Outer.Inner#method(+n).", + "a/A.Outer#Inner#method(+n).", + "a/Outer#Inner#method(+n).", + ), + ) + + // A Java module prefix (`module/...`) is not part of the SemanticDB symbol, so + // it is dropped (scalameta/metals#3383). + check( + "module-qualified-scaladoc-link", + "java.base/java.lang.String#chars()", + List("a/java/lang/String#chars(+n).", "java/lang/String#chars(+n)."), + ) + + // A Javadoc documentation fragment (`Type##fragment`) links to the type, not a + // member, so the fragment is dropped (scalameta/metals#3383). + check( + "doc-fragment-scaladoc-link", + "Foo##fragment", + List( + "a/A.Foo#", "a/A.Foo.", "a/A.Foo(+n).", "a/Foo#", "a/package.Foo#", + "a/Foo.", "a/package.Foo.", "a/Foo(+n).", "a/package.Foo(+n).", + ), + ) + + // A `#` inside a backtick-escaped Scala identifier is part of the name, not a + // type/member separator, so it must not be split (scalameta/metals#3383). + check( + "backtick-hash-scaladoc-link", + "`Foo#Bar`", + List( + "a/A.`Foo#Bar`#", "a/A.`Foo#Bar`.", "a/A.`Foo#Bar`(+n).", "a/`Foo#Bar`#", + "a/package.`Foo#Bar`#", "a/`Foo#Bar`.", "a/package.`Foo#Bar`.", + "a/`Foo#Bar`(+n).", "a/package.`Foo#Bar`(+n).", + ), + ) + + // A backtick-escaped operator identifier may itself contain `(` or `[` (the + // official Scaladoc abusive example `` `([.abusive.])` ``); those are part of + // the name, not a method-signature delimiter, so the link is not truncated + // into a method reference (scalameta/metals#3383). + check( + "backtick-abusive-operator-scaladoc-link", + "`([.abusive.])`", + List( + "a/A.`([.abusive.])`#", "a/A.`([.abusive.])`.", + "a/A.`([.abusive.])`(+n).", "a/`([.abusive.])`#", + "a/package.`([.abusive.])`#", "a/`([.abusive.])`.", + "a/package.`([.abusive.])`.", "a/`([.abusive.])`(+n).", + "a/package.`([.abusive.])`(+n).", + ), + ) + + // A leading-`#` link whose member is the enclosing class name is a same-class + // constructor reference, so it also tries the `` form + // (scalameta/metals#3383). + check( + "same-class-constructor-scaladoc-link", + "#Foo(i: Int)", + List("a/Foo#``(+n).", "a/Foo#Foo(+n)."), + contextSymbols = ContextSymbols("a/", "Foo#", None), + ) + + // A bare `Foo(int)` (no `#`) is treated as a method reference, not a + // constructor. The constructor form isn't tried, so such a link to a + // constructor fails safely (resolving to nothing) rather than navigating + // incorrectly — see scalameta/metals#3383 (known limitation). + check( + "bare-constructor-scaladoc-link", + "Foo(i: Int)", + List("a/A.Foo(+n).", "a/Foo(+n).", "a/package.Foo(+n)."), + ) + + // A Java member named after a Scala keyword (`Thread#yield`) is stored + // unwrapped in Java SemanticDB, so the raw form is tried alongside the + // backtick-wrapped one (scalameta/metals#3383). + check( + "java-keyword-member-scaladoc-link", + "java.lang.Thread#yield()", + List( + "a/java/lang/Thread#`yield`(+n).", + "java/lang/Thread#`yield`(+n).", + "a/java/lang/Thread#yield(+n).", + "java/lang/Thread#yield(+n).", + ), + ) + + // A bare link to a nested type (`{@link Outer.Inner}`, no member) also tries + // the nested-type form `Outer#Inner` (scalameta/metals#3383). + check( + "nested-type-scaladoc-link", + "Outer.Inner", + List( + "a/A.Outer.Inner#", "a/A.Outer.Inner.", "a/A.Outer.Inner(+n).", + "a/Outer.Inner#", "a/Outer.Inner.", "a/Outer.Inner(+n).", + "a/A.Outer#Inner#", "a/A.Outer#Inner.", "a/A.Outer#Inner(+n).", + "a/Outer#Inner#", "a/Outer#Inner.", "a/Outer#Inner(+n).", + ), + ) + + // A Scala operator member like `/` must not be mistaken for a Java module + // separator, so the leading `BigDecimal.`/`BigDecimal#` is not stripped + // (scalameta/metals#3383). + check( + "operator-slash-member-scaladoc-link", + "BigDecimal./", + List( + "a/A.BigDecimal.`/`#", "a/A.BigDecimal.`/`.", "a/A.BigDecimal.`/`(+n).", + "a/BigDecimal.`/`#", "a/BigDecimal.`/`.", "a/BigDecimal.`/`(+n).", + "a/A.BigDecimal#`/`#", "a/A.BigDecimal#`/`.", "a/A.BigDecimal#`/`(+n).", + "a/BigDecimal#`/`#", "a/BigDecimal#`/`.", "a/BigDecimal#`/`(+n).", + ), + ) + + // A same-class constructor reference inside a nested class: the member equals + // the innermost class name (`Inner`), so the `` form is also tried + // (scalameta/metals#3383). + check( + "nested-same-class-constructor-scaladoc-link", + "#Inner(i: Int)", + List("a/Outer#Inner#``(+n).", "a/Outer#Inner#Inner(+n)."), + contextSymbols = ContextSymbols("a/", "Outer#Inner#", None), + ) + + // A Java package/type segment named after a Scala keyword (`type.Foo`) is + // stored unwrapped in Java SemanticDB, so the raw form is tried alongside the + // backtick-wrapped one (scalameta/metals#3383). + check( + "keyword-package-scaladoc-link", + "type.Foo!", + List( + "a/`type`/Foo#", "a/`type`/package.Foo#", "`type`/Foo#", + "`type`/package.Foo#", "a/type/Foo#", "a/type/package.Foo#", "type/Foo#", + "type/package.Foo#", + ), + ) + + // Every object-member (`.`) vs nested-type (`#`) assignment of the type-level + // boundaries is tried, so a mixed ownership chain such as `Outer.Middle#Inner` + // (an object holding a nested type) resolves, not only the all-dot and all-`#` + // extremes (scalameta/metals#3383). + check( + "mixed-ownership-chain-scaladoc-link", + "Outer.Middle.Inner!", + List( + "a/A.Outer.Middle.Inner#", "a/Outer.Middle.Inner#", + "a/A.Outer#Middle.Inner#", "a/Outer#Middle.Inner#", + "a/A.Outer.Middle#Inner#", "a/Outer.Middle#Inner#", + "a/A.Outer#Middle#Inner#", "a/Outer#Middle#Inner#", + ), + ) + + // A `/` that follows `_` is part of a Scala mixed operator identifier + // (`divide_/`), not a Java module separator, so it must not be stripped + // (scalameta/metals#3383). + check( + "mixed-operator-identifier-scaladoc-link", + "divide_/(i: Int)", + List( + "a/A.`divide_/`(+n).", + "a/`divide_/`(+n).", + "a/package.`divide_/`(+n).", + ), + ) + + // `Type###member` is a member separator `#` followed by the operator member + // `##`, not a documentation fragment, so it resolves to the member rather than + // the owner type. A `Type#member` link's type part is a type, not a package + // object, so no `scala/package.Any` form is generated (scalameta/metals#3383). + check( + "operator-after-separator-scaladoc-link", + "scala.Any###()", + List( + "a/scala/Any#`##`(+n).", + "scala/Any#`##`(+n).", + ), + ) + + // `List`, `Seq`, `Nil` etc. live in the `scala` package object, so a `scala/X` + // link also tries the `scala/package.X` symbol that `guessFromPath` can't + // produce from dots (scalameta/metals#3383). + check( + "scala-package-object-scaladoc-link", + "scala.List!", + List( + "a/scala/List#", + "a/scala/package.List#", + "scala/List#", + "scala/package.List#", ), ) + // A backslash-escaped `\!` is a literal member named `!`, not a force-type + // suffix, so the character is kept and resolved as a member + // (scalameta/metals#3383). + check( + "escaped-bang-member-scaladoc-link", + "A.\\!", + List( + "a/A.A.`!`#", "a/A.A.`!`.", "a/A.A.`!`(+n).", "a/A.`!`#", "a/A.`!`.", + "a/A.`!`(+n).", "a/A.A#`!`#", "a/A.A#`!`.", "a/A.A#`!`(+n).", "a/A#`!`#", + "a/A#`!`.", "a/A#`!`(+n).", + ), + ) + + // A Java module name ending in `_` (a legal Java identifier char) is still a + // module prefix and is stripped, since the `/` is followed by a package path + // (scalameta/metals#3383). + check( + "module-underscore-scaladoc-link", + "foo_/java.lang.String#chars()", + List("a/java/lang/String#chars(+n).", "java/lang/String#chars(+n)."), + ) + + // A `[...]` is a type-argument list, not a value-parameter signature, so it is + // stripped and the link resolves against its BASE type (`Map[K, V]` → `Map`, + // `List[Int]#head` → `List#head`) — never a `Map` method + // (scalameta/metals#3383). + test("type-application-scaladoc-link") { + val ctx = ContextSymbols("a/", "A.", None) + def symbols(link: String): List[String] = + ScalaDocLink(link, isScala3 = true) + .toScalaMetaSymbols(ctx) + .map(_.showSymbol) + assertEquals(symbols("Map[K, V]"), symbols("Map")) + assert( + symbols("Map[K, V]").exists(_.endsWith("Map#")), + symbols("Map[K, V]").toString, + ) + assertEquals(symbols("List[Int]#head"), symbols("List#head")) + // A generic method with a value signature is still a method of the base name. + assertEquals(symbols("foo[T](a: Int)"), symbols("foo(a: Int)")) + } + def check( name: TestOptions, symbol: String, diff --git a/tests/unit/src/test/scala/tests/WikiLinkSuite.scala b/tests/unit/src/test/scala/tests/WikiLinkSuite.scala new file mode 100644 index 000000000000..2b0ad80fa7c4 --- /dev/null +++ b/tests/unit/src/test/scala/tests/WikiLinkSuite.scala @@ -0,0 +1,109 @@ +package tests + +import scala.meta.internal.docstrings.WikiLink + +class WikiLinkSuite extends BaseSuite { + + private def checkSplit( + name: String, + content: String, + target: String, + title: Option[String], + ): Unit = + test(name)( + assertEquals(WikiLink.splitTargetTitle(content), (target, title)) + ) + + private def checkOffset( + name: String, + text: String, + offset: Int, + expected: Option[String], + ): Unit = + test(name)(assertEquals(WikiLink.atOffset(text, offset), expected)) + + private def checkSee( + name: String, + text: String, + offset: Int, + expected: Option[String], + ): Unit = + test(name)(assertEquals(WikiLink.seeTagAtOffset(text, offset), expected)) + + checkSplit("split-plain", "scala.Foo", "scala.Foo", None) + checkSplit("split-title", "scala.Foo the foo", "scala.Foo", Some("the foo")) + // A backticked target keeps an embedded space instead of being split as a title. + checkSplit("split-backtick-space", "`my type`", "`my type`", None) + checkSplit( + "split-backtick-space-title", + "`my type` the type", + "`my type`", + Some("the type"), + ) + // Whitespace inside a parenthesised signature is part of the target. + checkSplit("split-paren-space", "foo(a: Int) bar", "foo(a: Int)", Some("bar")) + checkSplit("split-leading-ws", " scala.Foo ", "scala.Foo", None) + // Whitespace inside a type-argument list `[...]` is part of the target too, not a + // title boundary (scalameta/metals#3383). + checkSplit( + "split-type-args", + "foo[A, B](a: A) label", + "foo[A, B](a: A)", + Some("label"), + ) + checkSplit("split-type-args-only", "Map[K, V]", "Map[K, V]", None) + + checkOffset("offset-double", "see [[scala.Foo]] now", 12, Some("scala.Foo")) + // The triple-bracket form the old `[[ ]]` regex truncated to `[scala.Foo`. + checkOffset("offset-triple", "see [[[scala.Foo]]] now", 12, Some("scala.Foo")) + checkOffset("offset-backtick-space", "[[`my type`]]", 5, Some("`my type`")) + checkOffset( + "offset-with-title", + "[[scala.Foo the foo]]", + 5, + Some("scala.Foo"), + ) + checkOffset("offset-outside", "[[scala.Foo]] tail", 15, None) + checkOffset("offset-none", "no link here", 3, None) + // The char right after `]]` belongs to no link (and to the next one if + // adjacent), not to this link. + checkOffset("offset-after-close", "[[scala.Foo]] tail", 13, None) + checkOffset("offset-adjacent-first", "[[a.A]][[b.B]]", 2, Some("a.A")) + checkOffset("offset-adjacent-second-open", "[[a.A]][[b.B]]", 7, Some("b.B")) + checkOffset("offset-closing-bracket", "[[a.A]]", 6, Some("a.A")) + + // A `@see` BLOCK tag's reference is clickable from source, like the link hover + // renders it — but only the first token (the target), only when `@see` starts a + // comment line, and not for a quoted string (scalameta/metals#3383). + checkSee( + "see-block-tag", + " * @see java.util.List desc", + 10, + Some("java.util.List"), + ) + checkSee("see-block-member", " * @see #bar label", 9, Some("#bar")) + // A `@see` in prose (not at a line start) is not a block tag. + checkSee("see-mid-line-prose", " * text @see java.util.List", 16, None) + // A quoted `@see "..."` is plain text per the Javadoc spec, not a symbol. + checkSee("see-quoted-text", " * @see \"just text\"", 10, None) + // The description after the target is not part of the clickable link. + checkSee("see-offset-in-title", " * @see java.util.List desc", 24, None) + // A bare `@see` whose reference sits on a following continuation line still + // resolves — the parser appends it to the tag body (scalameta/metals#3383). + checkSee( + "see-continuation-line", + " * @see\n * java.util.ArrayList\n */", + 16, + Some("java.util.ArrayList"), + ) + // A bare `@see` with nothing but the comment close after it is not a link. + checkSee("see-empty-then-close", " * @see\n */", 9, None) + // An empty bare `@see` must NOT swallow the following `@see`: the second tag's + // reference is still clickable (scalameta/metals#3383). + checkSee( + "see-empty-then-see", + " * @see\n * @see java.util.List\n */", + 18, + Some("java.util.List"), + ) +}