diff --git a/docs/_docs/reference/experimental/capture-checking/advanced.md b/docs/_docs/reference/experimental/capture-checking/advanced.md index c6fc3b2038fa..4c5e6a71d024 100644 --- a/docs/_docs/reference/experimental/capture-checking/advanced.md +++ b/docs/_docs/reference/experimental/capture-checking/advanced.md @@ -94,4 +94,4 @@ By leveraging capability polymorphism, capability members, and path-dependent ca * `Label`s store the free capabilities `C` of the `block` passed to `boundary` in their capability member `Fv`. * When suspending on a given label, the suspension handler can capture at most the capabilities that occur freely at the `boundary` that introduced the label. That prevents mentioning nested bound labels. -[Back to Capability Polymorphism](polymorphism.md) \ No newline at end of file +[Back to Capability Polymorphism](polymorphism.md) diff --git a/docs/_docs/reference/experimental/capture-checking/basics.md b/docs/_docs/reference/experimental/capture-checking/basics.md index 4e4ea0d2694f..22cb38f5fe71 100644 --- a/docs/_docs/reference/experimental/capture-checking/basics.md +++ b/docs/_docs/reference/experimental/capture-checking/basics.md @@ -85,7 +85,7 @@ trait LzyList[+A]: object LzyList: def apply[T](xs: T*): LzyList[T] = ??? //} -val xs = usingLogFile { f => +val xs = usingLogFile { f => // error // error LzyList(1, 2, 3).map { x => f.write(x); x * x } } ``` @@ -372,7 +372,7 @@ like this: ```scala sc:fail sc-compile-with:logfile-checked var loophole: () => Unit = () => () usingLogFile { f => - loophole = () => f.write(0) + loophole = () => f.write(0) // error } loophole() ``` diff --git a/docs/_docs/reference/experimental/capture-checking/cc.md b/docs/_docs/reference/experimental/capture-checking/cc.md index dd697a8a272a..a207ff0ac8ba 100644 --- a/docs/_docs/reference/experimental/capture-checking/cc.md +++ b/docs/_docs/reference/experimental/capture-checking/cc.md @@ -4,4 +4,4 @@ title: "Capture Checking" nightlyOf: https://docs.scala-lang.org/scala3/reference/experimental/capture-checking/index.html --- -Capture checking is a research project that modifies the Scala type system to track references to capabilities in values. \ No newline at end of file +Capture checking is a research project that modifies the Scala type system to track references to capabilities in values. diff --git a/docs/_docs/reference/experimental/capture-checking/checked-exceptions.md b/docs/_docs/reference/experimental/capture-checking/checked-exceptions.md index f984e94c49ff..ea1931be168b 100644 --- a/docs/_docs/reference/experimental/capture-checking/checked-exceptions.md +++ b/docs/_docs/reference/experimental/capture-checking/checked-exceptions.md @@ -73,9 +73,9 @@ As with other capability based schemes, one needs to guard against capabilities that are captured in results. For instance, here is a problematic use case: ```scala sc:fail sc-compile-with:checked-exceptions-base def escaped(xs: Double*): (() => Double) throws LimitExceeded = - try () => xs.map(f).sum // error: CanThrow escapes into returned closure + try () => xs.map(f).sum catch case ex: LimitExceeded => () => -1 -val crasher = escaped(1, 2, 10e+11) +val crasher = escaped(1, 2, 10e+11) // error: CanThrow escapes into returned closure crasher() ``` This code needs to be rejected since otherwise the call to `crasher()` would cause diff --git a/docs/_docs/reference/experimental/capture-checking/internals.md b/docs/_docs/reference/experimental/capture-checking/internals.md index 3a95ac0e25da..c639294b35d7 100644 --- a/docs/_docs/reference/experimental/capture-checking/internals.md +++ b/docs/_docs/reference/experimental/capture-checking/internals.md @@ -80,5 +80,3 @@ This section lists all variables that appeared in previous diagnostics and their - variable `31` has a constant fixed superset `{xs, f}` - variable `32` has no dependencies. - - diff --git a/docs/_docs/reference/experimental/capture-checking/scoped-capabilities.md b/docs/_docs/reference/experimental/capture-checking/scoped-capabilities.md index 3494d97e9c24..f949b65a91d9 100644 --- a/docs/_docs/reference/experimental/capture-checking/scoped-capabilities.md +++ b/docs/_docs/reference/experimental/capture-checking/scoped-capabilities.md @@ -142,7 +142,7 @@ If any scope refuses to absorb the capability, capture checking fails: ```scala sc:fail sc-compile-with:scoped-fs-context def process(fs: FileSystem^): Unit = - val f: () -> Unit = () => fs.read() // Error: fs cannot flow into {} + val f: () -> Unit = () => fs.read() // error: fs cannot flow into {} ``` The closure is declared pure (`() -> Unit`), meaning its local `any` is the empty set. The @@ -312,14 +312,14 @@ determines the binding structure automatically from where `fresh` appears in the The rules above establish a key practical distinction when writing function types. Consider: -```scala sc:fail +```scala sc:fail sc-compile-with:scoped-cc-context import caps.fresh class A class B def test(): Unit = - val f: (x: A^) -> B^{fresh} = ??? // B^{fresh}: existentially bound - val g: A^ -> B^ = ??? // B^{any}: enclosing scope's local any + val f: (x: A^) -> B^{fresh} = ??? // B^{fresh}: existentially bound + val g: A^ -> B^ = ??? // B^{any}: enclosing scope's local any val _: A^ -> B^ = f // error: fresh is not in {any} val _: A^ -> B^{fresh} = f // ok @@ -390,7 +390,7 @@ directly returning a closure that captures it: ```scala sc:fail sc-compile-with:scoped-withfile-context withFile[() => File^]("test.txt"): f => // ^^^^^^^^^^^ T = () => File^, i.e., () ->{any} File^{any} for some outer any - () => f // error: We want to return this as () => File^ + () => f // error // error // error: We want to return this as () => File^ ``` The lambda `(f: File^) => () => f` has inferred type: @@ -414,7 +414,7 @@ into this outer `any`, so the assignment fails. Otherwise, allowing widening `∃fresh. () ->{fresh} File^{fresh}` to `() => File^` would let the scoped file escape: ```scala sc:fail sc-compile-with:scoped-withfile-context -val escaped: () => File^ = withFile[() => File^]("test.txt")(f => () => f) +val escaped: () => File^ = withFile[() => File^]("test.txt")(f => () => f) // error // error // ^^^^^^^^^^^ any here is in the outer scope escaped().read() // Use-after-close! ``` diff --git a/project/Build.scala b/project/Build.scala index ea0235e45add..c10cf53245b5 100644 --- a/project/Build.scala +++ b/project/Build.scala @@ -2137,9 +2137,7 @@ object Build { .add(NoLinkWarnings(true)) .add(NoLinkAssetWarnings(true)) .add(GenerateAPI(false)) - .add(SnippetCompiler(List( - s"${tempDocsRoot.getAbsolutePath}=compile" - ))) + .add(SnippetCompiler(referenceSnippetCompilerTargets(tempRoot.getAbsolutePath))) } generateDocumentation(config) @@ -3071,8 +3069,14 @@ object ScaladocConfigs { "enums", "experimental/capture-checking", ) + def captureCheckingSnippetTestTargets(docsRoot: String) = List( + s"$docsRoot/_docs/reference/experimental/capture-checking/basics.md=compile+test", + s"$docsRoot/_docs/reference/experimental/capture-checking/checked-exceptions.md=compile+test", + s"$docsRoot/_docs/reference/experimental/capture-checking/scoped-capabilities.md=compile+test" + ) def referenceSnippetCompilerTargets(docsRoot: String) = - referenceSnippetRelativeRoots.map(path => s"$docsRoot/_docs/reference/$path=compile") + referenceSnippetRelativeRoots.map(path => s"$docsRoot/_docs/reference/$path=compile") ++ + captureCheckingSnippetTestTargets(docsRoot) lazy val Scala3 = Def.task { val stdlib = { // relative path to the stdlib directory ('library/') diff --git a/scaladoc/src/dotty/tools/scaladoc/site/templates.scala b/scaladoc/src/dotty/tools/scaladoc/site/templates.scala index d9c72c909680..5c78fb249aa6 100644 --- a/scaladoc/src/dotty/tools/scaladoc/site/templates.scala +++ b/scaladoc/src/dotty/tools/scaladoc/site/templates.scala @@ -76,24 +76,24 @@ case class TemplateFile( def isIndexPage() = file.isFile && (file.getName == "index.md" || file.getName == "index.html") 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) val sourceFile = dotty.tools.dotc.util.SourceFile(dotty.tools.io.AbstractFile.getFile(path.get), scala.io.Codec.UTF8) - (snippet: SnippetSource, argOverride: Option[SnippetCompilerArg]) => { - val arg = argOverride.fold(pathBasedArg)(pathBasedArg.merge(_)) - val compilerData = SnippetCompilerData( - "staticsitesnippet", - SnippetCompilerData.Position(configOffset - 1, 0) - ) - ssctx.snippetChecker.checkSnippet(snippet, Some(compilerData), arg, sourceFile, 0).collect { - case r: SnippetCompilationResult if !r.isSuccessful => - ssctx.bufferSnippetMessages(r.messages) - r - case r => r - } - } + (snippet: SnippetSource, argOverride: Option[SnippetCompilerArg]) => + val arg = argOverride.fold(pathBasedArg)(pathBasedArg.merge(_)) + val compilerData = SnippetCompilerData("staticsitesnippet", SnippetCompilerData.Position(configOffset - 1, 0)) + val result = ssctx.snippetChecker.checkSnippet( + snippet, + Some(compilerData), + arg, + sourceFile, + 0 + ) + result.foreach: r => + if !r.isSuccessful then + ssctx.bufferSnippetMessages(r.messages) + result if (ctx.resolving.contains(file.getAbsolutePath)) throw new RuntimeException(s"Cycle in templates involving $file: ${ctx.resolving}") diff --git a/scaladoc/src/dotty/tools/scaladoc/snippets/FlexmarkSnippetProcessor.scala b/scaladoc/src/dotty/tools/scaladoc/snippets/FlexmarkSnippetProcessor.scala index 810746bac753..122bd812f788 100644 --- a/scaladoc/src/dotty/tools/scaladoc/snippets/FlexmarkSnippetProcessor.scala +++ b/scaladoc/src/dotty/tools/scaladoc/snippets/FlexmarkSnippetProcessor.scala @@ -3,7 +3,6 @@ package snippets import com.vladsch.flexmark.util.{ast => mdu, sequence} import com.vladsch.flexmark.{ast => mda} -import com.vladsch.flexmark.formatter.Formatter import scala.jdk.CollectionConverters._ import dotty.tools.scaladoc.tasty.comments.markdown.ExtendedFencedCodeBlock @@ -20,7 +19,7 @@ object FlexmarkSnippetProcessor: nodes.foldLeft[Map[String, SnippetSource]](Map()) { (snippetMap, node) => val lineOffset = node.getStartLineNumber + preparsed.fold(0)(_.strippedLinesBeforeNo) val codeStartLine = lineOffset + SnippetChecker.codeFenceContentLineOffset - val info = node.getInfo.toString.split(" ") + val info = node.getInfo.toString.split(" ").filter(_.nonEmpty) if info.contains("scala") then { val flagOverride = info .find(_.startsWith("sc:")) diff --git a/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetChecker.scala b/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetChecker.scala index ec845670e7c2..6e4520072f6d 100644 --- a/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetChecker.scala +++ b/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetChecker.scala @@ -1,15 +1,10 @@ package dotty.tools.scaladoc package snippets -import dotty.tools.scaladoc.DocContext -import java.nio.file.Paths -import java.io.File - +import dotty.tools.dotc.config.Settings._ +import dotty.tools.dotc.fromtasty.TastyFileUtil import dotty.tools.dotc.util.SourceFile 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): private val sep = System.getProperty("path.separator") @@ -18,15 +13,19 @@ class SnippetChecker(val args: Scaladoc.Args)(using cctx: CompilerContext): args.tastyFiles .map(_.getAbsolutePath()) .map(AbstractFile.getFile(_)) - .flatMap(t => try { TastyFileUtil.getClassPath(t) } catch { case e: AssertionError => Seq() }) - .distinct.mkString(sep), + .flatMap(t => try TastyFileUtil.getClassPath(t) catch case _: AssertionError => Seq.empty) + .distinct + .mkString(sep), args.classpath ).mkString(sep) - private val snippetCompilerSettings: Seq[SnippetCompilerSetting[?]] = cctx.settings.userSetSettings(cctx.settingsState).filter(_ != cctx.settings.classpath) - .map[SnippetCompilerSetting[?]]( s => - SnippetCompilerSetting(s, s.valueIn(cctx.settingsState)) - ) :+ SnippetCompilerSetting(cctx.settings.classpath, fullClasspath) + private val snippetCompilerSettings: Seq[SnippetCompilerSetting[?]] = + val userSetSettings = + cctx.settings.userSetSettings(cctx.settingsState) + .filter(_ != cctx.settings.classpath) + .map[SnippetCompilerSetting[?]]: setting => + SnippetCompilerSetting(setting, setting.valueIn(cctx.settingsState)) + userSetSettings :+ SnippetCompilerSetting(cctx.settings.classpath, fullClasspath) private val compiler: SnippetCompiler = SnippetCompiler(snippetCompilerSettings = snippetCompilerSettings) @@ -36,23 +35,31 @@ class SnippetChecker(val args: Scaladoc.Args)(using cctx: CompilerContext): arg: SnippetCompilerArg, sourceFile: SourceFile, sourceColumnOffset: Int - ): Option[SnippetCompilationResult] = { + ): Option[SnippetCompilationResult] = if arg.flag != SCFlags.NoCompile then val baseLineOffset = data.fold(0)(_.position.line) val baseColumnOffset = data.fold(0)(_.position.column) + sourceColumnOffset + val sourceLines = snippet.sourceLines.map(_.map(_ + baseLineOffset)) + val adjustedSnippet = snippet.copy( + sourceLines = sourceLines, + outerLineOffset = snippet.outerLineOffset + baseLineOffset + ) val wrapped = WrappedSnippet( snippet.snippet, data.map(_.packageName), snippet.outerLineOffset + baseLineOffset, baseColumnOffset, - snippet.sourceLines.map(_.map(_ + baseLineOffset)) + sourceLines ) - Some(compiler.compile(wrapped, arg, sourceFile)) + Some(compiler.compile( + adjustedSnippet, + wrapped, + arg, + sourceFile + )) else None - } - object SnippetChecker: // The first line of snippet content is two lines below the opening code fence. val codeFenceContentLineOffset = 2 diff --git a/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetCompilationResult.scala b/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetCompilationResult.scala index b7cafacc684d..b2fa0dd37db4 100644 --- a/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetCompilationResult.scala +++ b/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetCompilationResult.scala @@ -8,12 +8,13 @@ case class Position(srcPos: SourcePosition, relativeLine: Int) case class SnippetCompilerMessage(position: Option[Position], message: String, level: MessageLevel): def emit()(using CompilerContext): Unit = + val ctx = position.fold(summon[CompilerContext])(pos => summon[CompilerContext].withSource(pos.srcPos.source)) val pos: SrcPos = position.fold(dotty.tools.dotc.util.NoSourcePosition)(_.srcPos) level match - case MessageLevel.Info => report.log(message, pos) - case MessageLevel.Warning => report.warning(message, pos) - case MessageLevel.Error => report.error(message, pos) - case MessageLevel.Debug => report.log(message, pos) + case MessageLevel.Info => report.log(message, pos)(using ctx) + case MessageLevel.Warning => report.warning(message, pos)(using ctx) + case MessageLevel.Error => report.error(message, pos)(using ctx) + case MessageLevel.Debug => report.log(message, pos)(using ctx) case class SnippetCompilationResult( wrappedSnippet: WrappedSnippet, diff --git a/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetCompiler.scala b/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetCompiler.scala index bca2a8c89da6..39fe5622f8c2 100644 --- a/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetCompiler.scala +++ b/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetCompiler.scala @@ -7,19 +7,10 @@ import dotty.tools.dotc.core.Contexts.Context import dotty.tools.dotc.core.Mode import dotty.tools.dotc.core.MacroClassLoader import dotty.tools.dotc.config.Settings.Setting._ -import dotty.tools.dotc.interfaces.{ SourcePosition => ISourcePosition } -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.reporting.StoreReporter import dotty.tools.dotc.{ Compiler, Run } -import dotty.tools.io.{AbstractFile, VirtualDirectory} -import dotty.tools.io.AbstractFileClassLoader -import dotty.tools.dotc.util.Spans._ -import dotty.tools.dotc.interfaces.Diagnostic._ -import dotty.tools.dotc.util.{ SourcePosition, NoSourcePosition, SourceFile } - -import scala.util.{ Try, Success, Failure } +import dotty.tools.dotc.util.{SourceFile, SourcePosition} +import dotty.tools.dotc.util.Spans.NoSpan class SnippetCompiler( val snippetCompilerSettings: Seq[SnippetCompilerSetting[?]], @@ -49,32 +40,12 @@ class SnippetCompiler( private def newRun(using ctx: Context): Run = scala3Compiler.newRun - private def nullableMessage(msgOrNull: String | Null): String = - if (msgOrNull == null) "" else msgOrNull - - private def createReportMessage(wrappedSnippet: WrappedSnippet, diagnostics: Seq[Diagnostic], sourceFile: SourceFile): Seq[SnippetCompilerMessage] = { - val infos = diagnostics.toSeq.sortBy(_.pos.source.path) - val errorMessages = infos.map { - case diagnostic if diagnostic.position.isPresent => - val diagPos = diagnostic.position.get match - case s: SourcePosition => s - case _ => NoSourcePosition - val pos = wrappedSnippet.sourcePosition(diagPos, sourceFile) - 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, sourceFile: SourceFile, context: Context): Seq[SnippetCompilerMessage] = { + private def additionalMessages( + wrappedSnippet: WrappedSnippet, + arg: SnippetCompilerArg, + sourceFile: SourceFile, + context: Context + ): Seq[SnippetCompilerMessage] = { Option.when(arg.flag == SCFlags.Fail && !context.reporter.hasErrors)( SnippetCompilerMessage( Some(Position(SourcePosition(sourceFile, NoSpan), wrappedSnippet.outerLineOffset)), @@ -84,10 +55,16 @@ class SnippetCompiler( private def isSuccessful(arg: SnippetCompilerArg, context: Context): Boolean = { if arg.flag == SCFlags.Fail then context.reporter.hasErrors - else !context.reporter.hasErrors + else !context.reporter.hasErrors } + private def missingExpectedErrorsMessage(arg: SnippetCompilerArg): Seq[SnippetCompilerMessage] = + Option.when(arg.flag == SCFlags.Fail)( + SnippetCompilerMessage(None, "No errors found when compiling snippet", MessageLevel.Error) + ).toList + def compile( + snippet: SnippetSource, wrappedSnippet: WrappedSnippet, arg: SnippetCompilerArg, sourceFile: SourceFile @@ -108,10 +85,35 @@ class SnippetCompiler( val run = newRun(using context) run.compileFromStrings(List(wrappedSnippet.snippet)) + val diagnostics = context.reporter.pendingMessages(using context) + val observed = SnippetExpectations.observe(diagnostics, wrappedSnippet, sourceFile) + val shouldVerifyDiagnostics = arg.verifyDiagnostics + val expected = + if shouldVerifyDiagnostics then SnippetExpectations.parse(snippet, sourceFile) + else SnippetExpectations.Parsed(Nil, Nil) + val diagnosticMessages = + if shouldVerifyDiagnostics then SnippetExpectations.validate(expected, observed, sourceFile) + else Nil + val failMessages = + if shouldVerifyDiagnostics && expected.expectedErrors == 0 && !context.reporter.hasErrors then + missingExpectedErrorsMessage(arg) + else Nil + val compatibilityMessages = + if !shouldVerifyDiagnostics then + additionalMessages(wrappedSnippet, arg, sourceFile, context) + else Nil + val validationMessages = diagnosticMessages ++ failMessages ++ compatibilityMessages + val expectationDriven = shouldVerifyDiagnostics + val hasMismatches = validationMessages.exists(_.level == MessageLevel.Error) val messages = - createReportMessage(wrappedSnippet, context.reporter.pendingMessages(using context), sourceFile) ++ - additionalMessages(wrappedSnippet, arg, sourceFile, context) + if expectationDriven && hasMismatches then validationMessages + else observed.map(_.message) ++ validationMessages + val succeeded = + if expectationDriven then + !hasMismatches + && (arg.flag != SCFlags.Fail || context.reporter.hasErrors || expected.expectedErrors > 0) + else isSuccessful(arg, context) val t = Option.when(!context.reporter.hasErrors)(target) - SnippetCompilationResult(wrappedSnippet, isSuccessful(arg, context), t, messages) + SnippetCompilationResult(wrappedSnippet, succeeded, t, messages) } diff --git a/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetCompilerArgs.scala b/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetCompilerArgs.scala index 78641d4a2ff0..9f7366d00812 100644 --- a/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetCompilerArgs.scala +++ b/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetCompilerArgs.scala @@ -3,11 +3,19 @@ package snippets import java.nio.file.Path -case class SnippetCompilerArg(flag: SCFlags, scalacOptions: Seq[String] = Seq.empty): +case class SnippetCompilerArg( + flag: SCFlags, + scalacOptions: Seq[String] = Seq.empty, + verifyDiagnostics: Boolean = false +): def overrideFlag(f: SCFlags): SnippetCompilerArg = copy(flag = f) def withScalacOptions(opts: Seq[String]): SnippetCompilerArg = copy(scalacOptions = scalacOptions ++ opts) def merge(other: SnippetCompilerArg): SnippetCompilerArg = - SnippetCompilerArg(other.flag, scalacOptions ++ other.scalacOptions) + SnippetCompilerArg( + other.flag, + scalacOptions ++ other.scalacOptions, + verifyDiagnostics || other.verifyDiagnostics + ) enum SCFlags(val flagName: String): case Compile extends SCFlags("compile") @@ -27,22 +35,27 @@ case class SnippetCompilerArgs(scArgs: PathBased[SnippetCompilerArg], defaultFla object SnippetCompilerArgs: + /** Enables inline diagnostic expectation checking (`// error`, `// warn`). */ + val TestModifier = "test" + val usage = - """ + s""" |Snippet compiler arguments provide a way to configure snippet type checking. | - |This setting accept list of arguments in format: + |This setting accepts a list of arguments in format: |args := arg{,arg} - |arg := [path=]flag[|scalacOption]* - |where `path` is a prefix of the path to source files where snippets are located, `flag` is the mode in which snippets will be type checked, and optional `scalacOption`s (separated by `|`) are passed to the compiler. + |arg := [path=]flag[+modifier]*[|scalacOption]* + |where `path` is a prefix of the path to source files where snippets are located, `flag` is the mode in which snippets will be type checked, optional `modifier`s tweak snippet assertions, and optional `scalacOption`s (separated by `|`) are passed to the compiler. | - |If the path is not present, the argument will be used as the default for all unmatched paths.. + |If the path is not present, the argument will be used as the default for all unmatched paths. | |Available flags: |compile - Enables snippet checking. |nocompile - Disables snippet checking. |fail - Enables snippet checking, asserts that snippet doesn't compile. | + |Available modifiers: + |$TestModifier - Enables inline diagnostic expectation checking (`// error`, `// warn`). """.stripMargin def load(args: List[String], defaultFlag: SCFlags = SCFlags.NoCompile)(using CompilerContext): SnippetCompilerArgs = { @@ -68,5 +81,13 @@ object SCFlagsParser extends ArgParser[SCFlags]: object SnippetCompilerArgParser extends ArgParser[SnippetCompilerArg]: def parse(s: String): Either[String, SnippetCompilerArg] = val parts = s.split("\\|") - SCFlagsParser.parse(parts(0)).map: flag => - SnippetCompilerArg(flag, parts.drop(1).toSeq) + val flagAndModifiers = parts.head.split("\\+").toList + val flagText = flagAndModifiers.head + val modifiers = flagAndModifiers.tail + val unknownModifiers = modifiers.filterNot(_ == SnippetCompilerArgs.TestModifier) + val verifyDiagnostics = modifiers.contains(SnippetCompilerArgs.TestModifier) + if unknownModifiers.nonEmpty then + Left(s"${unknownModifiers.mkString(", ")}: Unknown snippet compiler modifier(s).") + else + SCFlagsParser.parse(flagText).map: flag => + SnippetCompilerArg(flag, parts.drop(1).toSeq, verifyDiagnostics) diff --git a/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetExpectations.scala b/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetExpectations.scala new file mode 100644 index 000000000000..7cbd7ab1289e --- /dev/null +++ b/scaladoc/src/dotty/tools/scaladoc/snippets/SnippetExpectations.scala @@ -0,0 +1,230 @@ +package dotty.tools.scaladoc +package snippets + +import dotty.tools.dotc.reporting.Diagnostic +import dotty.tools.dotc.util.{SourceFile, SourcePosition} +import dotty.tools.dotc.util.Spans.Span + +import scala.collection.mutable.ListBuffer +import scala.util.Try + +object SnippetExpectations: + // Mirrors the compiler test suite (ParallelTesting.scala): space after `//` is optional. + // Only `// error` and `// warn` are supported; `// anypos-*` is rejected, `// nopos-*` + // is not recognised (position-less diagnostics don't arise in self-contained snippets). + private val annotation = + raw"""// *(anypos-)?(error|warn)\b""".r + + private def adjustAtEOF(pos: SourcePosition): SourcePosition = + if pos.span.isSynthetic + && pos.span.isZeroExtent + && pos.span.exists + && pos.source.exists + && pos.span.start == pos.source.length + && pos.span.start > 0 + && pos.source(pos.span.start - 1) == '\n' + then pos.withSpan(pos.span.shift(-1)) + else pos + + case class ExpectedDiagnostic( + level: MessageLevel, + sourceLine: Option[Int], + relativeLine: Int + ): + def position(sourceFile: SourceFile): Option[Position] = + sourceLine + .flatMap(sourceFile.lineToOffsetOpt) + .map(offset => Position(SourcePosition(sourceFile, Span(offset, offset)), relativeLine)) + + def description: String = + s"${level.text.toLowerCase} on line ${sourceLine.fold(relativeLine + 1)(_ + 1)}" + + // Matching is line-based only; column position is not checked. Multiple + // diagnostics on the same line are matched in declaration order. + // Diagnostic message content is intentionally ignored: annotations like + // `// error: some message` match any error on that line regardless of text. + def matches(observed: ObservedDiagnostic): Boolean = + observed.message.level == level && sourceLine == observed.sourceLine + + case class Parsed( + expectations: Seq[ExpectedDiagnostic], + parserErrors: Seq[SnippetCompilerMessage] + ): + def hasExpectations: Boolean = expectations.nonEmpty + def expectedErrors: Int = expectations.count(_.level == MessageLevel.Error) + + case class ObservedDiagnostic( + diagnostic: Diagnostic, + message: SnippetCompilerMessage, + renderPosition: Option[Position], + sourceLine: Option[Int] + ) + + private def levelName(level: MessageLevel): String = level match + case MessageLevel.Error => "error" + case MessageLevel.Warning => "warning" + case _ => level.text.toLowerCase + + private def levelNamePlural(level: MessageLevel): String = s"${levelName(level)}s" + + private def levelTitlePlural(level: MessageLevel): String = s"${level.text}s" + + private def annotationName(level: MessageLevel): String = level match + case MessageLevel.Warning => "warn" + case _ => "error" + + private def sourceLinePosition( + sourceFile: SourceFile, + sourceLine: Option[Int], + relativeLine: Int + ): Option[Position] = + sourceLine + .flatMap(sourceFile.lineToOffsetOpt) + .map(offset => Position(SourcePosition(sourceFile, Span(offset, offset)), relativeLine)) + + private def unexpectedDescription(observed: ObservedDiagnostic): String = + val levelText = levelName(observed.message.level) + observed.sourceLine match + case Some(line) => s"$levelText on line ${line + 1}" + case None => s"$levelText at an unknown position" + + private def unsupportedAnnotationMessage(level: MessageLevel): String = + val ann = annotationName(level) + s"Unsupported snippet diagnostic annotation `// anypos-$ann`; use `// $ann`" + + private def missingExpectationSummary(level: MessageLevel, actualCount: Int): String = level match + case MessageLevel.Error => + s"""|No expected errors marked in snippet -- use // error + |actual error count: $actualCount""".stripMargin + case MessageLevel.Warning => + s"""|No expected warnings marked in snippet -- use // warn + |actual warning count: $actualCount""".stripMargin + case _ => + s"No expected ${levelNamePlural(level)} marked in snippet" + + private def validateLevel( + level: MessageLevel, + expectations: Seq[ExpectedDiagnostic], + observed: Seq[ObservedDiagnostic], + sourceFile: SourceFile + ): Seq[SnippetCompilerMessage] = + if expectations.isEmpty then + if observed.isEmpty then Nil + else + val summary = SnippetCompilerMessage(None, missingExpectationSummary(level, observed.size), MessageLevel.Error) + val unexpected = observed.map(observed => + SnippetCompilerMessage( + observed.message.position, + s"Unexpected ${unexpectedDescription(observed)}", + MessageLevel.Error + ) + ) + summary +: unexpected + else + val matched = Array.fill(observed.size)(false) + val unfulfilled = ListBuffer.empty[(Option[Position], String)] + val unexpected = ListBuffer.empty[(Option[Position], String)] + + for expectation <- expectations.sortBy(_.relativeLine) do + val matchIdx = observed.indices.find(idx => !matched(idx) && expectation.matches(observed(idx))) + matchIdx match + case Some(idx) => + matched(idx) = true + case None => + unfulfilled += ((expectation.position(sourceFile), expectation.description)) + + for idx <- observed.indices if !matched(idx) do + val diagnostic = observed(idx) + unexpected += ((diagnostic.message.position, unexpectedDescription(diagnostic))) + + if unfulfilled.isEmpty && unexpected.isEmpty then Nil + else + val summary = + if expectations.size != observed.size then + s"""|Wrong number of ${levelNamePlural(level)} encountered when compiling snippet + |expected: ${expectations.size}, actual: ${observed.size}""".stripMargin + else + s"${levelTitlePlural(level)} found on incorrect row numbers when compiling snippet" + val summaryMessage = SnippetCompilerMessage(None, summary, MessageLevel.Error) + val mismatchMessages = + unfulfilled.map: (position, description) => + SnippetCompilerMessage(position, s"Unfulfilled expectation: $description", MessageLevel.Error) + ++ unexpected.map: (position, description) => + SnippetCompilerMessage(position, s"Unexpected $description", MessageLevel.Error) + summaryMessage +: mismatchMessages.toSeq + + /** Scans `snippet` for inline diagnostic annotations (`// error`, `// warn`) and + * returns them as a [[Parsed]] value together with any parse-level errors + * (e.g. unsupported `// anypos-*` annotations). + */ + def parse(snippet: SnippetSource, sourceFile: SourceFile): Parsed = + val expectations = ListBuffer.empty[ExpectedDiagnostic] + val parserErrors = ListBuffer.empty[SnippetCompilerMessage] + + for ((line, sourceLine), relativeLine) <- snippet.snippet.linesIterator.zip(snippet.sourceLines).zipWithIndex do + val annotations = annotation.findAllMatchIn(line).toList + for m <- annotations do + val isAnypos = Option(m.group(1)).isDefined + val level = m.group(2) match + case "warn" => MessageLevel.Warning + case _ => MessageLevel.Error + if isAnypos then + parserErrors += SnippetCompilerMessage( + sourceLinePosition(sourceFile, sourceLine, relativeLine), + unsupportedAnnotationMessage(level), + MessageLevel.Error + ) + else + expectations += ExpectedDiagnostic(level, sourceLine, relativeLine) + + Parsed(expectations.toList, parserErrors.toList) + + /** Converts raw compiler `diagnostics` into [[ObservedDiagnostic]] values by + * mapping their positions back from the synthetic wrapper to the original + * snippet source via `wrappedSnippet`. + */ + def observe( + diagnostics: Seq[Diagnostic], + wrappedSnippet: WrappedSnippet, + sourceFile: SourceFile + ): Seq[ObservedDiagnostic] = + diagnostics.toSeq.map: diagnostic => + val msg = + Try(diagnostic.message) match + case scala.util.Success(message) => message + case scala.util.Failure(ex) => ex.getMessage + // Relies on MessageLevel ordinals matching dotty.tools.dotc.interfaces.Diagnostic + // integer constants: INFO=0, WARNING=1, ERROR=2. Keep MessageLevel case order in sync. + val level = MessageLevel.fromOrdinal(diagnostic.level) + val rawPos = adjustAtEOF(diagnostic.pos.nonInlined) + val mappedPos = + if rawPos.exists then wrappedSnippet.sourcePosition(rawPos, sourceFile) + else None + val renderPos = + if rawPos.exists then wrappedSnippet.sourceSpanPosition(rawPos, sourceFile) + else None + ObservedDiagnostic( + diagnostic, + SnippetCompilerMessage(mappedPos, if msg == null then "" else msg, level), + renderPos, + mappedPos.map(_.srcPos.line) + ) + + /** Matches `observed` diagnostics against `parsed` expectations and returns + * [[SnippetCompilerMessage]] values describing any mismatches (unexpected + * diagnostics, unfulfilled expectations, wrong counts, wrong lines). + * Returns an empty sequence when all expectations are satisfied. + */ + def validate( + parsed: Parsed, + observed: Seq[ObservedDiagnostic], + sourceFile: SourceFile + ): Seq[SnippetCompilerMessage] = + val errorExpectations = parsed.expectations.filter(_.level == MessageLevel.Error) + val warningExpectations = parsed.expectations.filter(_.level == MessageLevel.Warning) + val errorDiagnostics = observed.filter(_.message.level == MessageLevel.Error) + val warningDiagnostics = observed.filter(_.message.level == MessageLevel.Warning) + + parsed.parserErrors ++ + validateLevel(MessageLevel.Error, errorExpectations, errorDiagnostics, sourceFile) ++ + validateLevel(MessageLevel.Warning, warningExpectations, warningDiagnostics, sourceFile) diff --git a/scaladoc/src/dotty/tools/scaladoc/snippets/WrappedSnippet.scala b/scaladoc/src/dotty/tools/scaladoc/snippets/WrappedSnippet.scala index 1ea1f2bb9bb9..2ef42b3d84a8 100644 --- a/scaladoc/src/dotty/tools/scaladoc/snippets/WrappedSnippet.scala +++ b/scaladoc/src/dotty/tools/scaladoc/snippets/WrappedSnippet.scala @@ -21,6 +21,12 @@ case class WrappedSnippet( .flatten .flatMap(_.sourcePosition(diagPos, sourceFile, outerColumnOffset)) + def sourceSpanPosition(diagPos: SourcePosition, sourceFile: SourceFile): Option[Position] = + lineMappings + .lift(diagPos.line) + .flatten + .flatMap(_.sourceSpanPosition(diagPos, sourceFile, outerColumnOffset)) + object WrappedSnippet: // `wrappedColumnOffset` accounts for indentation added by the synthetic wrapper. case class WrappedLineMapping(sourceLine: Int, relativeLine: Int, wrappedColumnOffset: Int): @@ -28,13 +34,33 @@ object WrappedSnippet: diagPos: SourcePosition, sourceFile: SourceFile, outerColumnOffset: Int + ): Option[Position] = + mapPosition(diagPos, sourceFile, outerColumnOffset, diagPos.column, diagPos.column) + + def sourceSpanPosition( + diagPos: SourcePosition, + sourceFile: SourceFile, + outerColumnOffset: Int + ): Option[Position] = + val endColumn = + if diagPos.startLine == diagPos.endLine then diagPos.endColumn + else diagPos.startColumn + mapPosition(diagPos, sourceFile, outerColumnOffset, diagPos.startColumn, endColumn) + + private def mapPosition( + diagPos: SourcePosition, + sourceFile: SourceFile, + outerColumnOffset: Int, + startBase: Int, + endBase: Int ): Option[Position] = val lineOffset = sourceFile match case NoSource => Some(0) case sf: SourceFile => sf.lineToOffsetOpt(sourceLine) lineOffset.map: offset => - val sourceColumn = (diagPos.column + outerColumnOffset - wrappedColumnOffset).max(0) - val span = Span(offset + sourceColumn, offset + sourceColumn) + val startColumn = (startBase + outerColumnOffset - wrappedColumnOffset).max(0) + val endColumn = (endBase + outerColumnOffset - wrappedColumnOffset).max(startColumn) + val span = Span(offset + startColumn, offset + endColumn) Position(SourcePosition(sourceFile, span), relativeLine) val indent: Int = 2 diff --git a/scaladoc/src/dotty/tools/scaladoc/tasty/comments/Comments.scala b/scaladoc/src/dotty/tools/scaladoc/tasty/comments/Comments.scala index 40da87cb33a3..db13c322eed0 100644 --- a/scaladoc/src/dotty/tools/scaladoc/tasty/comments/Comments.scala +++ b/scaladoc/src/dotty/tools/scaladoc/tasty/comments/Comments.scala @@ -139,7 +139,13 @@ abstract class MarkupConversion[T](val repr: Repr)(using dctx: DocContext) { val sourceFile = scDataCollector.getSourceFile(s) (snippet: SnippetSource, argOverride: Option[SnippetCompilerArg]) => { val arg = argOverride.fold(pathBasedArg)(pathBasedArg.merge(_)) - val res = snippetChecker.checkSnippet(snippet, Some(data), arg, sourceFile, SnippetChecker.docCommentColumnOffset) + val res = snippetChecker.checkSnippet( + snippet, + Some(data), + arg, + sourceFile, + SnippetChecker.docCommentColumnOffset + ) res.filter(r => !r.isSuccessful).foreach(_.reportMessages()(using compilerContext)) res } diff --git a/scaladoc/test/dotty/tools/scaladoc/site/TemplateFileTests.scala b/scaladoc/test/dotty/tools/scaladoc/site/TemplateFileTests.scala index 1e8a9259818d..3544b4bcca52 100644 --- a/scaladoc/test/dotty/tools/scaladoc/site/TemplateFileTests.scala +++ b/scaladoc/test/dotty/tools/scaladoc/site/TemplateFileTests.scala @@ -344,8 +344,6 @@ class TemplateFileTests: loadTemplateFile(first).resolveInner(RenderingContext(Map.empty)) loadTemplateFile(second).resolveInner(RenderingContext(Map.empty)) - assertEquals(0, dctx.compilerContext.reportedDiagnostics.errors.size) - summon[StaticSiteContext].reportSnippetMessages() val diagnostics = dctx.compilerContext.reportedDiagnostics @@ -356,6 +354,40 @@ class TemplateFileTests: ) finally IO.delete(tmpRoot) + @Test + def markdownInlineExpectationsCanValidateFailingSnippets(): Unit = + val tmpRoot = Files.createTempDirectory("snippet-inline-checks").toFile() + val tmpDocs = File(tmpRoot, "_docs") + val tmpFile = File(tmpDocs, "checks.md") + try + Files.createDirectories(tmpDocs.toPath) + Files.write( + tmpFile.toPath, + """--- + |title: "Snippet inline checks" + |--- + | + |```scala sc:fail + |val x = 1.missing // error + |``` + |""".stripMargin.getBytes + ) + + val dctx = DocContext( + testArgs().copy( + docsRoot = Some(tmpRoot.getAbsolutePath), + snippetCompiler = List(s"${tmpFile.getAbsolutePath}=compile+test") + ), + testContext + ) + given StaticSiteContext = dctx.staticSiteContext.get + + loadTemplateFile(tmpFile).resolveInner(RenderingContext(Map.empty)) + summon[StaticSiteContext].reportSnippetMessages() + + assertEquals(0, dctx.compilerContext.reportedDiagnostics.errors.size) + finally IO.delete(tmpRoot) + private def renderNamedSnippet(relativePath: String, noSnippetNamesFor: List[String] = Nil): String = val tmpRoot = Files.createTempDirectory("snippet-name-rendering").toFile() val tmpFile = File(tmpRoot, relativePath) diff --git a/scaladoc/test/dotty/tools/scaladoc/snippets/SnippetCompilerTest.scala b/scaladoc/test/dotty/tools/scaladoc/snippets/SnippetCompilerTest.scala index 3b7631a87191..256b91855953 100644 --- a/scaladoc/test/dotty/tools/scaladoc/snippets/SnippetCompilerTest.scala +++ b/scaladoc/test/dotty/tools/scaladoc/snippets/SnippetCompilerTest.scala @@ -3,7 +3,6 @@ package snippets import org.junit.Test import org.junit.Assert._ -import dotty.tools.io.{AbstractFile, VirtualDirectory} class SnippetCompilerTest { val compiler = SnippetCompiler( @@ -16,7 +15,14 @@ class SnippetCompilerTest { 0, ) - def runTest(str: String) = compiler.compile(wrapFn(str), SnippetCompilerArg(SCFlags.Compile), dotty.tools.dotc.util.SourceFile.virtual("test", str)) + private def sourceFile(str: String) = + dotty.tools.dotc.util.SourceFile.virtual("test", str) + + def runTest( + str: String, + arg: SnippetCompilerArg = SnippetCompilerArg(SCFlags.Compile) + ) = + compiler.compile(SnippetSource(str, 0), wrapFn(str), arg, sourceFile(str)) private def assertSuccessfulCompilation(res: SnippetCompilationResult): Unit = res match { case r @ SnippetCompilationResult(_, isSuccessful, _, messages) => assert(isSuccessful, r.messages.map(_.message).mkString("\n")) @@ -84,4 +90,182 @@ class SnippetCompilerTest { |def foo[C^](x: AnyRef^{C}): AnyRef^{x} = x |""".stripMargin) } + + @Test + def inlineExpectedErrorsCanDriveNegativeSnippets: Unit = { + val snippet = + """|val x = 1.missing // error + |""".stripMargin + + val result = runTest(snippet, SnippetCompilerArg(SCFlags.Compile, verifyDiagnostics = true)) + assertSuccessfulCompilation(result) + assertMessageLevelPresent(result, MessageLevel.Error) + } + + @Test + def testedSnippetsWithoutDiagnosticsPass: Unit = { + val snippet = + """|val x = 1 + 1 + |""".stripMargin + + assertSuccessfulCompilation(runTest(snippet, SnippetCompilerArg(SCFlags.Compile, verifyDiagnostics = true))) + } + + @Test + def testedSnippetsRequireErrorAnnotations: Unit = { + val snippet = + """|val x = 1.missing + |""".stripMargin + + val result = runTest(snippet, SnippetCompilerArg(SCFlags.Compile, verifyDiagnostics = true)) + assertFailedCompilation(result) + assertEquals(2, result.messages.count(_.level == MessageLevel.Error)) + assertTrue(result.messages.exists(_.message.contains("No expected errors marked in snippet -- use // error"))) + assertTrue(result.messages.exists(_.message.contains("Unexpected error on line 1"))) + } + + @Test + def testedSnippetsRequireWarningAnnotations: Unit = { + val snippet = + """|val a: Int = try { 5 } + |""".stripMargin + + val result = runTest(snippet, SnippetCompilerArg(SCFlags.Compile, verifyDiagnostics = true)) + assertFailedCompilation(result) + assertEquals(2, result.messages.count(_.level == MessageLevel.Error)) + assertTrue(result.messages.exists(_.message.contains("No expected warnings marked in snippet -- use // warn"))) + assertTrue(result.messages.exists(_.message.contains("Unexpected warning on line 1"))) + } + + @Test + def inlineExpectedErrorsWorkWithFailFlag: Unit = { + val snippet = + """|val x = 1.missing // error + |""".stripMargin + + assertSuccessfulCompilation(runTest(snippet, SnippetCompilerArg(SCFlags.Fail, verifyDiagnostics = true))) + } + + @Test + def testedFailSnippetsNeedExpectedErrors: Unit = { + val snippet = + """|val x = 1 + 1 + |""".stripMargin + + val result = runTest(snippet, SnippetCompilerArg(SCFlags.Fail, verifyDiagnostics = true)) + assertFailedCompilation(result) + assertTrue(result.messages.exists(_.message.contains("No errors found when compiling snippet"))) + } + + @Test + def inlineExpectedDiagnosticMessagesAreIgnored: Unit = { + val snippet = + """|val x = 1.missing // error: /totally different/ + |""".stripMargin + + assertSuccessfulCompilation(runTest(snippet, SnippetCompilerArg(SCFlags.Compile, verifyDiagnostics = true))) + } + + @Test + def inlineExpectationRowMismatchesUseNegTestWording: Unit = { + val snippet = + """|val x = 1.missing + |val y = 1 + 1 // error + |""".stripMargin + + val result = runTest(snippet, SnippetCompilerArg(SCFlags.Compile, verifyDiagnostics = true)) + assertFailedCompilation(result) + assertEquals(3, result.messages.count(_.level == MessageLevel.Error)) + assertTrue(result.messages.exists(_.message.contains("Errors found on incorrect row numbers when compiling snippet"))) + assertTrue(result.messages.exists(_.message.contains("Unfulfilled expectation: error on line 2"))) + assertTrue(result.messages.exists(_.message.contains("Unexpected error on line 1"))) + } + + @Test + def inlineExpectationCountMismatchesUseNegTestWording: Unit = { + val snippet = + """|val x = 1.missing // error + |val y = 1 + 1 // error + |""".stripMargin + + val result = runTest(snippet, SnippetCompilerArg(SCFlags.Compile, verifyDiagnostics = true)) + assertFailedCompilation(result) + assertEquals(2, result.messages.count(_.level == MessageLevel.Error)) + assertTrue(result.messages.exists(_.message.contains("Wrong number of errors encountered when compiling snippet"))) + assertTrue(result.messages.exists(_.message.contains("expected: 2, actual: 1"))) + assertTrue(result.messages.exists(_.message.contains("Unfulfilled expectation: error on line 2"))) + } + + @Test + def inlineExpectationParserAcceptsTrailingProseAndWarnings: Unit = { + val snippet = + """|val x = 1.missing // error // explanatory prose + |val y: Int = try { 5 } // warn // more prose + |val z = 1.missing // error: not a member + |""".stripMargin + + val parsed = SnippetExpectations.parse(SnippetSource(snippet, 0), sourceFile(snippet)) + assertTrue(parsed.parserErrors.isEmpty) + assertEquals(3, parsed.expectations.size) + assertEquals(2, parsed.expectedErrors) + assertEquals(1, parsed.expectations.count(_.level == MessageLevel.Warning)) + } + + @Test + def inlineExpectationParserRejectsAnyposAnnotations: Unit = { + val snippet = + """|val x = 1.missing // anypos-error + |val y: Int = try { 5 } // anypos-warn + |""".stripMargin + + val parsed = SnippetExpectations.parse(SnippetSource(snippet, 0), sourceFile(snippet)) + val errors = parsed.parserErrors.map(_.message) + assertEquals(2, parsed.parserErrors.size) + assertEquals(0, parsed.expectations.size) + assertTrue(errors.exists(_.contains("Unsupported snippet diagnostic annotation `// anypos-error`; use `// error`"))) + assertTrue(errors.exists(_.contains("Unsupported snippet diagnostic annotation `// anypos-warn`; use `// warn`"))) + } + + @Test + def inlineExpectedWarningsAreChecked: Unit = { + val warningSnippet = + """|val a: Int = try { 5 } // warn + |""".stripMargin + + val result = runTest(warningSnippet, SnippetCompilerArg(SCFlags.Compile, verifyDiagnostics = true)) + assertSuccessfulCompilation(result) + assertMessageLevelPresent(result, MessageLevel.Warning) + } + + @Test + def inlineExpectationWarnRowMismatch: Unit = { + // Warning occurs on line 1, but annotation is on line 2 — should fail. + val snippet = + """|val a: Int = try { 5 } + |val b = 1 + 1 // warn + |""".stripMargin + + val result = runTest(snippet, SnippetCompilerArg(SCFlags.Compile, verifyDiagnostics = true)) + assertFailedCompilation(result) + assertTrue(result.messages.exists(_.message.contains("Warnings found on incorrect row numbers when compiling snippet"))) + assertTrue(result.messages.exists(_.message.contains("Unfulfilled expectation: warning on line 2"))) + assertTrue(result.messages.exists(_.message.contains("Unexpected warning on line 1"))) + } + + @Test + def multilineInlineExpectationsAreChecked: Unit = { + val snippet = + """|import language.experimental.captureChecking + |import caps.* + | + |trait File extends SharedCapability + |def withFile[T](path: String)(block: File^ => T): T = ??? + | + |withFile[() => File^]("test.txt"): f => + | () => f // error // error // error + |""".stripMargin + + assertSuccessfulCompilation(runTest(snippet, SnippetCompilerArg(SCFlags.Fail, verifyDiagnostics = true))) + } + }