diff --git a/compiler/src/dotty/tools/dotc/reporting/StoreReporter.scala b/compiler/src/dotty/tools/dotc/reporting/StoreReporter.scala index 8b6cd6ea5a0d..d3c334599666 100644 --- a/compiler/src/dotty/tools/dotc/reporting/StoreReporter.scala +++ b/compiler/src/dotty/tools/dotc/reporting/StoreReporter.scala @@ -37,7 +37,7 @@ class StoreReporter(outer: Reporter = Reporter.NoReporter) extends Reporter { if (infos != null) try infos.toList finally infos = null else Nil - override def pendingMessages(using Context): List[Diagnostic] = infos.toList + override def pendingMessages(using Context): List[Diagnostic] = if (infos != null) infos.toList else Nil override def errorsReported: Boolean = hasErrors || (outer != null && outer.errorsReported) } diff --git a/dist/bin/scaladoc b/dist/bin/scaladoc index e92e21d6e49a..f09bf27812c0 100755 --- a/dist/bin/scaladoc +++ b/dist/bin/scaladoc @@ -127,6 +127,7 @@ eval "\"$JAVACMD\"" \ ${JAVA_OPTS:-$default_java_opts} \ "${java_args[@]}" \ "${jvm_cp_args-}" \ + -Dscala.usejavacp=true \ "dotty.tools.scaladoc.Main" \ "${scala_args[@]}" \ "${residual_args[@]}" \ diff --git a/docs/css/dottydoc.css b/docs/css/dottydoc.css index caeb967d6fd1..d2520888120a 100644 --- a/docs/css/dottydoc.css +++ b/docs/css/dottydoc.css @@ -180,18 +180,6 @@ h5:hover a.anchor:hover { font-family: var(--font-family-monospace); } -/* code */ -pre, code { - font-variant-ligatures: none; -} -pre { - padding: 0; - font-size: 13px; - background: var(--pre-bg); - border-radius: 2px; - border: 1px solid rgba(0, 0, 0, 0.1); -} - /* admonitions */ blockquote { padding: 0 1em; diff --git a/project/Build.scala b/project/Build.scala index c0f25d09694c..52ff8f8979e5 100644 --- a/project/Build.scala +++ b/project/Build.scala @@ -1243,16 +1243,19 @@ object Build { libraryDependencies += ("org.scala-js" %%% "scalajs-dom" % "1.1.0").cross(CrossVersion.for3Use2_13) ) - def generateDocumentation(targets: Seq[String], name: String, outDir: String, ref: String, params: Seq[String] = Nil) = + def generateDocumentation(targets: Seq[String], name: String, outDir: String, ref: String, params: Seq[String] = Nil, usingScript: Boolean = true) = Def.taskDyn { val distLocation = (dist / pack).value val projectVersion = version.value IO.createDirectory(file(outDir)) val stdLibVersion = stdlibVersion(Bootstrapped) + val scalaLib = findArtifactPath(externalCompilerClasspathTask.value, "scala-library") + val dottyLib = (`scala3-library` / Compile / classDirectory).value // TODO add versions etc. def srcManaged(v: String, s: String) = s"out/bootstrap/stdlib-bootstrapped/scala-$v/src_managed/main/$s-library-src" def scalaSrcLink(v: String, s: String) = s"-source-links:$s=github://scala/scala/v$v#src/library" def dottySrcLink(v: String, s: String) = s"-source-links:$s=github://lampepfl/dotty/$v#library/src" + val revision = Seq("-revision", ref, "-project-version", projectVersion) val cmd = Seq( "-d", @@ -1264,7 +1267,20 @@ object Build { s"-source-links:github://lampepfl/dotty/$referenceVersion", ) ++ scalacOptionsDocSettings ++ revision ++ params ++ targets import _root_.scala.sys.process._ - Def.task((s"$distLocation/bin/scaladoc" +: cmd).!) + if (usingScript) + Def.task((s"$distLocation/bin/scaladoc" +: cmd).!) + else { + val escapedCmd = cmd.map(arg => if(arg.contains(" ")) s""""$arg"""" else arg) + Def.task { + try { + (Compile / run).toTask(escapedCmd.mkString(" ", " ", "")).value + 0 + } catch { + case _ : Throwable => 1 + } + } + } + } val SourceLinksIntegrationTest = config("sourceLinksIntegrationTest") extend Test @@ -1311,7 +1327,7 @@ object Build { generateSelfDocumentation := Def.taskDyn { generateDocumentation( (Compile / classDirectory).value.getAbsolutePath :: Nil, - "scaladoc", "scaladoc/output/self", VersionUtil.gitHash, + "scaladoc", "scaladoc/output/self", VersionUtil.gitHash ) }.value, generateScalaDocumentation := Def.inputTaskDyn { @@ -1352,7 +1368,7 @@ object Build { s"-source-links:docs=github://lampepfl/dotty/master#docs", "-doc-root-content", docRootFile.toString, "-Ydocument-synthetic-types" - ) + ), usingScript = false )) }.evaluated, @@ -1361,7 +1377,8 @@ object Build { (Test / Build.testcasesOutputDir).value, "scaladoc testcases", "scaladoc/output/testcases", - "master") + "master" + ) }.value, Test / buildInfoKeys := Seq[BuildInfoKey]( diff --git a/scaladoc-js/resources/scaladoc-searchbar.css b/scaladoc-js/resources/scaladoc-searchbar.css index 035adbee6555..45f3b42080c8 100644 --- a/scaladoc-js/resources/scaladoc-searchbar.css +++ b/scaladoc-js/resources/scaladoc-searchbar.css @@ -100,5 +100,38 @@ .pull-right { float: right; - margin-left: auto + margin-left: auto; } + +.snippet-comment-button { + position: absolute; + display: inline-block; + left: 50%; + width: 24px; + height: 24px; + background: + linear-gradient(#fff, #fff), + linear-gradient(#fff, #fff), + #aaa; + background-position: center; + background-size: 50% 2px, 2px 50%; + background-repeat: no-repeat; + border-radius: 12px; + box-shadow: 0 0 2px black; +} + +.snippet-comment-button:hover { + background: + linear-gradient(#444, #444), + linear-gradient(#444, #444), + #ddd; + background-position: center; + background-size: 50% 2px, 2px 50%; + background-repeat: no-repeat; +} + +.hide-snippet-comments-button { + -ms-transform: rotate(45deg); + transform: rotate(45deg); +} + diff --git a/scaladoc-js/src/Main.scala b/scaladoc-js/src/Main.scala index 7b32aed9a1ea..c31ad58568fa 100644 --- a/scaladoc-js/src/Main.scala +++ b/scaladoc-js/src/Main.scala @@ -3,4 +3,5 @@ package dotty.tools.scaladoc object Main extends App { Searchbar() SocialLinks() + CodeSnippets() } diff --git a/scaladoc-js/src/searchbar/code-snippets/CodeSnippets.scala b/scaladoc-js/src/searchbar/code-snippets/CodeSnippets.scala new file mode 100644 index 000000000000..50f0ad2d5df7 --- /dev/null +++ b/scaladoc-js/src/searchbar/code-snippets/CodeSnippets.scala @@ -0,0 +1,27 @@ +package dotty.tools.scaladoc + +import org.scalajs.dom._ +import org.scalajs.dom.ext._ + +class CodeSnippets: + def toggleHide(e: html.Element | html.Document) = e.querySelectorAll("code span.hideable").foreach { + case e: html.Element if e.style.getPropertyValue("display").isEmpty => e.style.setProperty("display", "none") + case e: html.Element => e.style.removeProperty("display") + } + + toggleHide(document) + + document.querySelectorAll("pre").foreach { + case e: html.Element if e.querySelectorAll("code span.hideable").nonEmpty => + val a = document.createElement("a") + a.addEventListener("click", { (_: MouseEvent) => + if(a.classList.contains("hide-snippet-comments-button")) { + a.classList.remove("hide-snippet-comments-button") + } else { + a.classList.add("hide-snippet-comments-button") + } + toggleHide(e) + }) + a.classList.add("snippet-comment-button") + e.insertBefore(a, e.firstChild) + } diff --git a/scaladoc-testcases/docs/docs/index.md b/scaladoc-testcases/docs/docs/index.md new file mode 100644 index 000000000000..69036cd254a4 --- /dev/null +++ b/scaladoc-testcases/docs/docs/index.md @@ -0,0 +1,13 @@ +--- + + +--- + +```scala sc:compile +2 + List(0) +``` + +```scala sc:compile +new snippetCompiler.Snippet0 { } +``` + diff --git a/scaladoc-testcases/src/tests/snippetComments.scala b/scaladoc-testcases/src/tests/snippetComments.scala new file mode 100644 index 000000000000..39b15648103e --- /dev/null +++ b/scaladoc-testcases/src/tests/snippetComments.scala @@ -0,0 +1,31 @@ +package tests.snippetComments + + +/** + * This is my codeblock + * + * ``` + * //{{ + * import xd + * import xd2 + * //}} + * + * + * val x = 1 // This is my valid comment + * + * /* + * multi line comment + * */ + * val reallyFLongDeclaration = "1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890" + * val y = 2 // comment in the same line + * // comment in new line + * val z = 3 + * + * //{{ + * val hideMe = 7 + * //}} + * ``` + * + * The end of my codeblock + */ +class A diff --git a/scaladoc-testcases/src/tests/snippetCompilerTests.scala b/scaladoc-testcases/src/tests/snippetCompilerTests.scala new file mode 100644 index 000000000000..85d4db4ee0a2 --- /dev/null +++ b/scaladoc-testcases/src/tests/snippetCompilerTests.scala @@ -0,0 +1,68 @@ +package tests +package snippetCompilerTests + +/** + * ```scala sc:compile + * //{ + * import scala.collection.Seq + * //} + * + * def a = 2 + * val x = 1 + List() + * a + * + * try { + * 2+3 + * } + * + * /* + * Huge comment + * */ + * val xd: String = 42 + * + * def a(i: Int): Boolean = i match // This is a function + * case 1 => true + * + * val b: Int = 2.3 /* Also quite a big comment */ + * + * val d: Long = "asd" + * ``` + * + * ```scala sc:fail + * def a = 2 + * val x = 1 + List() + * a + * ``` + * + * ```scala sc:fail + * def a = 2 + * ``` + * + * ```scala sc:nocompile + * def a = 3 + * a() + * ``` + */ +class A { + trait B +} + +/** + * ```scala sc:compile + * val c: Int = 4.5 + * ``` + */ +class B { } + +trait Quotes { + val reflect: reflectModule = ??? + trait reflectModule { self: reflect.type => + /** + * ```scala sc:compile + * 2 + List() + * ``` + * + */ + def a = 3 + } +} \ No newline at end of file diff --git a/scaladoc-testcases/src/tests/snippetTestcase1.scala b/scaladoc-testcases/src/tests/snippetTestcase1.scala new file mode 100644 index 000000000000..b0907f983946 --- /dev/null +++ b/scaladoc-testcases/src/tests/snippetTestcase1.scala @@ -0,0 +1,12 @@ +package tests.snippetTestcase1 + +class SnippetTestcase1: + /** + * SNIPPET(OUTERLINEOFFSET:8,OUTERCOLUMNOFFSET:6,INNERLINEOFFSET:3,INNERCOLUMNOFFSET:2) + * ERROR(LINE:8,COLUMN:8) + * ```scala sc:compile + * 2 + List() + * ``` + * + */ + def a = 3 \ No newline at end of file diff --git a/scaladoc-testcases/src/tests/snippetTestcase2.scala b/scaladoc-testcases/src/tests/snippetTestcase2.scala new file mode 100644 index 000000000000..8e3fd0f33744 --- /dev/null +++ b/scaladoc-testcases/src/tests/snippetTestcase2.scala @@ -0,0 +1,58 @@ +package tests +package snippetTestcase2 + +trait Quotes2[A] { + val r1: r1Module[_] = ??? + trait r1Module[A] { + type X + object Y { + /** + * SNIPPET(OUTERLINEOFFSET:13,OUTERCOLUMNOFFSET:10,INNERLINEOFFSET:6,INNERCOLUMNOFFSET:6) + * ERROR(LINE:13,COLUMN:12) + * ```scala sc:compile + * 2 + List() + * ``` + * + */ + type YY + } + val z: zModule = ??? + trait zModule { + /** + * SNIPPET(OUTERLINEOFFSET:25,OUTERCOLUMNOFFSET:10,INNERLINEOFFSET:7,INNERCOLUMNOFFSET:6) + * ERROR(LINE:25,COLUMN:12) + * ```scala sc:compile + * 2 + List() + * ``` + * + */ + type ZZ + } + } + object r2 { + type X + object Y { + /** + * SNIPPET(OUTERLINEOFFSET:39,OUTERCOLUMNOFFSET:10,INNERLINEOFFSET:5,INNERCOLUMNOFFSET:6) + * ERROR(LINE:39,COLUMN:12) + * ```scala sc:compile + * 2 + List() + * ``` + * + */ + type YY + } + val z: zModule = ??? + trait zModule { + /** + * SNIPPET(OUTERLINEOFFSET:51,OUTERCOLUMNOFFSET:10,INNERLINEOFFSET:6,INNERCOLUMNOFFSET:6) + * ERROR(LINE:51,COLUMN:12) + * ```scala sc:compile + * 2 + List() + * ``` + * + */ + type ZZ + } + } +} \ No newline at end of file diff --git a/scaladoc/resources/dotty_res/styles/scalastyle.css b/scaladoc/resources/dotty_res/styles/scalastyle.css index dda4e5f08fd1..c32f351bb82d 100644 --- a/scaladoc/resources/dotty_res/styles/scalastyle.css +++ b/scaladoc/resources/dotty_res/styles/scalastyle.css @@ -5,7 +5,9 @@ --border-light: #DADFE6; --border-medium: #abc; + --body-bg: #f0f3f6; --code-bg: #F4F5FA; + --documentable-bg: #FFFFFF; --symbol-fg: #333; --link-fg: #00607D; --link-hover-fg: #00A0D0; @@ -45,6 +47,7 @@ body { padding: 0; font-family: "Lato", sans-serif; font-size: 16px; + background-color: var(--body-bg); } /* Page layout */ @@ -75,7 +78,12 @@ h1, h2, h3 { font-family: var(--title-font); color: var(--title-fg); } -pre, code, .monospace, .hljs { +.monospace { + font-family: var(--mono-font); + background: var(--documentable-bg); + font-variant-ligatures: none; +} +pre, code, .hljs { font-family: var(--mono-font); background: var(--code-bg); font-variant-ligatures: none; @@ -84,12 +92,49 @@ code { font-size: .8em; padding: 0 .3em; } +pre { + overflow: visible; + scrollbar-width: thin; + margin: 0px; +} pre code, pre code.hljs { font-size: 1em; padding: 0; } +.snippet { + padding: 12px 8px 10px 12px; + background: var(--code-bg); + margin: 1em 0px; + border-radius: 2px; + box-shadow: 0 0 2px #888; +} +.snippet-error { + border-bottom: 2px dotted red; +} +.snippet-warn { + border-bottom: 2px dotted orange; +} +.snippet-info { + border-bottom: 2px dotted teal; +} +.snippet-debug { + border-bottom: 2px dotted pink; +} +.tooltip { + position: relative; +} +.tooltip:hover:after { + content: attr(label); + padding: 4px 8px; + color: white; + background-color:black; + position: absolute; + left: 0; + z-index:10; + box-shadow:0 0 3px #444; + opacity: 0.8; +} pre, .symbol.monospace { - padding: 10px 8px 10px 12px; font-weight: 500; font-size: 12px; } @@ -97,6 +142,9 @@ pre .hljs-comment { /* Fold comments in snippets */ white-space: normal; } +.symbol.monospace { + padding: 12px 8px 10px 12px; +} a, a:visited, span[data-unresolved-link] { text-decoration: none; color: var(--link-fg); @@ -472,12 +520,18 @@ footer .pull-right { padding-right: 0.5em; min-width: 10em; max-width: 10em; + width: 10em; overflow: hidden; direction: rtl; white-space: nowrap; text-indent: 0em; } +.documentableElement .docs { + width: 100%; + table-layout: fixed; +} + .documentableElement .modifiers .other-modifiers { color: gray; } @@ -518,8 +572,8 @@ footer .pull-right { padding: 5px 4px 5px 4px; font-weight: 500; font-size: 12px; - background: var(--code-bg); - border: 0.25em solid white; + background: var(--documentable-bg); + border: 0.25em solid var(--body-bg); } .documentableElement>div { @@ -528,12 +582,11 @@ footer .pull-right { .expand.documentableElement>div { display: block; - padding-left: 3em; } .expand.documentableElement>div.header { display: block; - padding-left: 7.5em; + padding-left: 4.5em; text-indent: -4.5em; } diff --git a/scaladoc/src/dotty/tools/scaladoc/DocContext.scala b/scaladoc/src/dotty/tools/scaladoc/DocContext.scala index 9f44069fece1..ee1444effc4a 100644 --- a/scaladoc/src/dotty/tools/scaladoc/DocContext.scala +++ b/scaladoc/src/dotty/tools/scaladoc/DocContext.scala @@ -73,10 +73,18 @@ case class NavigationNode(name: String, dri: DRI, nested: Seq[NavigationNode]) case class DocContext(args: Scaladoc.Args, compilerContext: CompilerContext): lazy val sourceLinks = SourceLinks.load(args.sourceLinks, args.revision)(using compilerContext) + + lazy val snippetCompilerArgs = snippets.SnippetCompilerArgs.load(args.snippetCompiler, args.snippetCompilerDebug)(using compilerContext) + + lazy val snippetChecker = snippets.SnippetChecker(args)(using compilerContext) + lazy val staticSiteContext = args.docsRoot.map(path => StaticSiteContext( File(path).getAbsoluteFile(), args, - sourceLinks + sourceLinks, + snippetCompilerArgs, + snippetChecker )(using compilerContext)) + val externalDocumentationLinks = args.externalMappings diff --git a/scaladoc/src/dotty/tools/scaladoc/Scaladoc.scala b/scaladoc/src/dotty/tools/scaladoc/Scaladoc.scala index 3b9c8e9b9624..44dcb15b65b2 100644 --- a/scaladoc/src/dotty/tools/scaladoc/Scaladoc.scala +++ b/scaladoc/src/dotty/tools/scaladoc/Scaladoc.scala @@ -31,6 +31,7 @@ object Scaladoc: tastyDirs: Seq[File] = Nil, tastyFiles: Seq[File] = Nil, classpath: String = "", + bootclasspath: String = "", output: File, docsRoot: Option[String] = None, projectVersion: Option[String] = None, @@ -49,6 +50,8 @@ object Scaladoc: includePrivateAPI: Boolean = false, docCanonicalBaseUrl: String = "", documentSyntheticTypes: Boolean = false, + snippetCompiler: List[String] = Nil, + snippetCompilerDebug: Boolean = false ) def run(args: Array[String], rootContext: CompilerContext): Reporter = @@ -65,7 +68,7 @@ object Scaladoc: val tastyFiles = parsedArgs.tastyFiles ++ parsedArgs.tastyDirs.flatMap(listTastyFiles) if !ctx.reporter.hasErrors then - val updatedArgs = parsedArgs.copy(tastyDirs = Nil, tastyFiles = tastyFiles) + val updatedArgs = parsedArgs.copy(tastyDirs = parsedArgs.tastyDirs, tastyFiles = tastyFiles) if (parsedArgs.output.exists()) util.IO.delete(parsedArgs.output) @@ -172,6 +175,7 @@ object Scaladoc: dirs, validFiles, classpath.get, + bootclasspath.get, destFile, siteRoot.nonDefault, projectVersion.nonDefault, @@ -189,7 +193,9 @@ object Scaladoc: groups.get, visibilityPrivate.get, docCanonicalBaseUrl.get, - YdocumentSyntheticTypes.get + YdocumentSyntheticTypes.get, + snippetCompiler.get, + snippetCompilerDebug.get ) (Some(docArgs), newContext) } diff --git a/scaladoc/src/dotty/tools/scaladoc/ScaladocSettings.scala b/scaladoc/src/dotty/tools/scaladoc/ScaladocSettings.scala index 8320a5d6c2f7..ec326bf57178 100644 --- a/scaladoc/src/dotty/tools/scaladoc/ScaladocSettings.scala +++ b/scaladoc/src/dotty/tools/scaladoc/ScaladocSettings.scala @@ -19,13 +19,14 @@ import dotty.tools.dotc.core.Contexts._ class ScaladocSettings extends SettingGroup with AllScalaSettings: val unsupportedSettings = Seq( // Options that we like to support - bootclasspath, extdirs, javabootclasspath, encoding, usejavacp, + extdirs, javabootclasspath, encoding, // Needed for plugin architecture plugin,disable,require, pluginsDir, pluginOptions, // we need support for sourcepath and sourceroot sourcepath, sourceroot ) + val projectName: Setting[String] = StringSetting("-project", "project title", "The name of the project.", "", aliases = List("-doc-title")) @@ -94,3 +95,12 @@ class ScaladocSettings extends SettingGroup with AllScalaSettings: val YdocumentSyntheticTypes: Setting[Boolean] = BooleanSetting("-Ydocument-synthetic-types", "Documents intrinsic types e. g. Any, Nothing. Setting is useful only for stdlib", false) + + val snippetCompiler: Setting[List[String]] = + MultiStringSetting("-snippet-compiler", "snippet-compiler", snippets.SnippetCompilerArgs.usage) + + val snippetCompilerDebug: Setting[Boolean] = + BooleanSetting("-Ysnippet-compiler-debug", snippets.SnippetCompilerArgs.debugUsage, false) + + def scaladocSpecificSettings: Set[Setting[_]] = + Set(sourceLinks, syntax, revision, externalDocumentationMappings, socialLinks, skipById, skipByRegex, deprecatedSkipPackages, docRootContent, snippetCompiler, snippetCompilerDebug) diff --git a/scaladoc/src/dotty/tools/scaladoc/api.scala b/scaladoc/src/dotty/tools/scaladoc/api.scala index 17e1207324ea..91f0eb3cc5b4 100644 --- a/scaladoc/src/dotty/tools/scaladoc/api.scala +++ b/scaladoc/src/dotty/tools/scaladoc/api.scala @@ -232,4 +232,15 @@ extension (s: Signature) case l: Link => l.name }.mkString -case class TastyMemberSource(val path: java.nio.file.Path, val lineNumber: Int) +case class TastyMemberSource(path: java.nio.file.Path, lineNumber: Int) + +object SnippetCompilerData: + case class Position(line: Int, column: Int) + case class ClassInfo(tpe: Option[String], names: Seq[String], generics: Option[String]) + +case class SnippetCompilerData( + packageName: String, + classInfos: Seq[SnippetCompilerData.ClassInfo], + imports: List[String], + position: SnippetCompilerData.Position +) diff --git a/scaladoc/src/dotty/tools/scaladoc/site/LoadedTemplate.scala b/scaladoc/src/dotty/tools/scaladoc/site/LoadedTemplate.scala index beda1eb14676..590a016a25d3 100644 --- a/scaladoc/src/dotty/tools/scaladoc/site/LoadedTemplate.scala +++ b/scaladoc/src/dotty/tools/scaladoc/site/LoadedTemplate.scala @@ -51,4 +51,4 @@ case class LoadedTemplate( ("site" -> (getMap("site") + ("posts" -> posts))) + ("urls" -> sourceLinks.toMap) + ("page" -> (getMap("page") + ("title" -> templateFile.title))) - templateFile.resolveInner(RenderingContext(updatedSettings, ctx.layouts)) + templateFile.resolveInner(RenderingContext(updatedSettings, ctx.layouts))(using ctx) diff --git a/scaladoc/src/dotty/tools/scaladoc/site/StaticSiteContext.scala b/scaladoc/src/dotty/tools/scaladoc/site/StaticSiteContext.scala index 8a2d60036e6c..010ecfeec052 100644 --- a/scaladoc/src/dotty/tools/scaladoc/site/StaticSiteContext.scala +++ b/scaladoc/src/dotty/tools/scaladoc/site/StaticSiteContext.scala @@ -13,7 +13,9 @@ import collection.JavaConverters._ class StaticSiteContext( val root: File, val args: Scaladoc.Args, - val sourceLinks: SourceLinks)(using val outerCtx: CompilerContext): + val sourceLinks: SourceLinks, + val snippetCompilerArgs: snippets.SnippetCompilerArgs, + val snippetChecker: snippets.SnippetChecker)(using val outerCtx: CompilerContext): var memberLinkResolver: String => Option[DRI] = _ => None diff --git a/scaladoc/src/dotty/tools/scaladoc/site/common.scala b/scaladoc/src/dotty/tools/scaladoc/site/common.scala index 8cc576ad82d4..49c4621a90dc 100644 --- a/scaladoc/src/dotty/tools/scaladoc/site/common.scala +++ b/scaladoc/src/dotty/tools/scaladoc/site/common.scala @@ -36,7 +36,8 @@ val defaultMarkdownOptions: DataHolder = EmojiExtension.create(), YamlFrontMatterExtension.create(), StrikethroughExtension.create(), - WikiLinkExtension.create() + WikiLinkExtension.create(), + tasty.comments.markdown.SnippetRenderingExtension )) def emptyTemplate(file: File, title: String): TemplateFile = TemplateFile( @@ -48,7 +49,8 @@ def emptyTemplate(file: File, title: String): TemplateFile = TemplateFile( title = title, hasFrame = true, resources = List.empty, - layout = None + layout = None, + configOffset = 0 ) final val ConfigSeparator = "---" @@ -106,6 +108,7 @@ def loadTemplateFile(file: File): TemplateFile = { title = stringSetting(allSettings, "title").getOrElse(name), hasFrame = !stringSetting(allSettings, "hasFrame").contains("false"), resources = (listSetting(allSettings, "extraCSS") ++ listSetting(allSettings, "extraJS")).flatten.toList, - layout = stringSetting(allSettings, "layout") + layout = stringSetting(allSettings, "layout"), + configOffset = config.size ) } diff --git a/scaladoc/src/dotty/tools/scaladoc/site/templates.scala b/scaladoc/src/dotty/tools/scaladoc/site/templates.scala index 5ff9fd161746..d832659ab4bd 100644 --- a/scaladoc/src/dotty/tools/scaladoc/site/templates.scala +++ b/scaladoc/src/dotty/tools/scaladoc/site/templates.scala @@ -2,7 +2,7 @@ package dotty.tools.scaladoc package site import java.io.File -import java.nio.file.Files +import java.nio.file.{Files, Paths} import com.vladsch.flexmark.ext.anchorlink.AnchorLinkExtension import com.vladsch.flexmark.ext.autolink.AutolinkExtension @@ -18,6 +18,7 @@ import liqp.Template import scala.collection.JavaConverters._ import scala.io.Source +import dotty.tools.scaladoc.snippets._ case class RenderingContext( properties: Map[String, Object], @@ -52,10 +53,32 @@ case class TemplateFile( hasFrame: Boolean, resources: List[String], layout: Option[String], + configOffset: Int ): def isIndexPage() = file.isFile && (file.getName == "index.md" || file.getName == "index.html") - private[site] def resolveInner(ctx: RenderingContext): ResolvedPage = + private[site] def resolveInner(ctx: RenderingContext)(using ssctx: StaticSiteContext): ResolvedPage = + + lazy val snippetCheckingFunc: SnippetChecker.SnippetCheckingFunc = + val path = Some(Paths.get(file.getAbsolutePath)) + val pathBasedArg = ssctx.snippetCompilerArgs.get(path) + (str: String, lineOffset: SnippetChecker.LineOffset, argOverride: Option[SCFlags]) => { + val arg = argOverride.fold(pathBasedArg)(pathBasedArg.overrideFlag(_)) + val compilerData = SnippetCompilerData( + "staticsitesnippet", + Seq(SnippetCompilerData.ClassInfo(None, Nil, None)), + Nil, + SnippetCompilerData.Position(configOffset - 1, 0) + ) + ssctx.snippetChecker.checkSnippet(str, Some(compilerData), arg, lineOffset).collect { + case r: SnippetCompilationResult if !r.isSuccessful => + val msg = s"In static site (${file.getAbsolutePath}):\n${r.getSummary}" + report.error(msg)(using ssctx.outerCtx) + r + case r => r + } + } + if (ctx.resolving.contains(file.getAbsolutePath)) throw new RuntimeException(s"Cycle in templates involving $file: ${ctx.resolving}") @@ -74,9 +97,13 @@ case class TemplateFile( val rendered = Template.parse(this.rawCode).render(mutableProperties) // We want to render markdown only if next template is html val code = if (isHtml || layoutTemplate.exists(!_.isHtml)) rendered else + // Snippet compiler currently supports markdown only val parser: Parser = Parser.builder(defaultMarkdownOptions).build() - HtmlRenderer.builder(defaultMarkdownOptions).build().render(parser.parse(rendered)) + val parsedMd = parser.parse(rendered) + val processed = FlexmarkSnippetProcessor.processSnippets(parsedMd, ssctx.snippetCompilerArgs.debug, snippetCheckingFunc)(using ssctx.outerCtx) + HtmlRenderer.builder(defaultMarkdownOptions).build().render(processed) + layoutTemplate match case None => ResolvedPage(code, resources ++ ctx.resources) case Some(layoutTemplate) => - layoutTemplate.resolveInner(ctx.nest(code, file, resources)) + layoutTemplate.resolveInner(ctx.nest(code, file, resources)) \ No newline at end of file diff --git a/scaladoc/src/dotty/tools/scaladoc/snippets/FlexmarkSnippetProcessor.scala b/scaladoc/src/dotty/tools/scaladoc/snippets/FlexmarkSnippetProcessor.scala new file mode 100644 index 000000000000..eb67e2e1fc2a --- /dev/null +++ b/scaladoc/src/dotty/tools/scaladoc/snippets/FlexmarkSnippetProcessor.scala @@ -0,0 +1,57 @@ +package dotty.tools.scaladoc +package snippets + +import com.vladsch.flexmark.util.{ast => mdu, sequence} +import com.vladsch.flexmark.{ast => mda} +import com.vladsch.flexmark.formatter.Formatter +import com.vladsch.flexmark.util.options.MutableDataSet +import collection.JavaConverters._ + +import dotty.tools.scaladoc.tasty.comments.markdown.ExtendedFencedCodeBlock + +object FlexmarkSnippetProcessor: + def processSnippets(root: mdu.Node, debug: Boolean, checkingFunc: => SnippetChecker.SnippetCheckingFunc)(using CompilerContext): mdu.Node = { + lazy val cf: SnippetChecker.SnippetCheckingFunc = checkingFunc + + val nodes = root.getDescendants().asScala.collect { + case fcb: mda.FencedCodeBlock => fcb + }.toList + + nodes.foreach { node => + val snippet = node.getContentChars.toString + val lineOffset = node.getStartLineNumber + val info = node.getInfo.toString.split(" ") + if info.contains("scala") then { + val argOverride = + info + .find(_.startsWith("sc:")) + .map(_.stripPrefix("sc:")) + .map(SCFlagsParser.parse) + .flatMap(_ match { + case Right(flags) => Some(flags) + case Left(error) => + report.warning( + s"""|Error occured during parsing flags in snippet: + |$error""".stripMargin + ) + None + }) + val snippetCompilationResult = cf(snippet, lineOffset, argOverride) match { + case result@Some(SnippetCompilationResult(wrapped, _, _, _)) if debug => + val s = sequence.BasedSequence.EmptyBasedSequence() + .append(wrapped.snippet) + .append(sequence.BasedSequence.EOL) + val content = mdu.BlockContent() + content.add(s, 0) + node.setContent(content) + result + case result => result + } + + node.insertBefore(new ExtendedFencedCodeBlock(node, snippetCompilationResult)) + node.unlink() + } + } + + root + } \ No newline at end of file diff --git a/scaladoc/src/dotty/tools/scaladoc/snippets/SelfTypePrinter.scala b/scaladoc/src/dotty/tools/scaladoc/snippets/SelfTypePrinter.scala new file mode 100644 index 000000000000..1f51a29243a2 --- /dev/null +++ b/scaladoc/src/dotty/tools/scaladoc/snippets/SelfTypePrinter.scala @@ -0,0 +1,42 @@ +package dotty.tools.scaladoc +package snippets + +import dotty.tools.dotc.printing.RefinedPrinter +import dotty.tools.dotc.core._ +import dotty.tools.dotc.printing.Texts._ +import dotty.tools.dotc.core.Types._ +import dotty.tools.dotc.core.Flags._ +import dotty.tools.dotc.core.Names._ +import dotty.tools.dotc.core.Symbols._ +import dotty.tools.dotc.core.NameOps._ +import dotty.tools.dotc.core.TypeErasure.ErasedValueType +import dotty.tools.dotc.core.Contexts._ +import dotty.tools.dotc.core.Annotations.Annotation +import dotty.tools.dotc.core.Denotations._ +import dotty.tools.dotc.core.SymDenotations._ +import dotty.tools.dotc.core.StdNames.{nme, tpnme} +import dotty.tools.dotc.ast.{Trees, untpd} +import dotty.tools.dotc.typer.{Implicits, Namer, Applications} +import dotty.tools.dotc.typer.ProtoTypes._ +import dotty.tools.dotc.ast.Trees._ +import dotty.tools.dotc.core.TypeApplications._ +import dotty.tools.dotc.core.Decorators._ +import dotty.tools.dotc.util.Chars.isOperatorPart +import dotty.tools.dotc.transform.TypeUtils._ +import dotty.tools.dotc.transform.SymUtils._ + +import language.implicitConversions +import dotty.tools.dotc.util.{NameTransformer, SourcePosition} +import dotty.tools.dotc.ast.untpd.{MemberDef, Modifiers, PackageDef, RefTree, Template, TypeDef, ValOrDefDef} + +class SelfTypePrinter(using _ctx: Context) extends RefinedPrinter(_ctx): + + override def toTextSingleton(tp: SingletonType): Text = + tp match + case ConstantType(value) => + if value.tag == Constants.ByteTag || value.tag == Constants.ShortTag then + toText(value) ~ s" /*${value.tpe.show}*/" + else + toText(value) + case _: TermRef => toTextRef(tp) ~ ".type /*" ~ toTextGlobal(tp.underlying) ~ "*/" + case _ => "(" ~ toTextRef(tp) ~ ": " ~ toTextGlobal(tp.underlying) ~ ")" diff --git a/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetChecker.scala b/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetChecker.scala new file mode 100644 index 000000000000..763821d0de59 --- /dev/null +++ b/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetChecker.scala @@ -0,0 +1,60 @@ +package dotty.tools.scaladoc +package snippets + +import dotty.tools.scaladoc.DocContext +import java.nio.file.Paths +import java.io.File + +import dotty.tools.io.AbstractFile +import dotty.tools.dotc.fromtasty.TastyFileUtil +import dotty.tools.dotc.config.Settings._ +import dotty.tools.dotc.config.ScalaSettings + +class SnippetChecker(val args: Scaladoc.Args)(using cctx: CompilerContext): + +// (val classpath: String, val bootclasspath: String, val tastyFiles: Seq[File], isScalajs: Boolean, useJavaCp: Boolean): + private val sep = System.getProperty("path.separator") + + private val fullClasspath = List( + args.tastyFiles + .map(_.getAbsolutePath()) + .map(AbstractFile.getFile(_)) + .flatMap(t => try { TastyFileUtil.getClassPath(t) } catch { case e: AssertionError => Seq() }) + .distinct.mkString(sep), + args.classpath + ).mkString(sep) + + private val snippetCompilerSettings: Seq[SnippetCompilerSetting[_]] = cctx.settings.userSetSettings(cctx.settingsState).filter(_ != cctx.settings.classpath).map( s => + SnippetCompilerSetting(s, s.valueIn(cctx.settingsState)) + ) :+ SnippetCompilerSetting(cctx.settings.classpath, fullClasspath) + + private val compiler: SnippetCompiler = SnippetCompiler(snippetCompilerSettings = snippetCompilerSettings) + + // These constants were found empirically to make snippet compiler + // report errors in the same position as main compiler. + private val constantLineOffset = 3 + private val constantColumnOffset = 4 + + def checkSnippet( + snippet: String, + data: Option[SnippetCompilerData], + arg: SnippetCompilerArg, + lineOffset: SnippetChecker.LineOffset + ): Option[SnippetCompilationResult] = { + if arg.flag != SCFlags.NoCompile then + val wrapped = WrappedSnippet( + snippet, + data.map(_.packageName), + data.fold(Nil)(_.classInfos), + data.map(_.imports).getOrElse(Nil), + lineOffset + data.fold(0)(_.position.line) + constantLineOffset, + data.fold(0)(_.position.column) + constantColumnOffset + ) + val res = compiler.compile(wrapped, arg) + Some(res) + else None + } + +object SnippetChecker: + type LineOffset = Int + type SnippetCheckingFunc = (String, LineOffset, Option[SCFlags]) => Option[SnippetCompilationResult] diff --git a/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetCompilationResult.scala b/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetCompilationResult.scala new file mode 100644 index 000000000000..f4fe2f8223fb --- /dev/null +++ b/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetCompilationResult.scala @@ -0,0 +1,27 @@ +package dotty.tools.scaladoc +package snippets + +import dotty.tools.io.{ AbstractFile } + +case class Position(line: Int, column: Int, sourceLine: String, relativeLine: Int) + +case class SnippetCompilerMessage(position: Option[Position], message: String, level: MessageLevel): + def getSummary: String = + position.fold(s"${level.text}: ${message}") { pos => + s"At ${pos.line}:${pos.column}:\n${pos.sourceLine}${level.text}: ${message}" + } + +case class SnippetCompilationResult( + wrappedSnippet: WrappedSnippet, + isSuccessful: Boolean, + result: Option[AbstractFile], + messages: Seq[SnippetCompilerMessage] +): + def getSummary: String = messages.map(_.getSummary).mkString("\n") + +enum MessageLevel(val text: String): + case Info extends MessageLevel("Info") + case Warning extends MessageLevel("Warning") + case Error extends MessageLevel("Error") + case Debug extends MessageLevel("Debug") + diff --git a/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetCompiler.scala b/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetCompiler.scala new file mode 100644 index 000000000000..9fdcfc8e46cf --- /dev/null +++ b/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetCompiler.scala @@ -0,0 +1,108 @@ +package dotty.tools.scaladoc +package snippets + +import dotty.tools.io.{AbstractFile, VirtualDirectory} +import dotty.tools.dotc.Driver +import dotty.tools.dotc.core.Contexts.Context +import dotty.tools.dotc.core.Mode +import dotty.tools.dotc.config.Settings.Setting._ +import dotty.tools.dotc.interfaces.SourcePosition +import dotty.tools.dotc.ast.Trees.Tree +import dotty.tools.dotc.interfaces.{SourceFile => ISourceFile} +import dotty.tools.dotc.reporting.{ Diagnostic, StoreReporter } +import dotty.tools.dotc.parsing.Parsers.Parser +import dotty.tools.dotc.{ Compiler, Run } +import dotty.tools.io.{AbstractFile, VirtualDirectory} +import dotty.tools.repl.AbstractFileClassLoader +import dotty.tools.dotc.util.SourceFile +import dotty.tools.dotc.interfaces.Diagnostic._ + +import scala.util.{ Try, Success, Failure } + +class SnippetCompiler( + val snippetCompilerSettings: Seq[SnippetCompilerSetting[_]], + target: AbstractFile = new VirtualDirectory("(memory)") +): + object SnippetDriver extends Driver: + val currentCtx = + val rootCtx = initCtx.fresh.addMode(Mode.ReadPositions).addMode(Mode.Interactive) + rootCtx.setSetting(rootCtx.settings.YretainTrees, true) + rootCtx.setSetting(rootCtx.settings.YcookComments, true) + rootCtx.setSetting(rootCtx.settings.YreadComments, true) + rootCtx.setSetting(rootCtx.settings.color, "never") + rootCtx.setSetting(rootCtx.settings.XimportSuggestionTimeout, 0) + + val ctx = setup(Array(""), rootCtx) match + case Some((_, ctx)) => + ctx + case None => rootCtx + val res = snippetCompilerSettings.foldLeft(ctx.fresh) { (ctx, setting) => + ctx.setSetting(setting.setting, setting.value) + } + res.initialize()(using res) + res + + private val scala3Compiler = new Compiler + + private def newRun(using ctx: Context): Run = scala3Compiler.newRun + + private def nullableMessage(msgOrNull: String): String = + if (msgOrNull == null) "" else msgOrNull + + private def createReportMessage(wrappedSnippet: WrappedSnippet, arg: SnippetCompilerArg, diagnostics: Seq[Diagnostic]): Seq[SnippetCompilerMessage] = { + val line = wrappedSnippet.outerLineOffset + val column = wrappedSnippet.outerColumnOffset + val innerLineOffset = wrappedSnippet.innerLineOffset + val innerColumnOffset = wrappedSnippet.innerColumnOffset + val infos = diagnostics.toSeq.sortBy(_.pos.source.path) + val errorMessages = infos.map { + case diagnostic if diagnostic.position.isPresent => + val diagPos = diagnostic.position.get + val pos = Some( + Position(diagPos.line + line - innerLineOffset, diagPos.column + column - innerColumnOffset, diagPos.lineContent, if arg.debug then diagPos.line else diagPos.line - innerLineOffset) + ) + val dmsg = Try(diagnostic.message) match { + case Success(msg) => msg + case Failure(ex) => ex.getMessage + } + val msg = nullableMessage(dmsg) + val level = MessageLevel.fromOrdinal(diagnostic.level) + SnippetCompilerMessage(pos, msg, level) + case d => + val level = MessageLevel.fromOrdinal(d.level) + SnippetCompilerMessage(None, nullableMessage(d.message), level) + } + errorMessages + } + + private def additionalMessages(wrappedSnippet: WrappedSnippet, arg: SnippetCompilerArg, context: Context): Seq[SnippetCompilerMessage] = { + Option.when(arg.flag == SCFlags.Fail && !context.reporter.hasErrors)( + SnippetCompilerMessage(None, "Snippet should not compile but compiled succesfully", MessageLevel.Error) + ).toList + } + + private def isSuccessful(arg: SnippetCompilerArg, context: Context): Boolean = { + if arg.flag == SCFlags.Fail then context.reporter.hasErrors + else !context.reporter.hasErrors + } + + def compile( + wrappedSnippet: WrappedSnippet, + arg: SnippetCompilerArg + ): SnippetCompilationResult = { + val context = SnippetDriver.currentCtx.fresh + .setSetting( + SnippetDriver.currentCtx.settings.outputDir, + target + ) + .setReporter(new StoreReporter) + val run = newRun(using context) + run.compileFromStrings(List(wrappedSnippet.snippet)) + + val messages = + createReportMessage(wrappedSnippet, arg, context.reporter.pendingMessages(using context)) ++ + additionalMessages(wrappedSnippet, arg, context) + + val t = Option.when(!context.reporter.hasErrors)(target) + SnippetCompilationResult(wrappedSnippet, isSuccessful(arg, context), t, messages) + } diff --git a/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetCompilerArgs.scala b/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetCompilerArgs.scala new file mode 100644 index 000000000000..dcba74b12533 --- /dev/null +++ b/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetCompilerArgs.scala @@ -0,0 +1,72 @@ +package dotty.tools.scaladoc +package snippets + +import java.nio.file.Path + +case class SnippetCompilerArg(flag: SCFlags, debug: Boolean): + def overrideFlag(f: SCFlags): SnippetCompilerArg = copy(flag = f) + +sealed trait SCFlags(val flagName: String) + +object SCFlags: + case object Compile extends SCFlags("compile") + case object NoCompile extends SCFlags("nocompile") + case object Fail extends SCFlags("fail") + + def values: Seq[SCFlags] = Seq(Compile, NoCompile, Fail) + +case class SnippetCompilerArgs(scFlags: PathBased[SCFlags], val debug: Boolean, defaultFlag: SCFlags): + def get(member: Member): SnippetCompilerArg = + member.sources + .flatMap(s => scFlags.get(s.path).map(_.elem)) + .fold(SnippetCompilerArg(defaultFlag, debug))(SnippetCompilerArg(_, debug)) + + def get(path: Option[Path]): SnippetCompilerArg = + path + .flatMap(p => scFlags.get(p).map(_.elem)) + .fold(SnippetCompilerArg(defaultFlag, debug))(SnippetCompilerArg(_, debug)) + + +object SnippetCompilerArgs: + val usage = + """ + |Snippet compiler arguments provide a way to configure snippet checking. + | + |This setting accept list of arguments in format: + |args := arg{,arg} + |arg := [path=]flag + |where path is a prefix of source paths to members to which argument should be set. + | + |If path is not present, argument will be used as default. + | + |Available flags: + |compile - Enables snippet checking. + |nocompile - Disables snippet checking. + |fail - Enables snippet checking, asserts that snippet doesn't compile. + | + """.stripMargin + + val debugUsage = """ + |Setting this option causes snippet compiler to print snippet as it is compiled (after wrapping). + """.stripMargin + + def load(args: List[String], debug: Boolean, defaultFlag: SCFlags = SCFlags.NoCompile)(using CompilerContext): SnippetCompilerArgs = { + PathBased.parse[SCFlags](args)(using SCFlagsParser) match { + case PathBased.ParsingResult(errors, res) => + if errors.nonEmpty then report.warning(s""" + |Got following errors during snippet compiler args parsing: + |$errors + | + |$usage + |""".stripMargin + ) + SnippetCompilerArgs(res, debug, defaultFlag) + } + } + +object SCFlagsParser extends ArgParser[SCFlags]: + def parse(s: String): Either[String, SCFlags] = { + SCFlags.values + .find(_.flagName == s) + .fold(Left(s"$s: No such flag found."))(Right(_)) + } diff --git a/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetCompilerDataCollector.scala b/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetCompilerDataCollector.scala new file mode 100644 index 000000000000..475d38fef895 --- /dev/null +++ b/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetCompilerDataCollector.scala @@ -0,0 +1,82 @@ +package dotty.tools.scaladoc +package snippets + +import scala.quoted._ +import dotty.tools.scaladoc.tasty.SymOps._ +import dotty.tools.dotc.core._ + +class SnippetCompilerDataCollector[Q <: Quotes](val qctx: Q): + import qctx.reflect._ + given qctx.type = qctx + + def getSnippetCompilerData(sym: Symbol, originalSym: Symbol): SnippetCompilerData = + val packageName = sym.packageName + if !sym.isPackageDef then sym.tree match { + case c: qctx.reflect.ClassDef => + import dotty.tools.dotc + import dotty.tools.dotc.core.Decorators._ + given dotc.core.Contexts.Context = qctx.asInstanceOf[scala.quoted.runtime.impl.QuotesImpl].ctx + val printer: dotc.printing.Printer = SelfTypePrinter() + val classSym = c.symbol.asInstanceOf[Symbols.ClassSymbol] + + def getOwners(sym: Symbols.ClassSymbol): Seq[Symbols.ClassSymbol] = Seq(sym) ++ + (if sym.owner.isClass && !sym.owner.is(dotc.core.Flags.Package) then getOwners(sym.owner.asInstanceOf[Symbols.ClassSymbol]) else Nil) + + def collectNames(tp: Types.Type): Seq[String] = tp match { + case Types.AndType(t1, t2) => collectNames(t1) ++ collectNames(t2) + case Types.AppliedType(tpe, _) => collectNames(tpe) + case Types.AnnotatedType(tpe, _) => collectNames(tpe) + case t: Types.NamedType => if t.symbol.is(dotc.core.Flags.Module) then Seq() else Seq(t.symbol.name.show) + case t: Types.ThisType => Seq(t.cls.name.show) + case _ => Seq() + } + + val allSyms = getOwners(classSym) + + val allProcessed = allSyms.map { cSym => + def createTypeConstructor(tpe: Types.Type, topLevel: Boolean = true): String = tpe match { + case t @ Types.TypeBounds(upper, lower) => lower match { + case l: Types.HKTypeLambda => + (if topLevel then "" else "?") + l.paramInfos.map(p => createTypeConstructor(p, false)).mkString("[",", ","]") + case _ => (if topLevel then "" else "_") + } + } + val classType = + val ct = cSym.classInfo.selfType.toText(printer).show.replace(".this","").replace("\n", " ").stripPrefix(s"$packageName.") + Some(ct) + val classNames = collectNames(cSym.classInfo.selfType) + val classGenerics = Option.when( + !cSym.typeParams.isEmpty + )( + cSym.typeParams.map(_.typeRef).map(t => + t.show + + createTypeConstructor(t.asInstanceOf[Types.TypeRef].underlying) + ).mkString("[",", ","]") + ) + SnippetCompilerData.ClassInfo(classType, classNames, classGenerics) + } + val firstProcessed = allProcessed.head + SnippetCompilerData(packageName, allProcessed.reverse, Nil, position(hackGetPositionOfDocstring(using qctx)(originalSym))) + case _ => getSnippetCompilerData(sym.maybeOwner, originalSym) + } else SnippetCompilerData(packageName, Nil, Nil, position(hackGetPositionOfDocstring(using qctx)(originalSym))) + + private def position(p: Option[qctx.reflect.Position]): SnippetCompilerData.Position = + p.fold(SnippetCompilerData.Position(0, 0))(p => SnippetCompilerData.Position(p.startLine, p.startColumn)) + + private def hackGetPositionOfDocstring(using Quotes)(s: qctx.reflect.Symbol): Option[qctx.reflect.Position] = + import dotty.tools.dotc.core.Comments.CommentsContext + import dotty.tools.dotc + given ctx: Contexts.Context = qctx.asInstanceOf[scala.quoted.runtime.impl.QuotesImpl].ctx + val docCtx = ctx.docCtx.getOrElse { + throw new RuntimeException( + "DocCtx could not be found and documentations are unavailable. This is a compiler-internal error." + ) + } + s.pos.flatMap { pos => + docCtx.docstring(s.asInstanceOf[Symbols.Symbol]).map { docstring => + dotty.tools.dotc.util.SourcePosition( + pos.sourceFile.asInstanceOf[dotty.tools.dotc.util.SourceFile], + docstring.span + ).asInstanceOf[qctx.reflect.Position] + } + } \ No newline at end of file diff --git a/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetCompilerSetting.scala b/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetCompilerSetting.scala new file mode 100644 index 000000000000..4c5ef500c83a --- /dev/null +++ b/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetCompilerSetting.scala @@ -0,0 +1,8 @@ +package dotty.tools.scaladoc +package snippets + +import dotty.tools.scaladoc.DocContext +import dotty.tools.dotc.config.Settings._ +import dotty.tools.dotc.config.ScalaSettings + +case class SnippetCompilerSetting[T](setting: Setting[T], value: T) \ No newline at end of file diff --git a/scaladoc/src/dotty/tools/scaladoc/snippets/WrappedSnippet.scala b/scaladoc/src/dotty/tools/scaladoc/snippets/WrappedSnippet.scala new file mode 100644 index 000000000000..e12078217c7e --- /dev/null +++ b/scaladoc/src/dotty/tools/scaladoc/snippets/WrappedSnippet.scala @@ -0,0 +1,54 @@ +package dotty.tools.scaladoc +package snippets + +import java.io.ByteArrayOutputStream +import java.io.PrintStream + +case class WrappedSnippet(snippet: String, outerLineOffset: Int, outerColumnOffset: Int, innerLineOffset: Int, innerColumnOffset: Int) + +object WrappedSnippet: + + val indent: Int = 2 + + def apply(str: String): WrappedSnippet = + val baos = new ByteArrayOutputStream() + val ps = new PrintStream(baos) + ps.println("package snippets") + ps.println("object Snippet {") + str.split('\n').foreach(ps.printlnWithIndent(indent, _)) + ps.println("}") + WrappedSnippet(baos.toString, 0, 0, indent, indent) + + def apply( + str: String, + packageName: Option[String], + classInfos: Seq[SnippetCompilerData.ClassInfo], + imports: List[String], + outerLineOffset: Int, + outerColumnOffset: Int + ): WrappedSnippet = + val baos = new ByteArrayOutputStream() + val ps = new PrintStream(baos) + ps.println(s"package ${packageName.getOrElse("snippets")}") + imports.foreach(i => ps.println(s"import $i")) + val notEmptyClassInfos = if classInfos.isEmpty then Seq(SnippetCompilerData.ClassInfo(None, Nil, None)) else classInfos + notEmptyClassInfos.zipWithIndex.foreach { (info, i) => + ps.printlnWithIndent(indent * i, s"trait Snippet$i${info.generics.getOrElse("")} { ${info.tpe.fold("")(cn => s"self: $cn =>")}") + info.names.foreach{ name => + ps.printlnWithIndent(indent * i + indent, s"val $name = self") + } + } + str.split('\n').foreach(ps.printlnWithIndent(notEmptyClassInfos.size * indent, _)) + (0 to notEmptyClassInfos.size -1).reverse.foreach( i => ps.printlnWithIndent(i * indent, "}")) + WrappedSnippet( + baos.toString, + outerLineOffset, + outerColumnOffset, + notEmptyClassInfos.size + notEmptyClassInfos.flatMap(_.names).size + packageName.size, + notEmptyClassInfos.size * indent + ) + + extension (ps: PrintStream) private def printlnWithIndent(indent: Int, str: String) = + ps.println((" " * indent) + str) + + diff --git a/scaladoc/src/dotty/tools/scaladoc/tasty/BasicSupport.scala b/scaladoc/src/dotty/tools/scaladoc/tasty/BasicSupport.scala index 77cf553c82c9..bbfe50f421ec 100644 --- a/scaladoc/src/dotty/tools/scaladoc/tasty/BasicSupport.scala +++ b/scaladoc/src/dotty/tools/scaladoc/tasty/BasicSupport.scala @@ -39,10 +39,6 @@ trait BasicSupport: extension (using Quotes)(sym: reflect.Symbol) def documentation = sym.docstring.map(parseComment(_, sym.tree)) - def source = - val path = sym.pos.map(_.sourceFile.jpath).filter(_ != null).map(_.toAbsolutePath) - path.map(TastyMemberSource(_, sym.pos.get.startLine)) - def getAnnotations(): List[Annotation] = sym.annotations.filterNot(_.symbol.packageName.startsWith("scala.annotation.internal")).map(parseAnnotation).reverse diff --git a/scaladoc/src/dotty/tools/scaladoc/tasty/SymOps.scala b/scaladoc/src/dotty/tools/scaladoc/tasty/SymOps.scala index cdb1e889b29c..72c8a8073d8a 100644 --- a/scaladoc/src/dotty/tools/scaladoc/tasty/SymOps.scala +++ b/scaladoc/src/dotty/tools/scaladoc/tasty/SymOps.scala @@ -38,6 +38,11 @@ object SymOps: Some(s"${sym.name}-$hash") } else None + + def source = + val path = sym.pos.map(_.sourceFile.jpath).filter(_ != null).map(_.toAbsolutePath) + path.map(TastyMemberSource(_, sym.pos.get.startLine)) + //TODO: Retrieve string that will match scaladoc anchors diff --git a/scaladoc/src/dotty/tools/scaladoc/tasty/comments/Comments.scala b/scaladoc/src/dotty/tools/scaladoc/tasty/comments/Comments.scala index d9199864a407..066b7b5173c4 100644 --- a/scaladoc/src/dotty/tools/scaladoc/tasty/comments/Comments.scala +++ b/scaladoc/src/dotty/tools/scaladoc/tasty/comments/Comments.scala @@ -4,17 +4,18 @@ package tasty.comments import scala.collection.immutable.SortedMap import scala.util.Try -import com.vladsch.flexmark.util.{ast => mdu} +import com.vladsch.flexmark.util.{ast => mdu, sequence} import com.vladsch.flexmark.{ast => mda} import com.vladsch.flexmark.formatter.Formatter import com.vladsch.flexmark.util.options.MutableDataSet import scala.quoted._ +import dotty.tools.scaladoc.tasty.comments.markdown.ExtendedFencedCodeBlock import dotty.tools.scaladoc.tasty.comments.wiki.Paragraph import dotty.tools.scaladoc.DocPart -import dotty.tools.scaladoc.tasty.SymOpsWithLinkCache -import collection.JavaConverters._ +import dotty.tools.scaladoc.tasty.{ SymOpsWithLinkCache, SymOps } import collection.JavaConverters._ +import dotty.tools.scaladoc.snippets._ class Repr(val qctx: Quotes)(val sym: qctx.reflect.Symbol) @@ -70,13 +71,16 @@ case class PreparsedComment( case class DokkaCommentBody(summary: Option[DocPart], body: DocPart) -abstract class MarkupConversion[T](val repr: Repr)(using DocContext) { +abstract class MarkupConversion[T](val repr: Repr)(using dctx: DocContext) { protected def stringToMarkup(str: String): T protected def markupToDokka(t: T): DocPart protected def markupToString(t: T): String protected def markupToDokkaCommentBody(t: T): DokkaCommentBody protected def filterEmpty(xs: List[String]): List[T] protected def filterEmpty(xs: SortedMap[String, String]): SortedMap[String, T] + protected def processSnippets(t: T): T + + lazy val snippetChecker = dctx.snippetChecker val qctx: repr.qctx.type = if repr == null then null else repr.qctx // TODO why we do need null? val owner: qctx.reflect.Symbol = @@ -84,8 +88,8 @@ abstract class MarkupConversion[T](val repr: Repr)(using DocContext) { private given qctx.type = qctx object SymOpsWithLinkCache extends SymOpsWithLinkCache - export SymOpsWithLinkCache.dri - export SymOpsWithLinkCache.driInContextOfInheritingParent + export SymOpsWithLinkCache._ + import SymOps._ def resolveLink(queryStr: String): DocLink = if SchemeUri.matches(queryStr) then DocLink.ToURL(queryStr) @@ -122,8 +126,27 @@ abstract class MarkupConversion[T](val repr: Repr)(using DocContext) { case _ => None } + def snippetCheckingFunc: qctx.reflect.Symbol => SnippetChecker.SnippetCheckingFunc = + (s: qctx.reflect.Symbol) => { + val path = s.source.map(_.path) + val pathBasedArg = dctx.snippetCompilerArgs.get(path) + val data = SnippetCompilerDataCollector[qctx.type](qctx).getSnippetCompilerData(s, s) + (str: String, lineOffset: SnippetChecker.LineOffset, argOverride: Option[SCFlags]) => { + val arg = argOverride.fold(pathBasedArg)(pathBasedArg.overrideFlag(_)) + + snippetChecker.checkSnippet(str, Some(data), arg, lineOffset).collect { + case r: SnippetCompilationResult if !r.isSuccessful => + val msg = s"In member ${s.name} (${s.dri.location}):\n${r.getSummary}" + report.error(msg)(using dctx.compilerContext) + r + case r => r + } + } + } + final def parse(preparsed: PreparsedComment): Comment = - val body = markupToDokkaCommentBody(stringToMarkup(preparsed.body)) + val markup = stringToMarkup(preparsed.body) + val body = markupToDokkaCommentBody(processSnippets(markup)) Comment( body = body.body, short = body.summary, @@ -148,7 +171,7 @@ abstract class MarkupConversion[T](val repr: Repr)(using DocContext) { ) } -class MarkdownCommentParser(repr: Repr)(using DocContext) +class MarkdownCommentParser(repr: Repr)(using dctx: DocContext) extends MarkupConversion[mdu.Node](repr) { def stringToMarkup(str: String) = @@ -174,6 +197,9 @@ class MarkdownCommentParser(repr: Repr)(using DocContext) xs.view.mapValues(_.trim) .filterNot { case (_, v) => v.isEmpty } .mapValues(stringToMarkup).to(SortedMap) + + def processSnippets(root: mdu.Node): mdu.Node = + FlexmarkSnippetProcessor.processSnippets(root, dctx.snippetCompilerArgs.debug, snippetCheckingFunc(owner)) } class WikiCommentParser(repr: Repr)(using DocContext) @@ -227,3 +253,7 @@ class WikiCommentParser(repr: Repr)(using DocContext) def filterEmpty(xs: SortedMap[String,String]) = xs.view.mapValues(stringToMarkup).to(SortedMap) .filterNot { case (_, v) => v.blocks.isEmpty } + + def processSnippets(root: wiki.Body): wiki.Body = + // Currently not supported + root diff --git a/scaladoc/src/dotty/tools/scaladoc/tasty/comments/MemberLookup.scala b/scaladoc/src/dotty/tools/scaladoc/tasty/comments/MemberLookup.scala index 14f14cbedbd1..a6da58ab3e71 100644 --- a/scaladoc/src/dotty/tools/scaladoc/tasty/comments/MemberLookup.scala +++ b/scaladoc/src/dotty/tools/scaladoc/tasty/comments/MemberLookup.scala @@ -151,10 +151,7 @@ trait MemberLookup { } if owner.isPackageDef then - findMatch(hackMembersOf(owner).flatMap { - s => - (if s.name.endsWith("package$") then hackMembersOf(s) else Iterator.empty) ++ Iterator(s) - }) + findMatch(hackMembersOf(owner)) else owner.tree match { case tree: TypeDef => diff --git a/scaladoc/src/dotty/tools/scaladoc/tasty/comments/markdown/DocFlexmarkExtension.scala b/scaladoc/src/dotty/tools/scaladoc/tasty/comments/markdown/DocFlexmarkExtension.scala index ad5e4ac20e2d..2868b901f191 100644 --- a/scaladoc/src/dotty/tools/scaladoc/tasty/comments/markdown/DocFlexmarkExtension.scala +++ b/scaladoc/src/dotty/tools/scaladoc/tasty/comments/markdown/DocFlexmarkExtension.scala @@ -10,13 +10,21 @@ import com.vladsch.flexmark.ext.wikilink.internal.WikiLinkLinkRefProcessor import com.vladsch.flexmark.util.ast._ import com.vladsch.flexmark.util.options._ import com.vladsch.flexmark.util.sequence.BasedSequence +import com.vladsch.flexmark._ +import dotty.tools.scaladoc.snippets._ +import scala.collection.JavaConverters._ class DocLinkNode( val target: DocLink, val body: String, seq: BasedSequence - ) extends WikiNode(seq, false, false, false, false) +) extends WikiNode(seq, false, false, false, false) + +case class ExtendedFencedCodeBlock( + codeBlock: ast.FencedCodeBlock, + compilationResult: Option[SnippetCompilationResult] +) extends WikiNode(codeBlock.getChars, false, false, false, false) class DocFlexmarkParser(resolveLink: String => DocLink) extends Parser.ParserExtension: @@ -55,7 +63,9 @@ case class DocFlexmarkRenderer(renderLink: (DocLink, String) => String) object Render extends NodeRenderer: override def getNodeRenderingHandlers: JSet[NodeRenderingHandler[_]] = - JSet(new NodeRenderingHandler(classOf[DocLinkNode], Handler)) + JSet( + new NodeRenderingHandler(classOf[DocLinkNode], Handler), + ) object Factory extends NodeRendererFactory: override def create(options: DataHolder): NodeRenderer = Render @@ -65,5 +75,10 @@ case class DocFlexmarkRenderer(renderLink: (DocLink, String) => String) object DocFlexmarkRenderer: def render(node: Node)(renderLink: (DocLink, String) => String) = - val opts = MarkdownParser.mkMarkdownOptions(Seq(DocFlexmarkRenderer(renderLink))) - HtmlRenderer.builder(opts).escapeHtml(true).build().render(node) + val opts = MarkdownParser.mkMarkdownOptions( + Seq( + DocFlexmarkRenderer(renderLink), + SnippetRenderingExtension + ) + ) + HtmlRenderer.builder(opts).build().render(node) \ No newline at end of file diff --git a/scaladoc/src/dotty/tools/scaladoc/tasty/comments/markdown/SnippetRenderer.scala b/scaladoc/src/dotty/tools/scaladoc/tasty/comments/markdown/SnippetRenderer.scala new file mode 100644 index 000000000000..0c480fe5ced9 --- /dev/null +++ b/scaladoc/src/dotty/tools/scaladoc/tasty/comments/markdown/SnippetRenderer.scala @@ -0,0 +1,127 @@ +package dotty.tools.scaladoc.tasty.comments.markdown + +import com.vladsch.flexmark.html._ + +import dotty.tools.scaladoc.snippets._ + +case class SnippetLine(content: String, lineNo: Int, classes: Set[String] = Set.empty, messages: Seq[String] = Seq.empty): + def withClass(cls: String) = this.copy(classes = classes + cls) + private def escapeQuotes(msg: String): String = msg.replace("\"", """) + def toHTML = + val label = if messages.nonEmpty then s"""label="${messages.map(escapeQuotes).mkString("\n")}"""" else "" + s"""$content""" + +object SnippetRenderer: + private def compileMessageCSSClass(msg: SnippetCompilerMessage) = msg.level match + case MessageLevel.Info => "snippet-info" + case MessageLevel.Warning => "snippet-warn" + case MessageLevel.Error => "snippet-error" + case MessageLevel.Debug => "snippet-debug" + + private def cutBetweenSymbols[A]( + startSymbol: String, + endSymbol: String, + snippetLines: Seq[SnippetLine] + )( + f: (Seq[SnippetLine], Seq[SnippetLine], Seq[SnippetLine]) => A + ): Option[A] = + for { + startIdx <- snippetLines.zipWithIndex.find(_._1.content.contains(startSymbol)).map(_._2) + endIdx <- snippetLines.zipWithIndex.find(_._1.content.contains(endSymbol)).map(_._2) + (tmp, end) = snippetLines.splitAt(endIdx+1) + (begin, mid) = tmp.splitAt(startIdx) + } yield f(begin, mid, end) + + private def wrapHiddenSymbols(snippetLines: Seq[SnippetLine]): Seq[SnippetLine] = + val mRes = cutBetweenSymbols("//{", "//}", snippetLines) { + case (begin, mid, end) => + begin ++ mid.drop(1).dropRight(1).map(_.withClass("hideable")) ++ wrapHiddenSymbols(end) + } + mRes.getOrElse(snippetLines) + + private def wrapLineInBetween(startSymbol: Option[String], endSymbol: Option[String], line: SnippetLine): SnippetLine = + val startIdx = startSymbol.map(s => line.content.indexOf(s)) + val endIdx = endSymbol.map(s => line.content.indexOf(s)) + (startIdx, endIdx) match + case (Some(idx), None) => + val (code, comment) = line.content.splitAt(idx) + comment match + case _ if code.forall(_.isWhitespace) => + line.withClass("hideable") + case _ if comment.last == '\n' => + line.copy(content = code + s"""${comment.dropRight(1)}${"\n"}""") + case _ => + line.copy(content = code + s"""$comment""") + case (None, Some(idx)) => + val (comment, code) = line.content.splitAt(idx+endSymbol.get.size) + comment match + case _ if code.forall(_.isWhitespace) => + line.withClass("hideable") + case _ => + line.copy(content = s"""$comment""" + code) + case (Some(startIdx), Some(endIdx)) => + val (tmp, end) = line.content.splitAt(endIdx+endSymbol.get.size) + val (begin, comment) = tmp.splitAt(startIdx) + line.copy(content = begin + s"""$comment""" + end) + case _ => line + + private def wrapSingleLineComments(snippetLines: Seq[SnippetLine]): Seq[SnippetLine] = + snippetLines.map { line => + line.content.indexOf("//") match + case -1 => line + case idx => + wrapLineInBetween(Some("//"), None, line) + } + + private def wrapMultiLineComments(snippetLines: Seq[SnippetLine]): Seq[SnippetLine] = + val mRes = cutBetweenSymbols("/*", "*/", snippetLines) { + case (begin, mid, end) if mid.size == 1 => + val midRedacted = mid.map(wrapLineInBetween(Some("/*"), Some("*/"), _)) + begin ++ midRedacted ++ end + case (begin, mid, end) => + val midRedacted = + mid.take(1).map(wrapLineInBetween(Some("/*"), None, _)) + ++ mid.drop(1).dropRight(1).map(_.withClass("hideable")) + ++ mid.takeRight(1).map(wrapLineInBetween(None, Some("*/"), _)) + begin ++ midRedacted ++ wrapMultiLineComments(end) + } + mRes.getOrElse(snippetLines) + + private def wrapCodeLines(codeLines: Seq[String]): Seq[SnippetLine] = + val snippetLines = codeLines.zipWithIndex.map { + case (content, idx) => SnippetLine(content, idx) + } + wrapHiddenSymbols + .andThen(wrapSingleLineComments) + .andThen(wrapMultiLineComments) + .apply(snippetLines) + + private def addCompileMessages(messages: Seq[SnippetCompilerMessage])(codeLines: Seq[SnippetLine]): Seq[SnippetLine] = //TODO add tooltips and stuff + val messagesDict = messages.filter(_.position.nonEmpty).groupBy(_.position.get.relativeLine).toMap[Int, Seq[SnippetCompilerMessage]] + codeLines.map { line => + messagesDict.get(line.lineNo) match + case None => line + case Some(messages) => + val classes = List( + messages.find(_.level == MessageLevel.Error).map(compileMessageCSSClass), + messages.find(_.level == MessageLevel.Warning).map(compileMessageCSSClass), + messages.find(_.level == MessageLevel.Info).map(compileMessageCSSClass) + ).flatten + line.copy(classes = line.classes ++ classes.toSet ++ Set("tooltip"), messages = messages.map(_.message)) + } + + private def messagesHTML(messages: Seq[SnippetCompilerMessage]): String = + if messages.isEmpty + then "" + else + val content = messages + .map { msg => + s"""${msg.getSummary}""" + } + .mkString("
") + s"""
$content""" + + def renderSnippetWithMessages(codeLines: Seq[String], messages: Seq[SnippetCompilerMessage]): String = + val transformedLines = wrapCodeLines.andThen(addCompileMessages(messages)).apply(codeLines).map(_.toHTML) + val codeHTML = s"""${transformedLines.mkString("")}
""" + s"""
$codeHTML
""" \ No newline at end of file diff --git a/scaladoc/src/dotty/tools/scaladoc/tasty/comments/markdown/SnippetRenderingExtension.scala b/scaladoc/src/dotty/tools/scaladoc/tasty/comments/markdown/SnippetRenderingExtension.scala new file mode 100644 index 000000000000..d798d21c4e9f --- /dev/null +++ b/scaladoc/src/dotty/tools/scaladoc/tasty/comments/markdown/SnippetRenderingExtension.scala @@ -0,0 +1,37 @@ +package dotty.tools.scaladoc +package tasty.comments.markdown + +import dotty.tools.scaladoc.snippets._ + +import com.vladsch.flexmark.html._ +import com.vladsch.flexmark.html.renderer._ +import com.vladsch.flexmark.parser._ +import com.vladsch.flexmark.ext.wikilink._ +import com.vladsch.flexmark.ext.wikilink.internal.WikiLinkLinkRefProcessor +import com.vladsch.flexmark.util.ast._ +import com.vladsch.flexmark.util.options._ +import com.vladsch.flexmark.util.sequence.BasedSequence +import com.vladsch.flexmark._ + +object SnippetRenderingExtension extends HtmlRenderer.HtmlRendererExtension: + def rendererOptions(opt: MutableDataHolder): Unit = () + object ExtendedFencedCodeBlockHandler extends CustomNodeRenderer[ExtendedFencedCodeBlock]: + override def render(node: ExtendedFencedCodeBlock, c: NodeRendererContext, html: HtmlWriter): Unit = + html.raw( + SnippetRenderer.renderSnippetWithMessages( + node.codeBlock.getContentChars.toString.split("\n").map(_ + "\n").toSeq, + node.compilationResult.toSeq.flatMap(_.messages) + ) + ) + + object Render extends NodeRenderer: + override def getNodeRenderingHandlers: JSet[NodeRenderingHandler[_]] = + JSet( + new NodeRenderingHandler(classOf[ExtendedFencedCodeBlock], ExtendedFencedCodeBlockHandler), + ) + + object Factory extends NodeRendererFactory: + override def create(options: DataHolder): NodeRenderer = Render + + def extend(htmlRendererBuilder: HtmlRenderer.Builder, tpe: String): Unit = + htmlRendererBuilder.nodeRendererFactory(Factory) \ No newline at end of file diff --git a/scaladoc/test/dotty/tools/scaladoc/BaseHtmlTest.scala b/scaladoc/test/dotty/tools/scaladoc/BaseHtmlTest.scala index 6bb1f1a20010..82713d0505f7 100644 --- a/scaladoc/test/dotty/tools/scaladoc/BaseHtmlTest.scala +++ b/scaladoc/test/dotty/tools/scaladoc/BaseHtmlTest.scala @@ -34,7 +34,7 @@ class BaseHtmlTest: tastyFiles = pcks.flatMap(tastyFiles(_)), output = dest.toFile, docsRoot = docsRoot, - projectVersion = Some(projectVersion), + projectVersion = Some(projectVersion) ) Scaladoc.run(args)(using testContext) op(using ProjectContext(dest)) diff --git a/scaladoc/test/dotty/tools/scaladoc/ScaladocTest.scala b/scaladoc/test/dotty/tools/scaladoc/ScaladocTest.scala index 1ca6dcd04367..ad93d773cf4f 100644 --- a/scaladoc/test/dotty/tools/scaladoc/ScaladocTest.scala +++ b/scaladoc/test/dotty/tools/scaladoc/ScaladocTest.scala @@ -16,7 +16,7 @@ abstract class ScaladocTest(val name: String): given DocContext = testDocContext(tastyFiles(name)) op(ScalaModuleProvider.mkModule()) - private def getTempDir() : TemporaryFolder = + protected def getTempDir() : TemporaryFolder = val folder = new TemporaryFolder() folder.create() folder diff --git a/scaladoc/test/dotty/tools/scaladoc/site/TemplateFileTests.scala b/scaladoc/test/dotty/tools/scaladoc/site/TemplateFileTests.scala index d7ff5340f46f..06d0fd59d055 100644 --- a/scaladoc/test/dotty/tools/scaladoc/site/TemplateFileTests.scala +++ b/scaladoc/test/dotty/tools/scaladoc/site/TemplateFileTests.scala @@ -8,6 +8,7 @@ import org.junit.Test import java.nio.file.Files class TemplateFileTests: + given staticSiteContext: StaticSiteContext = testDocContext().staticSiteContext.get private def testTemplate(code: String, ext: String = "html")(op: TemplateFile => Unit): Unit = val tmpFile = Files.createTempFile("headerTests", s".${ext}").toFile() try diff --git a/scaladoc/test/dotty/tools/scaladoc/snippets/SnippetCompilerTest.scala b/scaladoc/test/dotty/tools/scaladoc/snippets/SnippetCompilerTest.scala new file mode 100644 index 000000000000..86a630a9d42f --- /dev/null +++ b/scaladoc/test/dotty/tools/scaladoc/snippets/SnippetCompilerTest.scala @@ -0,0 +1,68 @@ +package dotty.tools.scaladoc +package snippets + +import org.junit.Test +import org.junit.Assert._ +import dotty.tools.io.{AbstractFile, VirtualDirectory} + +class SnippetCompilerTest { + val compiler = SnippetCompiler( + Seq(SnippetCompilerSetting(testContext.settings.usejavacp, true)) + ) + def wrapFn: String => WrappedSnippet = (str: String) => WrappedSnippet( + str, + Some("test"), + Nil, + Nil, + 0, + 0 + ) + + def runTest(str: String) = compiler.compile(wrapFn(str), SnippetCompilerArg(SCFlags.Compile, false)) + + private def assertSuccessfulCompilation(res: SnippetCompilationResult): Unit = res match { + case r @ SnippetCompilationResult(_, isSuccessful, _, messages) => assert(isSuccessful, r.getSummary) + } + + private def assertFailedCompilation(res: SnippetCompilationResult): Unit = res match { + case r @ SnippetCompilationResult(_, isSuccessful, _, messages) => assert(!isSuccessful, r.getSummary) + } + + def assertSuccessfulCompilation(str: String): Unit = assertSuccessfulCompilation(runTest(str)) + + def assertFailedCompilation(str: String): Unit = assertFailedCompilation(runTest(str)) + + def assertMessageLevelPresent(str: String, level: MessageLevel): Unit = assertMessageLevelPresent(runTest(str), level) + + def assertMessageLevelPresent(res: SnippetCompilationResult, level: MessageLevel): Unit = res match { + case r @ SnippetCompilationResult(_, isSuccessful, _, messages) => assertTrue( + s"Expected message with level: ${level.text}. Got result ${r.getSummary}", + messages.exists(_.level == level) + ) + } + + + @Test + def snippetCompilerTest: Unit = { + val simpleCorrectSnippet = s""" + |class A: + | val b: String = "asd" + |""".stripMargin + + val simpleIncorrectSnippet = s""" + |class A: + | val b: String + |""".stripMargin + val warningSnippet = s""" + |class A: + | val a: Int = try { + | 5 + | } + |""".stripMargin + assertSuccessfulCompilation(simpleCorrectSnippet) + assertFailedCompilation(simpleIncorrectSnippet) + assertMessageLevelPresent(simpleIncorrectSnippet, MessageLevel.Error) + assertMessageLevelPresent(warningSnippet, MessageLevel.Warning) + //No test for Info + } +} \ No newline at end of file diff --git a/scaladoc/test/dotty/tools/scaladoc/snippets/SnippetsE2eTest.scala b/scaladoc/test/dotty/tools/scaladoc/snippets/SnippetsE2eTest.scala new file mode 100644 index 000000000000..f80b709f3ce9 --- /dev/null +++ b/scaladoc/test/dotty/tools/scaladoc/snippets/SnippetsE2eTest.scala @@ -0,0 +1,170 @@ +package dotty.tools.scaladoc +package snippets + +import scala.io.Source + +import org.junit.Test +import org.junit.Assert._ +import dotty.tools.io.{AbstractFile, VirtualDirectory} +import dotty.tools.scaladoc.test.BuildInfo +import scala.util.matching.Regex +import dotty.tools.dotc.reporting.{ Diagnostic, StoreReporter } + +import com.vladsch.flexmark.util.{ast => mdu, sequence} +import com.vladsch.flexmark.{ast => mda} +import com.vladsch.flexmark.formatter.Formatter +import com.vladsch.flexmark.util.options.MutableDataSet +import collection.JavaConverters._ + +import dotty.tools.scaladoc.tasty.comments.markdown.ExtendedFencedCodeBlock + +abstract class SnippetsE2eTest(testName: String, flag: SCFlags, debug: Boolean) extends ScaladocTest(testName): + + import SnippetsE2eTest._ + + val source = Source.fromFile(s"${BuildInfo.test_testcasesSourceRoot}/tests/$testName.scala") + + val snippetsCount = source.getLines.filter(_.indexOf("```scala") != -1).size + + def report(str: String) = s"""|In test $testName: + |$str""".stripMargin + + println(BuildInfo.test_testcasesOutputDir.map(_ + s"/tests/$testName")) + + override def args = Scaladoc.Args( + name = "test", + tastyDirs = BuildInfo.test_testcasesOutputDir.map(java.io.File(_)).toSeq, + tastyFiles = tastyFiles(testName), + output = getTempDir().getRoot, + projectVersion = Some("1.0"), + snippetCompiler = List(s"${BuildInfo.test_testcasesSourceRoot}/tests=${flag.flagName}"), + snippetCompilerDebug = debug + ) + + override def withModule(op: DocContext ?=> Module => Unit) = + given DocContext = DocContext(args, testContext.fresh.setReporter(new StoreReporter)) + op(ScalaModuleProvider.mkModule()) + + private def checkWrappedSnippet(ws: WrappedSnippet, si: SnippetInfo) = { + assertTrue( + report( + s"Invalid outer line offset: ${ws.outerLineOffset}. " + + s"Expected: ${si.outerOffset.line}\n" + ), + ws.outerLineOffset == si.outerOffset.line + ) + assertTrue( + report( + s"Invalid outer column offset: ${ws.outerColumnOffset}. " + + s"Expected: ${si.outerOffset.column}\n" + ), + ws.outerColumnOffset == si.outerOffset.column + ) + assertTrue( + report( + s"Invalid inner line offset: ${ws.innerLineOffset}. " + + s"Expected: ${si.innerOffset.line}\n" + ), + ws.innerLineOffset == si.innerOffset.line + ) + assertTrue( + report( + s"Invalid inner column offset: ${ws.innerColumnOffset}. " + + s"Expected: ${si.innerOffset.column}\n" + ), + ws.innerColumnOffset == si.innerOffset.column + ) + } + + private def checkMessages(compilationMessages: Seq[SnippetCompilerMessage], messages: Seq[Message], ws: WrappedSnippet) = { + val compilationMessagesWithPos = compilationMessages.collect { + case m @ SnippetCompilerMessage(Some(_), _, _) => m + }.toList + def isSamePosition(msg: Message, cmsg: SnippetCompilerMessage): Boolean = + cmsg.level == msg.level && cmsg.position.get.line == msg.offset.line && cmsg.position.get.column == msg.offset.column + + def checkRelativeLines(msg: Message, cmsg: SnippetCompilerMessage): Seq[String] = + val pos = cmsg.position.get + if debug then { + if !(pos.relativeLine == pos.line - ws.outerLineOffset + ws.innerLineOffset) then Seq( + s"Expected ${msg.level.text} message at relative line: ${pos.line - ws.outerLineOffset + ws.innerLineOffset} " + + s"but found at ${pos.relativeLine}" + ) else Nil + } else { + if !(pos.relativeLine == pos.line - ws.outerLineOffset) then Seq( + s"Expected ${msg.level.text} message at relative line: ${pos.line - ws.outerLineOffset} " + + s"but found at ${pos.relativeLine}" + ) else Nil + } + + val result = messages.flatMap { msg => + compilationMessagesWithPos + .find(cmsg => isSamePosition(msg, cmsg)) + .fold(Seq(s"Expected ${msg.level.text} message at ${msg.offset.line}:${msg.offset.column}.")) { resp => + checkRelativeLines(msg, resp) + } + } + + if !result.isEmpty then { + val errors = result.mkString("\n") + val foundMessages = compilationMessages.map(m => s"${m.level} at ${m.position.get.line}:${m.position.get.column}").mkString("\n") + throw AssertionError(Seq("Errors:", errors,"Found:", foundMessages).mkString("\n", "\n", "\n")) + } + } + + def moduleTestingFunc: DocContext ?=> Module => Unit = (m: Module) => { + val snippets = m.members.values + .flatMap(_.docs) + .map(_.body) + .collect { case n: mdu.Node => n } + .flatMap(_.getDescendants.asScala) + .collect { case en: ExtendedFencedCodeBlock => en } + + assertTrue(report(s"Expected $snippetsCount snippets but found ${snippets.size}"), snippets.size == snippetsCount) + + snippets.foreach { snippet => + val configStrs = (snippet.getPrevious() match { + case c: mdu.ContentNode => + c.getContentChars.toString.split("\n").map(_.trim) + case _ => throw AssertionError(s"Not found info for snippet ${snippet.codeBlock.getContentChars.toString}") + }).toList + val info = SnippetInfo(configStrs.head) + val messages = configStrs.tail.map(Message.apply) + val compilationResult = snippet.compilationResult match { + case Some(res) => res + case None => throw AssertionError(s"Snippet validation failed:\n${snippet.codeBlock.getContentChars.toString}") + } + val wrappedSnippet = compilationResult.wrappedSnippet + checkWrappedSnippet(wrappedSnippet, info) + checkMessages(compilationResult.messages, messages, wrappedSnippet) + } + } + + def runTest = { + org.junit.Assume.assumeTrue("Running on Windows", java.io.File.separatorChar == '/') + withModule(moduleTestingFunc) + } + +object SnippetsE2eTest: + case class Offset(line: Int, column: Int) + case class SnippetInfo(outerOffset: Offset, innerOffset: Offset) + case class Message(level: MessageLevel, offset: Offset) + object SnippetInfo: + def apply(str: String): SnippetInfo = str match { + case snippetInfoRegex(ol, oc, il, ic) => SnippetInfo( + Offset(ol.toInt, oc.toInt), + Offset(il.toInt, ic.toInt) + ) + } + + object Message: + def apply(str: String): Message = str match { + case errorRegex(ln, cl) => Message(MessageLevel.Error, Offset(ln.toInt, cl.toInt)) + case warningRegex(ln, cl) => Message(MessageLevel.Warning, Offset(ln.toInt, cl.toInt)) + } + val snippetInfoRegex = (raw"SNIPPET\(" + + raw"OUTERLINEOFFSET:(\d+),OUTERCOLUMNOFFSET:(\d+)," + + raw"INNERLINEOFFSET:(\d+),INNERCOLUMNOFFSET:(\d+)\)").r + + val warningRegex = raw"WARNING\(LINE:(\d+),COLUMN:(\d+)\)".r + val errorRegex = raw"ERROR\(LINE:(\d+),COLUMN:(\d+)\)".r \ No newline at end of file diff --git a/scaladoc/test/dotty/tools/scaladoc/snippets/SnippetsE2eTestcases.scala b/scaladoc/test/dotty/tools/scaladoc/snippets/SnippetsE2eTestcases.scala new file mode 100644 index 000000000000..f1d588da81b5 --- /dev/null +++ b/scaladoc/test/dotty/tools/scaladoc/snippets/SnippetsE2eTestcases.scala @@ -0,0 +1,10 @@ +package dotty.tools.scaladoc +package snippets + +class SnippetE2eTestcase1 extends SnippetsE2eTest("snippetTestcase1", SCFlags.Compile, false) + +class SnippetE2eTestcase1Debug extends SnippetsE2eTest("snippetTestcase1", SCFlags.Compile, true) + +class SnippetE2eTestcase2 extends SnippetsE2eTest("snippetTestcase2", SCFlags.Compile, false) + +class SnippetE2eTestcase2Debug extends SnippetsE2eTest("snippetTestcase2", SCFlags.Compile, true) diff --git a/scaladoc/test/dotty/tools/scaladoc/testUtils.scala b/scaladoc/test/dotty/tools/scaladoc/testUtils.scala index b5c3d2abd2bc..5d2191c22c40 100644 --- a/scaladoc/test/dotty/tools/scaladoc/testUtils.scala +++ b/scaladoc/test/dotty/tools/scaladoc/testUtils.scala @@ -53,10 +53,14 @@ class TestReporter extends ConsoleReporter: def testArgs(files: Seq[File] = Nil, dest: File = new File("notUsed")) = Scaladoc.Args( name = "Test Project Name", output = dest, - tastyFiles = files + tastyFiles = files, + docsRoot = Some(""), ) -def testContext = (new ContextBase).initialCtx.fresh.setReporter(new TestReporter) +def testContext = + val ctx = (new ContextBase).initialCtx.fresh.setReporter(new TestReporter) + ctx.setSetting(ctx.settings.usejavacp, true) + ctx def testDocContext(files: Seq[File] = Nil) = DocContext(testArgs(files), testContext)