diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e938e44 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +# Runs on every push to main and on pull requests: verifies formatting with +# scalafmt, then compiles and runs the full test suite. +on: + push: + branches: + - main + pull_request: + +# Cancel superseded runs for the same ref (e.g. rapid PR pushes). +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '25' + cache: sbt + + - name: Set up sbt + uses: sbt/setup-sbt@v1 + + - name: Check formatting, compile, and test + run: sbt "scalafmtCheckAll;test" diff --git a/README.md b/README.md index 6b171c5..c8b3e26 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ marklit lives in [mdoc](https://scalameta.org/mdoc/)'s neighborhood. Many ideas | **Named scopes with inheritance** (`id=foo`, `extends=foo`) | ✗ | **✓** | | **Append to a named scope** (`extends=foo,append`) | ✗ | **✓** | | Cross-built dependencies on per-major classpaths | ✗ | **✓** | +| **Top-level definitions** (`opaque type`, `@main`, top-level `given`/`extension`) | ✗ | **✓** | +| **No "local class" warning on enum/ADT pattern matches** | partial | **✓** | | **Built-in ZIO runtime** (`zio-app` modifier) | ✗ | **✓** | | Multiple modifiers per block (`silent,id=setup`) | partial | **✓** | | Scoped-by-default — blocks isolated unless you opt in | ✗ | **✓** | @@ -145,6 +147,7 @@ Code fences with the info string `scala marklit:` are processed. Modi | `crash` | Assert that execution throws. The exception is rendered. | | `passthrough` | Render the block as-is — no compilation. | | `zio-app` | Wrap the block as a ZIO program and run it via ZIO's `Runtime.unsafe`. | +| `top-level` | Compile the block verbatim as its own compilation unit (no wrapper). For constructs that are illegal or warn inside a method body — `opaque type`, `@main`, or matching a parameterized `enum`/ADT case. **Compile-only**: shows the code (and any diagnostics), never output. See [Top-level blocks](#top-level-blocks). | | `id=` | Name this block's scope. | | `extends=` | Create a child scope inheriting from ``. | | `extends=,append` | Append to `` instead of branching. Subsequent `extends=` blocks see the appended code. | @@ -157,6 +160,47 @@ Examples: `silent,id=setup`, `fail,extends=errors`, `zio-app,scala=3.8.2`. `id` See [examples/base/src/main/markdown/tutorial.md](examples/base/src/main/markdown/tutorial.md) for a worked example of every feature. +## Top-level blocks + +By default every block is wrapped in `object MarklitWrapper: def run(): Unit = …`, so your code is a **method body**. That's usually what you want — but some Scala constructs are only legal, or only warning-free, at the *top level* of a source file: + +- `opaque type` and `@main def` are **rejected** inside a method body. +- Matching a *parameterized* `enum`/ADT case against a non-local type warns `the type test for X cannot be checked at runtime because it's a local class` — because an `enum` declared inside `def run()` becomes a *local* class. + +Mark such a block `top-level` and marklit compiles it verbatim as its own compilation unit. Because top-level code has no entry point, these blocks are **compile-only** — marklit renders the code (and any compile diagnostics) but never executes them. + +> **Why this matters (vs. mdoc).** mdoc wraps every block in a *class*, and its only escape hatch — `reset-object` — wraps in an object *and clears the scope*. By mdoc's own docs that works around `Value class may not be a member of another class` and `The outer reference in this type test cannot be checked at run time`, but an object member still can't be an `opaque type`, `@main`, or top-level `given`/`extension`, and `reset-object` throws away your prior definitions. marklit's `top-level` compiles at genuine file scope **and** hoists the definition forward into a normal executable block (below) — so you can show the type *and* run an example that uses it, warning-free. + +```scala marklit:top-level +opaque type Celsius = Double +object Celsius: + def apply(d: Double): Celsius = d + extension (c: Celsius) def value: Double = c +``` + +**Sharing a top-level definition with an executable example.** Give the top-level block an `id`, then `extends=` it from a normal block. Marklit *hoists* the inherited definition above the wrapper while your example runs inside `run()` — so the example compiles cleanly (no local-class warning) **and** shows its output: + +````markdown +```scala marklit:top-level,id=actions +enum CounterAction: + case Inc + case Set(v: Int) +``` + +```scala marklit:extends=actions +val a: Any = CounterAction.Set(10) +a match + case CounterAction.Set(v) => println(s"set $v") + case _ => println("other") +``` +```` + +Rules: + +- `top-level` is strict — it may only be combined with scope options (`id`/`extends`/`append`), a version selector (`scala=3`, `scala=3.7.3`), and `show-warnings`. Pairing it with `silent`, `fail`, `zio-app`, `scala=shared`, etc. is a validation error. +- Scopes are single-kind. A normal block may `extends=` a top-level scope (hoisting its definitions); a `top-level` block may **not** extend or append to a normal scope, and `append` must stay within one kind. +- Version selectors work as usual: `top-level,scala=3.7.3` compiles against that exact version, and an `extends=` consumer must agree on the version. Top-level blocks do not participate in `--page-scope`, but an explicit `extends=` consumer composes with it. + ## How marklit reads your Markdown Marklit is **not** a full Markdown processor. It scans for fenced code blocks and treats everything else as opaque text — headings, lists, tables, links, HTML, your blank lines and trailing whitespace all flow through verbatim. The renderer's job is to splice executed output into the right places, not to rewrite your prose. diff --git a/build.sbt b/build.sbt index 5f8fd3f..095163a 100644 --- a/build.sbt +++ b/build.sbt @@ -20,14 +20,16 @@ ThisBuild / scalaVersion := marklitScalaVersion ThisBuild / organization := "io.github.russwyte" // version is derived from git tags by sbt-dynver (tag v0.1.0 -> 0.1.0). -ThisBuild / organizationName := "russwyte" +ThisBuild / organizationName := "russwyte" ThisBuild / organizationHomepage := Some(url("https://github.com/russwyte")) -ThisBuild / licenses := List("Apache-2.0" -> url("http://www.apache.org/licenses/LICENSE-2.0.txt")) -ThisBuild / homepage := Some(url("https://github.com/russwyte/marklit")) -ThisBuild / scmInfo := Some( +ThisBuild / licenses := List( + "Apache-2.0" -> url("http://www.apache.org/licenses/LICENSE-2.0.txt") +) +ThisBuild / homepage := Some(url("https://github.com/russwyte/marklit")) +ThisBuild / scmInfo := Some( ScmInfo( url("https://github.com/russwyte/marklit"), - "scm:git@github.com:russwyte/marklit.git", + "scm:git@github.com:russwyte/marklit.git" ) ) ThisBuild / developers := List( @@ -35,7 +37,7 @@ ThisBuild / developers := List( id = "russwyte", name = "Russ White", email = "356303+russwyte@users.noreply.github.com", - url = url("https://github.com/russwyte"), + url = url("https://github.com/russwyte") ) ) ThisBuild / versionScheme := Some("early-semver") @@ -43,7 +45,8 @@ ThisBuild / versionScheme := Some("early-semver") // Publishing to Sonatype's Central Portal. sbt 1.11+ has built-in support via // `localStaging` / `publishSigned` / `sonaRelease` — no sbt-sonatype plugin needed. ThisBuild / publishTo := { - val centralSnapshots = "https://central.sonatype.com/repository/maven-snapshots/" + val centralSnapshots = + "https://central.sonatype.com/repository/maven-snapshots/" if (isSnapshot.value) Some("central-snapshots" at centralSnapshots) else localStaging.value } @@ -51,11 +54,13 @@ ThisBuild / publishTo := { // PGP key used to sign published artifacts (sbt-pgp + local gpg keyring). // CI overrides the key via PGP_KEY_HEX (dedicated GitHub Actions signing key); // local manual publishing falls back to the personal key on this machine. -usePgpKeyHex(sys.env.getOrElse("PGP_KEY_HEX", "2F64727A87F1BCF42FD307DD8582C4F16659A7D6")) +usePgpKeyHex( + sys.env.getOrElse("PGP_KEY_HEX", "2F64727A87F1BCF42FD307DD8582C4F16659A7D6") +) // Only the sbt plugin is published; everything else is internal or bundled. lazy val publishSettings = Seq( - publishMavenStyle := true, + publishMavenStyle := true, pomIncludeRepository := { _ => false } ) diff --git a/compiler/src/main/scala/marklit/Marklit.scala b/compiler/src/main/scala/marklit/Marklit.scala index bdd66f9..3f186dc 100644 --- a/compiler/src/main/scala/marklit/Marklit.scala +++ b/compiler/src/main/scala/marklit/Marklit.scala @@ -197,21 +197,31 @@ private final class CompilerServiceAdapter( private def buildContext( code: String, priorCode: Vector[String], - isZIOApp: Boolean + isZIOApp: Boolean, + topLevel: Boolean, + topLevelPriorCode: Vector[String] ): ScopeContext = + // Top-level blocks compile verbatim (no wrapper) and never execute, so no + // output marker is injected — a bare `print(marker)` would be illegal at + // file scope. Otherwise mark only when there's prior run-body code to skip. val marker = - if priorCode.nonEmpty then Some(blockMarker(code, priorCode, isZIOApp)) + if topLevel then None + else if priorCode.nonEmpty then + Some(blockMarker(code, priorCode, isZIOApp, topLevelPriorCode)) else None ScopeContext( priorCode = priorCode, outputMarker = marker, - isZIOApp = isZIOApp + isZIOApp = isZIOApp, + topLevel = topLevel, + topLevelPriorCode = topLevelPriorCode ) private def blockMarker( code: String, priorCode: Vector[String], - isZIOApp: Boolean + isZIOApp: Boolean, + topLevelPriorCode: Vector[String] ): String = val md = MessageDigest.getInstance("SHA-256") md.update(code.getBytes("UTF-8")) @@ -220,6 +230,11 @@ private final class CompilerServiceAdapter( md.update(p.getBytes("UTF-8")) md.update(0.toByte) } + md.update(1.toByte) // separator between priorCode and topLevelPriorCode + topLevelPriorCode.foreach { p => + md.update(p.getBytes("UTF-8")) + md.update(0.toByte) + } md.update((if isZIOApp then 1 else 0).toByte) val hex = md.digest().take(16).map(b => f"$b%02x").mkString s"__MARKLIT_${hex}__" @@ -254,11 +269,16 @@ private final class CompilerServiceAdapter( scalaVersion: Option[String], location: Option[Location], scopeConfig: ScopeConfig, - scopeMode: ScopeMode + scopeMode: ScopeMode, + topLevel: Boolean, + topLevelPriorCode: Vector[String] ): IO[MarklitError, CompileResult] = val invoke = compilerFor(scalaVersion).flatMap( - _.compile(code, buildContext(code, priorCode, isZIOApp)) + _.compile( + code, + buildContext(code, priorCode, isZIOApp, topLevel, topLevelPriorCode) + ) ) location match case None => invoke @@ -277,7 +297,9 @@ private final class CompilerServiceAdapter( startLine = loc.startLine, startColumn = loc.startColumn, scopeConfig = scopeConfig, - scopeMode = scopeMode + scopeMode = scopeMode, + topLevel = topLevel, + topLevelPriorCode = topLevelPriorCode ) cache.get(key).flatMap { case Some(hit) => ZIO.succeed(hit) @@ -289,9 +311,12 @@ private final class CompilerServiceAdapter( priorCode: Vector[String], isZIOApp: Boolean, scalaVersion: Option[String], - classFilesDir: Option[Path] + classFilesDir: Option[Path], + topLevel: Boolean, + topLevelPriorCode: Vector[String] ): IO[MarklitError, String] = - val ctx = buildContext(code, priorCode, isZIOApp) + val ctx = + buildContext(code, priorCode, isZIOApp, topLevel, topLevelPriorCode) compilerFor(scalaVersion).flatMap { c => classFilesDir match case Some(dir) => c.executeFromDir(dir, ctx).map(_.output) diff --git a/compiler/src/main/scala/marklit/compiler/Compiler.scala b/compiler/src/main/scala/marklit/compiler/Compiler.scala index b2a303a..a4f8561 100644 --- a/compiler/src/main/scala/marklit/compiler/Compiler.scala +++ b/compiler/src/main/scala/marklit/compiler/Compiler.scala @@ -11,7 +11,11 @@ final case class ScopeContext( scalacOptions: Vector[String] = Vector.empty, outputMarker: Option[String] = None, // UUID marker to identify where new output starts - isZIOApp: Boolean = false // Wrap in ZIOAppDefault instead of plain object + isZIOApp: Boolean = false, // Wrap in ZIOAppDefault instead of plain object + topLevel: Boolean = + false, // Compile this block verbatim as its own compilation unit + topLevelPriorCode: Vector[String] = + Vector.empty // Inherited definitions hoisted ABOVE the wrapper ): def append(code: String): ScopeContext = copy(priorCode = priorCode :+ code) @@ -19,6 +23,13 @@ final case class ScopeContext( def allCode: String = priorCode.mkString("\n\n") + /** Inherited top-level definitions, joined, to emit at file scope (above the + * `MarklitWrapper` object for normal blocks, or as the whole unit for + * top-level blocks). + */ + def hoistedCode: String = + topLevelPriorCode.mkString("\n\n") + object ScopeContext: val empty: ScopeContext = ScopeContext() diff --git a/compiler/src/main/scala/marklit/compiler/ScalaCompiler.scala b/compiler/src/main/scala/marklit/compiler/ScalaCompiler.scala index 6eaed14..41e6dbf 100644 --- a/compiler/src/main/scala/marklit/compiler/ScalaCompiler.scala +++ b/compiler/src/main/scala/marklit/compiler/ScalaCompiler.scala @@ -53,20 +53,10 @@ final class ScalaCompiler( ): IO[MarklitError, CompileResult] = ZIO .attempt { - val marker = context.outputMarker.getOrElse("") - - // For ZIO blocks, we need to handle markers differently since plain - // print() happens at effect construction time, not execution time. - val (markerCode, codeWithMarker) = - if context.isZIOApp && marker.nonEmpty then - ("", s"""zio.ZIO.succeed(print("$marker")) *> {\n$code\n}""") - else - val mc = if marker.nonEmpty then s"""print("$marker")\n""" else "" - (mc, code) - - val fullCode = - if context.priorCode.isEmpty then s"$markerCode$codeWithMarker" - else s"${context.allCode}\n\n$markerCode$codeWithMarker" + // Inherited top-level definitions (e.g. an `enum`) are emitted at file + // scope — above the wrapper for normal blocks, or as the entire unit + // for a top-level block. Empty for the common case. + val hoist = context.hoistedCode // Per-block output directory. Every compile gets its own subdir so // dotc's `MarklitWrapper$.class` from one block doesn't clobber the @@ -75,10 +65,38 @@ final class ScalaCompiler( val perBlockOut = Files.createTempDirectory(outputDir, "block-") val sourceFile = Files.createTempFile(perBlockOut, "marklit_", ".scala") try - val wrappedCode = - if context.isZIOApp then wrapInZIOApp(fullCode) - else wrapInObject(fullCode) - Files.writeString(sourceFile, wrappedCode) + val sourceCode = + if context.topLevel then + // Top-level blocks compile verbatim as their own compilation + // unit: no wrapper, and no `print(marker)` (a bare statement is + // illegal at file scope, and top-level blocks never execute). + if hoist.isEmpty then code else s"$hoist\n\n$code" + else + val marker = context.outputMarker.getOrElse("") + + // For ZIO blocks, we handle markers differently since plain + // print() happens at effect construction time, not execution. + val (markerCode, codeWithMarker) = + if context.isZIOApp && marker.nonEmpty then + ("", s"""zio.ZIO.succeed(print("$marker")) *> {\n$code\n}""") + else + val mc = + if marker.nonEmpty then s"""print("$marker")\n""" else "" + (mc, code) + + val bodyCode = + if context.priorCode.isEmpty then s"$markerCode$codeWithMarker" + else s"${context.allCode}\n\n$markerCode$codeWithMarker" + + val wrapped = + if context.isZIOApp then wrapInZIOApp(bodyCode) + else wrapInObject(bodyCode) + + // Hoisted top-level definitions go ABOVE the wrapper object so + // they live at file scope; the executable body stays in `run()`. + if hoist.isEmpty then wrapped else s"$hoist\n\n$wrapped" + + Files.writeString(sourceFile, sourceCode) val effectiveClasspath = (classpath ++ context.classpath).toList val effectiveOpts = diff --git a/compiler/src/test/scala/marklit/MarklitSpec.scala b/compiler/src/test/scala/marklit/MarklitSpec.scala index 8b7ad67..7fd26a7 100644 --- a/compiler/src/test/scala/marklit/MarklitSpec.scala +++ b/compiler/src/test/scala/marklit/MarklitSpec.scala @@ -346,6 +346,158 @@ object MarklitSpec extends ZIOSpecDefault: ) } ).provide(Marklit.layer), + suite("top-level (real compiler, isolated)")( + test( + "top-level block: enum + match renders code-only with NO local-class warning" + ) { + // The motivating case. A normal block would warn ("the type test ... + // cannot be checked at runtime because it's a local class") under the + // wrapper; a top-level block hoists the enum to file scope, so the + // warning is gone — and being compile-only, it renders just the code. + val content = + """```scala marklit:top-level + |enum CounterAction: + | case Inc + | case Set(v: Int) + | + |val a: Any = CounterAction.Set(10) + |val label = a match + | case CounterAction.Set(v) => s"set $v" + | case _ => "other" + |``` + |""".stripMargin + for + result <- Marklit.processContent(content, "test.md") + rendered = marklit.renderer.MarkdownRenderer.render( + result.document, + result.processingResult, + marklit.renderer.RenderConfig.default + ) + yield + val br = result.processingResult.blockResults.head + assertTrue( + result.isSuccess, + br.compileResult.exists(_.success), + // no local-class warning leaked + br.compileResult.forall( + !_.warnings.exists(_.message.contains("local class")) + ), + // compile-only: no execution output, code preserved in render + br.executionOutput.isEmpty, + rendered.contains("enum CounterAction"), + !rendered.contains("local class") + ) + }, + test( + "control: the same enum match WITHOUT top-level warns about a local class" + ) { + // Pins WHY the feature exists: identical source, wrapped, warns. + val content = + """```scala + |enum CounterAction: + | case Inc + | case Set(v: Int) + | + |val a: Any = CounterAction.Set(10) + |val label = a match + | case CounterAction.Set(v) => s"set $v" + | case _ => "other" + |``` + |""".stripMargin + for result <- Marklit.processContent(content, "test.md") + yield + val br = result.processingResult.blockResults.head + assertTrue( + br.compileResult.exists( + _.warnings.exists(_.message.contains("local class")) + ) + ) + }, + test( + "top-level definition consumed by a normal executable block: code-only + output" + ) { + // enum defined top-level (id=actions), then a normal block matches on + // it and prints. The normal block compiles WITHOUT the local-class + // warning (the enum is hoisted) and DOES produce output. + val content = + """```scala marklit:top-level,id=actions + |enum CounterAction: + | case Inc + | case Set(v: Int) + |``` + | + |```scala marklit:extends=actions + |val a: Any = CounterAction.Set(10) + |a match + | case CounterAction.Set(v) => println(s"set $v") + | case _ => println("other") + |``` + |""".stripMargin + for + result <- Marklit.processContent(content, "test.md") + rendered = marklit.renderer.MarkdownRenderer.render( + result.document, + result.processingResult, + marklit.renderer.RenderConfig.default + ) + yield + val defB = result.processingResult.blockResults(0) + val useB = result.processingResult.blockResults(1) + assertTrue( + result.isSuccess, + // definition is compile-only + defB.executionOutput.isEmpty, + // consumer compiled cleanly (no local-class warning) and ran + useB.compileResult.exists(_.success), + useB.compileResult.forall( + !_.warnings.exists(_.message.contains("local class")) + ), + useB.executionOutput.exists(_.contains("set 10")), + rendered.contains("set 10"), + !rendered.contains("local class") + ) + }, + test( + "top-level combined with a behavioral modifier fails the run" + ) { + val content = + """```scala marklit:top-level,silent + |opaque type C = Int + |``` + |""".stripMargin + for result <- Marklit.processContent(content, "test.md") + yield assertTrue( + !result.isSuccess, + result.processingResult.blockResults.head.error.exists { + case MarklitError.ValidationError(_, msg) => + msg.contains("top-level") + case _ => false + } + ) + }, + test( + "cross-version: a top-level block pinned to a specific version compiles there" + ) { + // opaque type only compiles at the top level; pin it to a specific 3.x + // version to exercise the version-specific top-level path end-to-end. + val v = CompilerFactory.defaultScalaVersion + val content = + s"""```scala marklit:top-level,scala=$v + |opaque type Celsius = Double + |object Celsius: + | def apply(d: Double): Celsius = d + |``` + |""".stripMargin + for result <- Marklit.processContent(content, "test.md") + yield + val br = result.processingResult.blockResults.head + assertTrue( + result.isSuccess, + br.compileResult.exists(_.success), + br.effectiveScalaVersion == Some(v) + ) + } + ).provide(Marklit.layer), suite("page scope (real compiler)")( test("two anonymous page-scoped blocks both produce executionOutput") { // Regression: rendered tour.md showed page-scoped blocks with no @@ -542,6 +694,55 @@ object MarklitSpec extends ZIOSpecDefault: outputs.forall(!_.contains("__MARKLIT_")), outputs.exists(_.contains("answer = 42")) ) + }, + test( + "top-level def + extends consumer composes with --page-scope" + ) { + // Under page scope, anonymous blocks share a run-body chain. A + // top-level definition block opts out (it's hoisted, not run-body), and + // an explicit `extends=` consumer also opts out of the page chain while + // still inheriting the hoisted enum. An anonymous page-scoped block + // sitting between them must not disturb the hoist. + val content = + """```scala marklit:top-level,id=actions + |enum CounterAction: + | case Inc + | case Set(v: Int) + |``` + | + |```scala + |val pageLocal = "page scope still works" + |println(pageLocal) + |``` + | + |```scala marklit:extends=actions + |val a: Any = CounterAction.Set(7) + |a match + | case CounterAction.Set(v) => println(s"set $v") + | case _ => println("other") + |``` + |""".stripMargin + for + result <- Marklit.processContent(content, "test.md") + rendered = marklit.renderer.MarkdownRenderer.render( + result.document, + result.processingResult, + marklit.renderer.RenderConfig.default + ) + yield + val useB = result.processingResult.blockResults(2) + assertTrue( + result.isSuccess, + // consumer inherited the hoisted enum, compiled cleanly, and ran + useB.compileResult.forall( + !_.warnings.exists(_.message.contains("local class")) + ), + useB.executionOutput.exists(_.contains("set 7")), + // page scope unaffected by the top-level block between them + rendered.contains("page scope still works"), + rendered.contains("set 7"), + !rendered.contains("local class") + ) } ).provide(pageScopeLayer) ) @@ TestAspect.sequential @@ TestAspect.timeout( diff --git a/compiler/src/test/scala/marklit/compiler/LocalClassWarnSpec.scala b/compiler/src/test/scala/marklit/compiler/LocalClassWarnSpec.scala new file mode 100644 index 0000000..e3c5cb7 --- /dev/null +++ b/compiler/src/test/scala/marklit/compiler/LocalClassWarnSpec.scala @@ -0,0 +1,68 @@ +package marklit.compiler + +import marklit.model.* +import zio.* +import zio.test.* + +/** Regression coverage for the core value proposition of `top-level`: a + * parameterized enum case matched against a non-local scrutinee type emits + * `the type test for X cannot be checked at runtime because it's a local + * class` when the enum is defined *inside* `def run()` (a local class). When + * the enum is hoisted to file scope (a real top-level class), the type test is + * checkable and the warning disappears. + * + * This is exactly the warning that forced `show-warnings=false` on the conduit + * docs; `top-level` lets authors drop that workaround. Verified against the + * bundled Scala shim. + */ +object LocalClassWarnSpec extends ZIOSpecDefault: + + private def warnings(r: CompileResult): List[String] = + r.diagnostics + .filter(_.severity == DiagnosticSeverity.Warning) + .map(_.message) + + private val localClassWarning = + "cannot be checked at runtime because it's a local class" + + private val enumDef = + """enum CounterAction: + | case Inc + | case Set(v: Int)""".stripMargin + + // Scrutinee static type is the non-local `Any`; matching the parameterized + // case `CounterAction.Set(v)` needs a runtime type test on the enum class. + private val matchBody = + """val a: Any = CounterAction.Set(10) + |val s = a match + | case CounterAction.Set(v) => s"set $v" + | case _ => "other" + |println(s)""".stripMargin + + def spec = suite("LocalClassWarn")( + test( + "wrapped in def run(): the enum is a local class and the match warns" + ) { + val all = enumDef + "\n" + matchBody + for result <- Compiler.compile(all, ScopeContext.empty) + yield assertTrue( + result.success, + warnings(result).exists(_.contains(localClassWarning)) + ) + }, + test( + "enum hoisted as top-level prior code: no local-class warning, and it runs" + ) { + for result <- Compiler.compileAndExecute( + matchBody, + ScopeContext.empty.copy(topLevelPriorCode = Vector(enumDef)) + ) + yield assertTrue( + result._1.success, + !warnings(result._1).exists(_.contains(localClassWarning)), + result._2.output.contains("set 10") + ) + } + ).provideShared(TestCompilerLayer.layer) @@ TestAspect.timeout( + 120.seconds + ) @@ TestAspect.withLiveClock @@ TestAspect.sequential diff --git a/compiler/src/test/scala/marklit/compiler/TopLevelCompileSpec.scala b/compiler/src/test/scala/marklit/compiler/TopLevelCompileSpec.scala new file mode 100644 index 0000000..eed945c --- /dev/null +++ b/compiler/src/test/scala/marklit/compiler/TopLevelCompileSpec.scala @@ -0,0 +1,122 @@ +package marklit.compiler + +import marklit.model.* +import zio.* +import zio.test.* + +/** Compiler-level coverage for the `top-level` feature: blocks that compile + * verbatim as their own compilation unit (no `MarklitWrapper`), and the + * hoisting of inherited top-level definitions above the wrapper for normal + * blocks that depend on them. + */ +object TopLevelCompileSpec extends ZIOSpecDefault: + + // An `opaque type` definition. This is a genuine motivating construct: it is + // a top-level-only modifier and is *rejected* inside a `def` body, so it can + // only be demonstrated at the top level. (Verified empirically against the + // bundled Scala 3.3.7 shim — see the project memory note on which constructs + // actually diverge between the wrapper and file scope.) + private val opaqueSource = + """opaque type Celsius = Double + |object Celsius: + | def apply(d: Double): Celsius = d + | extension (c: Celsius) def value: Double = c + |""".stripMargin + + def spec = suite("ScalaCompiler top-level")( + suite("positive")( + test("opaque type compiles cleanly as a top-level unit") { + val ctx = ScopeContext.empty.copy(topLevel = true) + for result <- Compiler.compile(opaqueSource, ctx) + yield assertTrue( + result.success, + result.errors.isEmpty + ) + }, + test( + "the same opaque type fails when wrapped in a def body (proves the bypass earns its keep)" + ) { + // Control: the non-top-level path wraps code in `def run()`, where an + // `opaque type` modifier is not allowed. This pins *why* top-level + // exists — without the wrapper bypass this source cannot compile. + val ctx = ScopeContext.empty // topLevel = false + for result <- Compiler.compile(opaqueSource, ctx) + yield assertTrue(!result.success) + }, + test( + "a normal block hoists an inherited opaque type and executes against it" + ) { + // The motivating end-to-end shape at the compiler seam: the opaque type + // is hoisted ABOVE the wrapper; the executable body uses it in run(). + val ctx = ScopeContext.empty.copy( + topLevelPriorCode = Vector(opaqueSource) + ) + val body = """val t = Celsius(20.0) + |println(s"temp = ${t.value}")""".stripMargin + for result <- Compiler.compileAndExecute(body, ctx) + yield assertTrue( + result._1.success, + result._2.output.contains("temp = 20.0") + ) + } + ), + suite("negative")( + test("malformed top-level source surfaces a real compile error") { + val ctx = ScopeContext.empty.copy(topLevel = true) + for result <- Compiler.compile("opaque type Broken =", ctx) + yield assertTrue( + !result.success, + result.errors.nonEmpty + ) + }, + test( + "an outputMarker set on a top-level context is NOT injected at file scope" + ) { + // If `print(marker)` were emitted ahead of the code, it would land at + // file scope (illegal) and fail to compile. A clean compile proves the + // marker was correctly omitted for top-level blocks. + val ctx = ScopeContext.empty.copy( + topLevel = true, + outputMarker = Some("__MARKLIT_MARKER__") + ) + for result <- Compiler.compile(opaqueSource, ctx) + yield assertTrue(result.success) + } + ), + suite("pathological")( + test("comment-only top-level code compiles") { + val ctx = ScopeContext.empty.copy(topLevel = true) + for result <- Compiler.compile("// just a comment", ctx) + yield assertTrue(result.success) + }, + test("hoisted code with CRLF and trailing whitespace still compiles") { + val enumDef = + "enum Color:\r\n case Red, Green, Blue \r\n" + val ctx = ScopeContext.empty.copy( + topLevelPriorCode = Vector(enumDef) + ) + for result <- Compiler.compile("val c: Color = Color.Green", ctx) + yield assertTrue(result.success) + }, + test( + "multiple hoisted top-level fragments are all visible to the body" + ) { + val colorDef = "enum Color:\n case Red, Green, Blue" + val sizeDef = "enum Size:\n case Small, Large" + val ctx = ScopeContext.empty.copy( + topLevelPriorCode = Vector(colorDef, sizeDef) + ) + val body = + """val c: Color = Color.Red + |val s: Size = Size.Small + |println(s"$c $s")""".stripMargin + for result <- Compiler.compileAndExecute(body, ctx) + yield assertTrue( + result._1.success, + result._2.output.contains("Red Small") + ) + } + ) + ).provideShared(TestCompilerLayer.layer) @@ TestAspect.timeout( + 120.seconds + ) @@ TestAspect.withLiveClock @@ TestAspect.sequential diff --git a/core/src/main/scala/marklit/cache/BlockCache.scala b/core/src/main/scala/marklit/cache/BlockCache.scala index 79f39a2..b989f4a 100644 --- a/core/src/main/scala/marklit/cache/BlockCache.scala +++ b/core/src/main/scala/marklit/cache/BlockCache.scala @@ -178,19 +178,26 @@ object BlockCacheKey: startLine: Int, startColumn: Int, scopeConfig: ScopeConfig = ScopeConfig.empty, - scopeMode: ScopeMode = ScopeMode.Isolated + scopeMode: ScopeMode = ScopeMode.Isolated, + topLevel: Boolean = false, + topLevelPriorCode: Vector[String] = Vector.empty ): BlockCacheKey = val md = MessageDigest.getInstance("SHA-256") def feed(s: String): Unit = md.update(s.getBytes(java.nio.charset.StandardCharsets.UTF_8)) md.update(0.toByte) // Version prefix — bump on key-shape changes to invalidate old entries. - feed("v2") + feed("v3") feed(scalaVersion) feed(file) feed(startLine.toString) feed(startColumn.toString) feed(if isZIOApp then "z" else "n") + feed(if topLevel then "t" else "w") + feed("hoist") + md.update(topLevelPriorCode.size.toString.getBytes("UTF-8")) + md.update(0.toByte) + topLevelPriorCode.foreach(feed) feed("scalac") scalacOptions.foreach(feed) feed("cp") diff --git a/core/src/main/scala/marklit/model/CodeBlock.scala b/core/src/main/scala/marklit/model/CodeBlock.scala index f8d2add..d2029e2 100644 --- a/core/src/main/scala/marklit/model/CodeBlock.scala +++ b/core/src/main/scala/marklit/model/CodeBlock.scala @@ -15,12 +15,39 @@ final case class CodeBlock( def showWarnings(default: Boolean): Boolean = showWarningsOverride.getOrElse(default) + /** Whether this block compiles verbatim as its own compilation unit (no + * `MarklitWrapper` object). Top-level blocks are always compile-only. + */ + def isTopLevel: Boolean = modifiers.contains(Modifier.TopLevel) + /** Whether this block should be executed */ def shouldExecute: Boolean = - !modifiers.contains(Modifier.CompileOnly) && + !isTopLevel && + !modifiers.contains(Modifier.CompileOnly) && !modifiers.contains(Modifier.Fail) && !modifiers.contains(Modifier.Passthrough) + /** For a `top-level` block, the set of other modifiers it was combined with — + * `top-level` is strict and may only accompany scope options + * (`id`/`extends`/`append`), version selectors, and `show-warnings`, none of + * which live in [[modifiers]]. Any *other* modifier here is a misuse. + * + * Returns `Some(message)` describing the conflict, or `None` when the block + * is either not top-level or top-level with no conflicting modifiers. + */ + def modifierConflicts: Option[String] = + if !isTopLevel then None + else + val others = (modifiers - Modifier.TopLevel).toList + if others.isEmpty then None + else + val names = others.map(Modifier.displayName).sorted.mkString(", ") + Some( + s"'top-level' cannot be combined with: $names. " + + "top-level blocks are compile-only and may only use scope options " + + "(id/extends/append), a Scala version, and show-warnings." + ) + /** Whether this block expects compilation to fail */ def expectsFailure: Boolean = modifiers.contains(Modifier.Fail) diff --git a/core/src/main/scala/marklit/model/Modifier.scala b/core/src/main/scala/marklit/model/Modifier.scala index 053b833..01debee 100644 --- a/core/src/main/scala/marklit/model/Modifier.scala +++ b/core/src/main/scala/marklit/model/Modifier.scala @@ -14,11 +14,13 @@ enum Modifier: case SharedMajor( major: String ) // Prepend to default scopes for that Scala major + case TopLevel // Compile verbatim as its own compilation unit (no wrapper) object Modifier: /** Parse a modifier from its string representation. Shared / SharedMajor are - * not parsed here — they're constructed by [[marklit.parser.InfoStringParser]] - * from the `scala=shared` / `scala=shared-{mv}` info-string forms. + * not parsed here — they're constructed by + * [[marklit.parser.InfoStringParser]] from the `scala=shared` / + * `scala=shared-{mv}` info-string forms. */ def parse(s: String): Option[Modifier] = s.toLowerCase match case "silent" => Some(Silent) @@ -29,4 +31,21 @@ object Modifier: case "compile-only" => Some(CompileOnly) case "passthrough" => Some(Passthrough) case "zio-app" => Some(ZIOApp) + case "top-level" => Some(TopLevel) case _ => None + + /** Human-readable name for a modifier, matching its info-string spelling. + * Used in diagnostics (e.g. [[marklit.model.CodeBlock.modifierConflicts]]). + */ + def displayName(m: Modifier): String = m match + case Silent => "silent" + case Invisible => "invisible" + case Fail => "fail" + case Warn => "warn" + case Crash => "crash" + case CompileOnly => "compile-only" + case Passthrough => "passthrough" + case ZIOApp => "zio-app" + case Shared => "scala=shared" + case SharedMajor(m) => s"scala=shared-$m" + case TopLevel => "top-level" diff --git a/core/src/main/scala/marklit/parser/InfoStringParser.scala b/core/src/main/scala/marklit/parser/InfoStringParser.scala index 8ed2644..a9312c1 100644 --- a/core/src/main/scala/marklit/parser/InfoStringParser.scala +++ b/core/src/main/scala/marklit/parser/InfoStringParser.scala @@ -118,8 +118,7 @@ object InfoStringParser: case "shared" => modifiers += Modifier.Shared case other if other.startsWith("shared-") => val mv = other.stripPrefix("shared-") - if mv.nonEmpty then - modifiers += Modifier.SharedMajor(mv) + if mv.nonEmpty then modifiers += Modifier.SharedMajor(mv) case _ => scalaVersion = Some(value) case "show-warnings" => showWarningsOverride = parseBool(value) case _ => () // Ignore unknown keys diff --git a/core/src/main/scala/marklit/processor/DocumentProcessor.scala b/core/src/main/scala/marklit/processor/DocumentProcessor.scala index 3eb94d4..a2cce46 100644 --- a/core/src/main/scala/marklit/processor/DocumentProcessor.scala +++ b/core/src/main/scala/marklit/processor/DocumentProcessor.scala @@ -57,9 +57,7 @@ final case class BlockResult( def isSuccess: Boolean = if skipped then true else if crossExecutions.nonEmpty then - crossExecutions.forall(x => - isExecutionSuccess(x.compileResult, x.error) - ) + crossExecutions.forall(x => isExecutionSuccess(x.compileResult, x.error)) else isExecutionSuccess(compileResult, error) /** Result of processing an entire document */ @@ -70,8 +68,8 @@ final case class DocumentResult( def isSuccess: Boolean = blockResults.forall(_.isSuccess) /** Returns unexpected errors (excludes expected RuntimeErrors from crash - * blocks). Walks per-version cross-executions for shared blocks so a - * failure on any single cross version is surfaced. + * blocks). Walks per-version cross-executions for shared blocks so a failure + * on any single cross version is surfaced. */ def errors: Vector[(CodeBlock, MarklitError)] = blockResults.flatMap { br => @@ -88,7 +86,8 @@ final case class DocumentResult( def compileErrors: Vector[(CodeBlock, List[ScalaDiagnostic])] = blockResults.flatMap { br => val crs = - if br.crossExecutions.nonEmpty then br.crossExecutions.flatMap(_.compileResult) + if br.crossExecutions.nonEmpty then + br.crossExecutions.flatMap(_.compileResult) else br.compileResult.toVector crs.filterNot(_.success).map(cr => (br.block, cr.diagnostics)) } @@ -136,7 +135,9 @@ trait CompilerService: scalaVersion: Option[String], location: Option[Location], scopeConfig: ScopeConfig = ScopeConfig.empty, - scopeMode: ScopeMode = ScopeMode.Isolated + scopeMode: ScopeMode = ScopeMode.Isolated, + topLevel: Boolean = false, + topLevelPriorCode: Vector[String] = Vector.empty ): IO[MarklitError, CompileResult] def execute( @@ -144,7 +145,9 @@ trait CompilerService: priorCode: Vector[String], isZIOApp: Boolean, scalaVersion: Option[String], - classFilesDir: Option[java.nio.file.Path] + classFilesDir: Option[java.nio.file.Path], + topLevel: Boolean = false, + topLevelPriorCode: Vector[String] = Vector.empty ): IO[MarklitError, String] /** Live implementation that processes blocks sequentially, respecting scope @@ -199,7 +202,8 @@ final class DocumentProcessorLive( val isAnon = cfg.id.isEmpty && cfg.extendsScope.isEmpty && !cfg.append val opaque = block.isPassthrough || block.isAnyShared || - block.expectsFailure || block.expectsCrash || block.expectsWarnings + block.expectsFailure || block.expectsCrash || + block.expectsWarnings || block.isTopLevel if !isAnon || opaque then (acc :+ block, initialized) else effectiveVersionFor(block) match @@ -227,6 +231,7 @@ final class DocumentProcessorLive( (blocks.iterator.collect { case b if !b.isPassthrough && + !b.isTopLevel && b.scopeConfig.id.isEmpty && b.scopeConfig.extendsScope.isEmpty && !b.scopeConfig.append => @@ -284,6 +289,26 @@ final class DocumentProcessorLive( private def processBlock( block: CodeBlock, versionsInUse: Vector[String] + ): IO[MarklitError, BlockResult] = + // `top-level` is strict: it may only accompany scope options and a version + // selector. Reject illegal combinations (e.g. top-level,silent) up front — + // the error flows through the same path as a compile/runtime failure and + // fails the run via DocumentResult.errors. + block.modifierConflicts match + case Some(msg) => + ZIO.succeed( + BlockResult( + block, + None, + None, + Some(MarklitError.ValidationError(block.location, msg)) + ) + ) + case None => processBlockChecked(block, versionsInUse) + + private def processBlockChecked( + block: CodeBlock, + versionsInUse: Vector[String] ): IO[MarklitError, BlockResult] = // Resolve the version this block compiles against. Precedence: // 1. Per-block specific version (e.g. `scala=3.7.0`) — exact request. @@ -331,23 +356,34 @@ final class DocumentProcessorLive( processSharedBlock(block, effectiveVersion, versionsInUse) else val effect = for - // Resolve scope and get inherited code + // Resolve scope and get inherited code, split into hoisted top-level + // definitions (emitted at file scope) and run-body prior code. resolved <- scopeManager.resolveScope( block.scopeConfig, block.location, - Some(effectiveVersion) + Some(effectiveVersion), + block.isTopLevel ) - allPriorCode = resolved.inheritedCode ++ resolved.scope.priorCode + // The resolved scope's own priorCode is folded into the correct bucket + // by hoistCode/bodyCode based on the scope's kind. + hoistCode = resolved.hoistCode + bodyCode = resolved.bodyCode // Compile - compileResult <- compileBlock(block, allPriorCode, requestedVersion) + compileResult <- compileBlock( + block, + bodyCode, + requestedVersion, + hoistCode + ) // Execute if compilation succeeded and block should execute execResult <- executeBlock( block, - allPriorCode, + bodyCode, compileResult, - requestedVersion + requestedVersion, + hoistCode ) // Record code in scope for subsequent blocks. Skip fail/crash blocks @@ -439,23 +475,26 @@ final class DocumentProcessorLive( private def compileBlock( block: CodeBlock, priorCode: Vector[String], - requestedVersion: Option[String] + requestedVersion: Option[String], + topLevelPriorCode: Vector[String] = Vector.empty ): IO[MarklitError, CompileResult] = val v = requestedVersion val loc = Some(block.location) + def doCompile: IO[MarklitError, CompileResult] = + compiler.compile( + block.code, + priorCode, + block.isZIOApp, + v, + loc, + block.scopeConfig, + scopeMode, + block.isTopLevel, + topLevelPriorCode + ) if block.expectsFailure then // For fail blocks, we expect compilation to fail - compiler - .compile( - block.code, - priorCode, - block.isZIOApp, - v, - loc, - block.scopeConfig, - scopeMode - ) - .either + doCompile.either .map { case Left(_) => // Expected failure - treat as success @@ -480,16 +519,7 @@ final class DocumentProcessorLive( } else if block.expectsWarnings then // For warn blocks, we expect compilation warnings - compiler - .compile( - block.code, - priorCode, - block.isZIOApp, - v, - loc, - block.scopeConfig, - scopeMode - ) + doCompile .map { cr => val hasWarnings = cr.warnings.nonEmpty if hasWarnings then @@ -510,16 +540,7 @@ final class DocumentProcessorLive( ) ) } - else - compiler.compile( - block.code, - priorCode, - block.isZIOApp, - v, - loc, - block.scopeConfig, - scopeMode - ) + else doCompile /** Result of execution, including both output and any captured error */ private case class ExecResult( @@ -531,16 +552,29 @@ final class DocumentProcessorLive( block: CodeBlock, priorCode: Vector[String], compileResult: CompileResult, - requestedVersion: Option[String] + requestedVersion: Option[String], + topLevelPriorCode: Vector[String] = Vector.empty ): IO[MarklitError, ExecResult] = val v = requestedVersion val dir = compileResult.classFilesDir + // top-level blocks are compile-only (shouldExecute == false), so we never + // reach the execute calls for them. A *normal* block that hoists top-level + // definitions does execute, and must pass the same topLevelPriorCode so the + // output marker (which hashes it) matches the one baked in at compile time. + def doExecute = + compiler.execute( + block.code, + priorCode, + block.isZIOApp, + v, + dir, + block.isTopLevel, + topLevelPriorCode + ) if compileResult.success && block.shouldExecute then if block.expectsCrash then // For crash blocks, expect runtime exception - capture it for display - compiler - .execute(block.code, priorCode, block.isZIOApp, v, dir) - .either + doExecute.either .map { case Left(err @ MarklitError.RuntimeError(_, output)) => ExecResult( @@ -557,10 +591,7 @@ final class DocumentProcessorLive( None ) } - else - compiler - .execute(block.code, priorCode, block.isZIOApp, v, dir) - .map(out => ExecResult(Some(out), None)) + else doExecute.map(out => ExecResult(Some(out), None)) else ZIO.succeed(ExecResult(None, None)) object DocumentProcessorLive: diff --git a/core/src/main/scala/marklit/renderer/MarkdownRenderer.scala b/core/src/main/scala/marklit/renderer/MarkdownRenderer.scala index 52af5bf..1fff49e 100644 --- a/core/src/main/scala/marklit/renderer/MarkdownRenderer.scala +++ b/core/src/main/scala/marklit/renderer/MarkdownRenderer.scala @@ -95,64 +95,64 @@ object MarkdownRenderer: ver: Option[String], config: RenderConfig ): Unit = - // For fail blocks, show the expected compilation errors - if block.expectsFailure then - br.compileResult.foreach { cr => - if cr.diagnostics.nonEmpty && config.showCompileErrors then - renderDiagnostics( - sb, - cr.diagnostics.filter(_.severity == DiagnosticSeverity.Error), - config, - ver - ) + // For fail blocks, show the expected compilation errors + if block.expectsFailure then + br.compileResult.foreach { cr => + if cr.diagnostics.nonEmpty && config.showCompileErrors then + renderDiagnostics( + sb, + cr.diagnostics.filter(_.severity == DiagnosticSeverity.Error), + config, + ver + ) + } + // For warn blocks, show the expected warnings + else if block.expectsWarnings then + br.compileResult.foreach { cr => + val warnings = + cr.diagnostics.filter(_.severity == DiagnosticSeverity.Warning) + if warnings.nonEmpty && config.showCompileErrors then + renderDiagnostics(sb, warnings, config, ver) + } + // Also show normal output if any + if block.showOutput then + br.executionOutput.filter(_.nonEmpty).foreach { output => + renderOutput(sb, output, config, ver) } - // For warn blocks, show the expected warnings - else if block.expectsWarnings then + // For crash blocks, show the exception + else if block.expectsCrash then + br.error.foreach { + case MarklitError.RuntimeError(ex, output) + if config.showRuntimeErrors => + if output.nonEmpty then renderOutput(sb, output, config, ver) + sb.append("```\n") + if config.showScalaVersion then + ver.foreach(v => sb.append(s"// Scala $v\n")) + sb.append( + s"${config.errorPrefix}Exception: ${ex.getClass.getSimpleName}: ${ex.getMessage}\n" + ) + sb.append("```\n") + case _ => () + } + // For normal blocks, show output if configured + else if block.showOutput then + // Handle compile errors + if !br.compileResult.exists(_.success) && config.showCompileErrors then br.compileResult.foreach { cr => - val warnings = - cr.diagnostics.filter(_.severity == DiagnosticSeverity.Warning) - if warnings.nonEmpty && config.showCompileErrors then - renderDiagnostics(sb, warnings, config, ver) - } - // Also show normal output if any - if block.showOutput then - br.executionOutput.filter(_.nonEmpty).foreach { output => - renderOutput(sb, output, config, ver) - } - // For crash blocks, show the exception - else if block.expectsCrash then - br.error.foreach { - case MarklitError.RuntimeError(ex, output) - if config.showRuntimeErrors => - if output.nonEmpty then renderOutput(sb, output, config, ver) - sb.append("```\n") - if config.showScalaVersion then - ver.foreach(v => sb.append(s"// Scala $v\n")) - sb.append( - s"${config.errorPrefix}Exception: ${ex.getClass.getSimpleName}: ${ex.getMessage}\n" - ) - sb.append("```\n") - case _ => () + renderDiagnostics(sb, cr.errors, config, ver) } - // For normal blocks, show output if configured - else if block.showOutput then - // Handle compile errors - if !br.compileResult.exists(_.success) && config.showCompileErrors then + // Handle execution output (and compile warnings if enabled) + else + if block.showWarnings(config.showCompileWarnings) then br.compileResult.foreach { cr => - renderDiagnostics(sb, cr.errors, config, ver) - } - // Handle execution output (and compile warnings if enabled) - else - if block.showWarnings(config.showCompileWarnings) then - br.compileResult.foreach { cr => - val warnings = - cr.diagnostics.filter(_.severity == DiagnosticSeverity.Warning) - if warnings.nonEmpty then - renderDiagnostics(sb, warnings, config, ver) - } - br.executionOutput.filter(_.nonEmpty).foreach { output => - renderOutput(sb, output, config, ver) + val warnings = + cr.diagnostics.filter(_.severity == DiagnosticSeverity.Warning) + if warnings.nonEmpty then + renderDiagnostics(sb, warnings, config, ver) } + br.executionOutput.filter(_.nonEmpty).foreach { output => + renderOutput(sb, output, config, ver) + } private def renderOutput( sb: StringBuilder, @@ -188,10 +188,10 @@ object MarkdownRenderer: sb.append("```\n") /** Render a `scala=shared` / `scala=shared-{mv}` block's per-version - * executions. When every execution succeeded with identical stdout, render - * a single output block headed by `// All cross versions: Scala v1, v2, …`. - * Otherwise emit one labeled output (or compile-error / runtime-error) - * block per execution, in the order versions appear in [[crossExecutions]]. + * executions. When every execution succeeded with identical stdout, render a + * single output block headed by `// All cross versions: Scala v1, v2, …`. + * Otherwise emit one labeled output (or compile-error / runtime-error) block + * per execution, in the order versions appear in [[crossExecutions]]. */ private def renderCrossExecutions( sb: StringBuilder, @@ -227,7 +227,9 @@ object MarkdownRenderer: if block.showWarnings(config.showCompileWarnings) then x.compileResult.foreach { cr => val warnings = - cr.diagnostics.filter(_.severity == DiagnosticSeverity.Warning) + cr.diagnostics.filter( + _.severity == DiagnosticSeverity.Warning + ) if warnings.nonEmpty then renderDiagnostics(sb, warnings, config, ver) } diff --git a/core/src/main/scala/marklit/scope/ScopeManager.scala b/core/src/main/scala/marklit/scope/ScopeManager.scala index 110fb92..08e6f15 100644 --- a/core/src/main/scala/marklit/scope/ScopeManager.scala +++ b/core/src/main/scala/marklit/scope/ScopeManager.scala @@ -3,12 +3,21 @@ package marklit.scope import marklit.model.* import zio.* -/** A scope node in the scope tree */ +/** A scope node in the scope tree. + * + * `topLevel` marks a scope whose code is compiled at file scope (no + * `MarklitWrapper` wrapper). Each scope is single-kind: top-level scopes hold + * only top-level definitions, normal scopes hold run-body code. A top-level + * scope can only be extended/appended by another top-level scope; a normal + * scope may *extend* (but not append to) a top-level scope, hoisting its + * definitions above the wrapper. + */ final case class Scope( id: String, scalaVersion: Option[String], priorCode: Vector[String], - parent: Option[String] + parent: Option[String], + topLevel: Boolean = false ): def appendCode(code: String): Scope = copy(priorCode = priorCode :+ code) @@ -45,15 +54,44 @@ object Scope: def named( id: String, scalaVersion: Option[String] = None, - parent: Option[String] = None + parent: Option[String] = None, + topLevel: Boolean = false ): Scope = - Scope(id, scalaVersion, Vector.empty, parent) - -/** Result of resolving a scope for a code block */ + Scope(id, scalaVersion, Vector.empty, parent, topLevel) + +/** Result of resolving a scope for a code block. + * + * `inheritedTopLevel` / `inheritedBody` are the ancestor code split by kind: + * code from top-level ancestor scopes (hoisted above the wrapper) vs. code + * from normal ancestor scopes (run-body). Because a top-level scope can only + * be extended by another top-level scope, top-level ancestors always form a + * prefix of the chain, so `inheritedTopLevel ++ inheritedBody` preserves the + * original ancestor order. + */ final case class ResolvedScope( scope: Scope, - inheritedCode: Vector[String] + inheritedTopLevel: Vector[String], + inheritedBody: Vector[String] ): + /** Flat inherited code in ancestor order (top-level ancestors first). Kept + * for callers that don't care about hoist placement. + */ + def inheritedCode: Vector[String] = inheritedTopLevel ++ inheritedBody + + /** Definitions to emit at file scope: inherited top-level ancestor code, plus + * this scope's own code when it is itself a top-level scope. + */ + def hoistCode: Vector[String] = + if scope.topLevel then inheritedTopLevel ++ scope.priorCode + else inheritedTopLevel + + /** Code to emit in the wrapper body: inherited normal-ancestor code, plus + * this scope's own code when it is a normal scope. + */ + def bodyCode: Vector[String] = + if scope.topLevel then inheritedBody + else inheritedBody ++ scope.priorCode + /** All code in order: inherited from ancestors + this scope's accumulated * code */ @@ -72,7 +110,8 @@ trait ScopeManager: def resolveScope( config: ScopeConfig, location: Location, - effectiveVersion: Option[String] = None + effectiveVersion: Option[String] = None, + topLevel: Boolean = false ): IO[MarklitError, ResolvedScope] /** Seed a per-version default scope's prior code (used to inject `shared` / @@ -97,10 +136,11 @@ object ScopeManager: def resolveScope( config: ScopeConfig, location: Location, - effectiveVersion: Option[String] = None + effectiveVersion: Option[String] = None, + topLevel: Boolean = false ): ZIO[ScopeManager, MarklitError, ResolvedScope] = ZIO.serviceWithZIO[ScopeManager]( - _.resolveScope(config, location, effectiveVersion) + _.resolveScope(config, location, effectiveVersion, topLevel) ) def seedDefaultPriorCode( @@ -176,7 +216,8 @@ private final class ScopeManagerLive(stateRef: Ref[ScopeState]) override def resolveScope( config: ScopeConfig, location: Location, - effectiveVersion: Option[String] + effectiveVersion: Option[String], + topLevel: Boolean ): IO[MarklitError, ResolvedScope] = // First validate the config ZIO @@ -185,7 +226,13 @@ private final class ScopeManagerLive(stateRef: Ref[ScopeState]) .flatMap { validConfig => stateRef .modify { state => - resolveInternal(validConfig, location, state, effectiveVersion) + resolveInternal( + validConfig, + location, + state, + effectiveVersion, + topLevel + ) } .flatMap { case Left(error) => ZIO.fail(error) @@ -210,62 +257,117 @@ private final class ScopeManagerLive(stateRef: Ref[ScopeState]) config: ScopeConfig, location: Location, state: ScopeState, - effectiveVersion: Option[String] + effectiveVersion: Option[String], + topLevel: Boolean ): (Either[MarklitError, ResolvedScope], ScopeState) = + // A block and the scope it resolves to must agree on kind: a top-level + // scope holds only top-level definitions (hoisted to file scope), a normal + // scope holds run-body code. Reusing an `id` across kinds, or appending + // across kinds, is illegal — they cannot share a single compilation form. + def kindMismatch(scopeId: String): String = + val (blockKind, scopeKind) = + if topLevel then ("top-level", "non-top-level") + else ("non-top-level", "top-level") + s"Scope '$scopeId' is $scopeKind; cannot use it from a $blockKind block " + + "(top-level and run-body scopes cannot be mixed)" + + // A NORMAL block may *extend* a top-level scope (hoisting its definitions); + // any other cross-kind relationship is rejected. `forAppend = true` + // tightens this to forbid even the extend case, since append mutates the + // parent and would mix kinds in one scope. + def crossKindError( + parent: Scope, + forAppend: Boolean + ): Option[MarklitError] = + val allowed = + parent.topLevel == topLevel || + (parent.topLevel && !topLevel && !forAppend) + if allowed then None + else Some(MarklitError.ValidationError(location, kindMismatch(parent.id))) + + // Re-using an existing `id=` must match the scope's kind *exactly* — unlike + // the extends relaxation above, you cannot redeclare a top-level scope as a + // normal one (or vice-versa); the scope's single compilation form is fixed + // at creation. + def sameKindError(existing: Scope): Option[MarklitError] = + if existing.topLevel == topLevel then None + else + Some(MarklitError.ValidationError(location, kindMismatch(existing.id))) + (config.id, config.extendsScope, config.append) match // No scope config - fresh anonymous scope per block (isolated by - // default, per the README's "Scopes — explicit by default" model). The - // per-version default scope (`__default__`) is treated as a - // read-only parent that holds the file's seeded `shared` / `shared-{mv}` - // blocks; the new anonymous scope inherits that code so opt-in helpers - // still flow into every block, but two anonymous blocks no longer see - // each other's definitions. + // default, per the README's "Scopes — explicit by default" model). + // + // Normal blocks parent to the per-version default scope (`__default__ + // `), inheriting its seeded `shared` / `shared-{mv}` code. + // Top-level blocks must NOT inherit that run-body code (illegal at file + // scope), so an anonymous top-level block gets a parentless isolated + // scope instead. case (None, None, false) => - val (defaultScope, stateWithDefault) = effectiveVersion match - case Some(v) => - val id = Scope.defaultIdFor(v) - state.getScope(id) match - case Some(s) => (s, state) - case None => - val ns = Scope.defaultFor(v) - (ns, state.addScope(ns)) - case None => - val s = state.getScope(Scope.defaultId).getOrElse(Scope.default) - (s, state) - val (stateWithId, anonId) = stateWithDefault.nextAnonymousId - val newScope = - Scope.named(anonId, effectiveVersion, Some(defaultScope.id)) - val newState = stateWithId.addScope(newScope) - val inherited = collectInheritedCode(defaultScope.id, stateWithId) - (Right(ResolvedScope(newScope, inherited)), newState) + if topLevel then + val (stateWithId, anonId) = state.nextAnonymousId + val newScope = + Scope.named(anonId, effectiveVersion, None, topLevel = true) + val newState = stateWithId.addScope(newScope) + (Right(ResolvedScope(newScope, Vector.empty, Vector.empty)), newState) + else + val (defaultScope, stateWithDefault) = effectiveVersion match + case Some(v) => + val id = Scope.defaultIdFor(v) + state.getScope(id) match + case Some(s) => (s, state) + case None => + val ns = Scope.defaultFor(v) + (ns, state.addScope(ns)) + case None => + val s = state.getScope(Scope.defaultId).getOrElse(Scope.default) + (s, state) + val (stateWithId, anonId) = stateWithDefault.nextAnonymousId + val newScope = + Scope.named(anonId, effectiveVersion, Some(defaultScope.id)) + val newState = stateWithId.addScope(newScope) + val (tl, body) = collectInheritedCode(defaultScope.id, stateWithId) + (Right(ResolvedScope(newScope, tl, body)), newState) // id=foo - create or get named scope case (Some(id), None, false) => state.getScope(id) match case Some(existing) => - // A subsequent block re-using `id=foo` must agree on the Scala - // version. The version we record on a scope at creation is the - // block's *requested* version (config.scalaVersion); we compare - // against that here, not against `effectiveVersion`, so that - // bare-major filters (e.g. `scala=3`) and absent values stay - // compatible with each other. - (config.scalaVersion, existing.scalaVersion) match - case (Some(reqV), Some(existingV)) if reqV != existingV => - ( - Left( - MarklitError.ValidationError( - location, - s"Scope '$id' was previously declared with Scala $existingV; cannot reuse with Scala $reqV" + sameKindError(existing) match + case Some(err) => (Left(err), state) + case None => + // A subsequent block re-using `id=foo` must agree on the Scala + // version. The version we record on a scope at creation is the + // block's *requested* version (config.scalaVersion); we compare + // against that here, not against `effectiveVersion`, so that + // bare-major filters (e.g. `scala=3`) and absent values stay + // compatible with each other. + (config.scalaVersion, existing.scalaVersion) match + case (Some(reqV), Some(existingV)) if reqV != existingV => + ( + Left( + MarklitError.ValidationError( + location, + s"Scope '$id' was previously declared with Scala $existingV; cannot reuse with Scala $reqV" + ) + ), + state + ) + case _ => + ( + Right( + ResolvedScope(existing, Vector.empty, Vector.empty) + ), + state ) - ), - state - ) - case _ => - (Right(ResolvedScope(existing, Vector.empty)), state) case None => - val newScope = Scope.named(id, config.scalaVersion) + val newScope = + Scope.named(id, config.scalaVersion, None, topLevel) val newState = state.addScope(newScope) - (Right(ResolvedScope(newScope, Vector.empty)), newState) + ( + Right(ResolvedScope(newScope, Vector.empty, Vector.empty)), + newState + ) // id=foo,extends=bar - create named child scope case (Some(id), Some(parentId), false) => @@ -281,44 +383,60 @@ private final class ScopeManagerLive(stateRef: Ref[ScopeState]) state ) case Some(parent) => - // Check version compatibility - (config.scalaVersion, parent.scalaVersion) match - case (Some(childV), Some(parentV)) if childV != parentV => - ( - Left( - MarklitError.ValidationError( - location, - s"Cannot extend scope '$parentId' (Scala $parentV) with different version (Scala $childV)" - ) - ), - state - ) - case _ => - state.getScope(id) match - case Some(existing) => - // Re-use of `id=foo` must not flip the scope's version. - (config.scalaVersion, existing.scalaVersion) match - case (Some(reqV), Some(existingV)) if reqV != existingV => - ( - Left( - MarklitError.ValidationError( - location, - s"Scope '$id' was previously declared with Scala $existingV; cannot reuse with Scala $reqV" - ) - ), - state + crossKindError(parent, forAppend = false) match + case Some(err) => (Left(err), state) + case None => + // Check version compatibility + (config.scalaVersion, parent.scalaVersion) match + case (Some(childV), Some(parentV)) if childV != parentV => + ( + Left( + MarklitError.ValidationError( + location, + s"Cannot extend scope '$parentId' (Scala $parentV) with different version (Scala $childV)" ) - case _ => - val inherited = collectInheritedCode(parentId, state) - (Right(ResolvedScope(existing, inherited)), state) - case None => - val effectiveVersion = - config.scalaVersion.orElse(parent.scalaVersion) - val newScope = - Scope.named(id, effectiveVersion, Some(parentId)) - val newState = state.addScope(newScope) - val inherited = collectInheritedCode(parentId, state) - (Right(ResolvedScope(newScope, inherited)), newState) + ), + state + ) + case _ => + state.getScope(id) match + case Some(existing) => + sameKindError(existing) match + case Some(err) => (Left(err), state) + case None => + // Re-use of `id=foo` must not flip the version. + (config.scalaVersion, existing.scalaVersion) match + case (Some(reqV), Some(existingV)) + if reqV != existingV => + ( + Left( + MarklitError.ValidationError( + location, + s"Scope '$id' was previously declared with Scala $existingV; cannot reuse with Scala $reqV" + ) + ), + state + ) + case _ => + val (tl, body) = + collectInheritedCode(parentId, state) + ( + Right(ResolvedScope(existing, tl, body)), + state + ) + case None => + val effectiveVersion = + config.scalaVersion.orElse(parent.scalaVersion) + val newScope = + Scope.named( + id, + effectiveVersion, + Some(parentId), + topLevel + ) + val newState = state.addScope(newScope) + val (tl, body) = collectInheritedCode(parentId, state) + (Right(ResolvedScope(newScope, tl, body)), newState) // extends=bar (anonymous child) case (None, Some(parentId), false) => @@ -334,27 +452,35 @@ private final class ScopeManagerLive(stateRef: Ref[ScopeState]) state ) case Some(parent) => - // Check version compatibility - (config.scalaVersion, parent.scalaVersion) match - case (Some(childV), Some(parentV)) if childV != parentV => - ( - Left( - MarklitError.ValidationError( - location, - s"Cannot extend scope '$parentId' (Scala $parentV) with different version (Scala $childV)" + crossKindError(parent, forAppend = false) match + case Some(err) => (Left(err), state) + case None => + // Check version compatibility + (config.scalaVersion, parent.scalaVersion) match + case (Some(childV), Some(parentV)) if childV != parentV => + ( + Left( + MarklitError.ValidationError( + location, + s"Cannot extend scope '$parentId' (Scala $parentV) with different version (Scala $childV)" + ) + ), + state ) - ), - state - ) - case _ => - val (stateWithId, anonId) = state.nextAnonymousId - val effectiveVersion = - config.scalaVersion.orElse(parent.scalaVersion) - val newScope = - Scope.named(anonId, effectiveVersion, Some(parentId)) - val newState = stateWithId.addScope(newScope) - val inherited = collectInheritedCode(parentId, state) - (Right(ResolvedScope(newScope, inherited)), newState) + case _ => + val (stateWithId, anonId) = state.nextAnonymousId + val effectiveVersion = + config.scalaVersion.orElse(parent.scalaVersion) + val newScope = + Scope.named( + anonId, + effectiveVersion, + Some(parentId), + topLevel + ) + val newState = stateWithId.addScope(newScope) + val (tl, body) = collectInheritedCode(parentId, state) + (Right(ResolvedScope(newScope, tl, body)), newState) // extends=bar,append - append to parent scope (mutate it) case (None, Some(parentId), true) => @@ -370,26 +496,30 @@ private final class ScopeManagerLive(stateRef: Ref[ScopeState]) state ) case Some(parent) => - // Check version compatibility - (config.scalaVersion, parent.scalaVersion) match - case (Some(v), Some(parentV)) if v != parentV => - ( - Left( - MarklitError.ValidationError( - location, - s"Cannot append to scope '$parentId' (Scala $parentV) with different version (Scala $v)" + crossKindError(parent, forAppend = true) match + case Some(err) => (Left(err), state) + case None => + // Check version compatibility + (config.scalaVersion, parent.scalaVersion) match + case (Some(v), Some(parentV)) if v != parentV => + ( + Left( + MarklitError.ValidationError( + location, + s"Cannot append to scope '$parentId' (Scala $parentV) with different version (Scala $v)" + ) + ), + state ) - ), - state - ) - case _ => - // For append, we return the parent scope itself - code will be added to it - // We only collect ancestors' code, NOT the parent's own priorCode, because - // DocumentProcessor adds scope.priorCode separately - val ancestorCode = parent.parent - .map(pid => collectInheritedCode(pid, state)) - .getOrElse(Vector.empty) - (Right(ResolvedScope(parent, ancestorCode)), state) + case _ => + // For append, we return the parent scope itself - code will + // be added to it. We only collect ancestors' code, NOT the + // parent's own priorCode, because DocumentProcessor adds + // scope.priorCode separately. + val (tl, body) = parent.parent + .map(pid => collectInheritedCode(pid, state)) + .getOrElse((Vector.empty, Vector.empty)) + (Right(ResolvedScope(parent, tl, body)), state) // id=foo,append - invalid (caught by validate, but handle defensively) case (Some(_), _, true) => @@ -413,19 +543,27 @@ private final class ScopeManagerLive(stateRef: Ref[ScopeState]) state ) - /** Collect all code from ancestors in order (oldest first) */ + /** Collect all code from ancestors in order (oldest first), split by scope + * kind: `(topLevelCode, bodyCode)`. Because a top-level scope can only be + * extended by another top-level scope, top-level ancestors always form a + * prefix of the chain — so concatenating `topLevelCode ++ bodyCode` recovers + * the original ancestor order. + */ private def collectInheritedCode( scopeId: String, state: ScopeState - ): Vector[String] = - def ancestors(id: String): Vector[String] = + ): (Vector[String], Vector[String]) = + def ancestors(id: String): Vector[Scope] = state.getScope(id) match case None => Vector.empty case Some(scope) => scope.parent match - case None => scope.priorCode - case Some(parentId) => ancestors(parentId) ++ scope.priorCode - ancestors(scopeId) + case None => Vector(scope) + case Some(parentId) => ancestors(parentId) :+ scope + val chain = ancestors(scopeId) + val tl = chain.filter(_.topLevel).flatMap(_.priorCode) + val body = chain.filterNot(_.topLevel).flatMap(_.priorCode) + (tl, body) override def recordCode(scopeId: String, code: String): UIO[Unit] = stateRef.update { state => diff --git a/core/src/test/scala/marklit/cache/BlockCacheSpec.scala b/core/src/test/scala/marklit/cache/BlockCacheSpec.scala index 6fda35b..f841183 100644 --- a/core/src/test/scala/marklit/cache/BlockCacheSpec.scala +++ b/core/src/test/scala/marklit/cache/BlockCacheSpec.scala @@ -130,6 +130,47 @@ object BlockCacheSpec extends ZIOSpecDefault: ) assertTrue(keyWith(ScopeMode.Isolated) != keyWith(ScopeMode.Page)) }, + test("toggling topLevel produces a different key") { + // A top-level (unwrapped) compile and a wrapped compile of identical + // source must not share a cache entry — their class files differ. + def keyWith(topLevel: Boolean): BlockCacheKey = + BlockCacheKey.make( + code = "opaque type T = Int", + priorCode = Vector.empty, + scalaVersion = "3.8.2", + classpath = Vector("a.jar"), + classpathHashes = Vector("h1"), + scalacOptions = Vector.empty, + isZIOApp = false, + file = "f.md", + startLine = 1, + startColumn = 1, + topLevel = topLevel + ) + assertTrue(keyWith(false) != keyWith(true)) + }, + test("different topLevelPriorCode produces a different key") { + // Hoisted definitions are part of the compilation unit; changing them + // must invalidate the entry. + def keyWith(hoist: Vector[String]): BlockCacheKey = + BlockCacheKey.make( + code = "val x = 1", + priorCode = Vector.empty, + scalaVersion = "3.8.2", + classpath = Vector("a.jar"), + classpathHashes = Vector("h1"), + scalacOptions = Vector.empty, + isZIOApp = false, + file = "f.md", + startLine = 1, + startColumn = 1, + topLevelPriorCode = hoist + ) + assertTrue( + keyWith(Vector.empty) != keyWith(Vector("enum E: case A")), + keyWith(Vector("enum E: case A")) != keyWith(Vector("enum E: case B")) + ) + }, test("changing classpath hash invalidates key") { val k1 = BlockCacheKey.make( code = "x", diff --git a/core/src/test/scala/marklit/model/CodeBlockSpec.scala b/core/src/test/scala/marklit/model/CodeBlockSpec.scala index a754557..c3e2871 100644 --- a/core/src/test/scala/marklit/model/CodeBlockSpec.scala +++ b/core/src/test/scala/marklit/model/CodeBlockSpec.scala @@ -15,7 +15,78 @@ object CodeBlockSpec extends ZIOSpecDefault: showWarningsOverride = override_ ) - def spec = suite("CodeBlock.showWarnings")( + private def blockWith( + modifiers: Set[Modifier], + scopeConfig: ScopeConfig = ScopeConfig.empty + ): CodeBlock = + CodeBlock( + code = "", + modifiers = modifiers, + scopeConfig = scopeConfig, + location = loc + ) + + def spec = suite("CodeBlock")( + topLevelSuite, + showWarningsSuite + ) + + private val topLevelSuite = suite("top-level")( + test("isTopLevel reflects the TopLevel modifier") { + assertTrue( + blockWith(Set(Modifier.TopLevel)).isTopLevel, + !blockWith(Set.empty).isTopLevel, + !blockWith(Set(Modifier.Silent)).isTopLevel + ) + }, + test("a top-level block does not execute (compile-only)") { + assertTrue(!blockWith(Set(Modifier.TopLevel)).shouldExecute) + }, + test("modifierConflicts is None for a bare top-level block") { + assertTrue(blockWith(Set(Modifier.TopLevel)).modifierConflicts.isEmpty) + }, + test("modifierConflicts is None for top-level with only scope options") { + // id / extends / append / scala=version / show-warnings live on + // ScopeConfig + showWarningsOverride, not in `modifiers`, so the + // modifier set is still just {TopLevel}. + val b = blockWith( + Set(Modifier.TopLevel), + ScopeConfig(id = Some("x"), scalaVersion = Some("3.7.3")) + ) + assertTrue(b.modifierConflicts.isEmpty) + }, + test( + "modifierConflicts is Some(...) when top-level pairs with a behavioral modifier" + ) { + val bad = List( + Modifier.Silent, + Modifier.Invisible, + Modifier.Fail, + Modifier.Warn, + Modifier.Crash, + Modifier.CompileOnly, + Modifier.Passthrough, + Modifier.ZIOApp, + Modifier.Shared, + Modifier.SharedMajor("3") + ) + assertTrue( + bad.forall(m => + blockWith(Set(Modifier.TopLevel, m)).modifierConflicts.isDefined + ) + ) + }, + test("modifierConflicts is None for a non-top-level block") { + // Plain blocks never report a top-level conflict, even with several + // modifiers — modifierConflicts only polices the top-level marker. + assertTrue( + blockWith(Set(Modifier.Silent)).modifierConflicts.isEmpty, + blockWith(Set.empty).modifierConflicts.isEmpty + ) + } + ) + + private val showWarningsSuite = suite("CodeBlock.showWarnings")( test("returns the default when override is None") { val b = block(None) assertTrue( diff --git a/core/src/test/scala/marklit/model/ModifierSpec.scala b/core/src/test/scala/marklit/model/ModifierSpec.scala new file mode 100644 index 0000000..61f7c88 --- /dev/null +++ b/core/src/test/scala/marklit/model/ModifierSpec.scala @@ -0,0 +1,40 @@ +package marklit.model + +import zio.test.* + +object ModifierSpec extends ZIOSpecDefault: + + def spec = suite("Modifier.parse")( + suite("top-level")( + test("parses 'top-level' to Modifier.TopLevel") { + assertTrue(Modifier.parse("top-level") == Some(Modifier.TopLevel)) + }, + test("is case-insensitive") { + assertTrue( + Modifier.parse("Top-Level") == Some(Modifier.TopLevel), + Modifier.parse("TOP-LEVEL") == Some(Modifier.TopLevel) + ) + } + ), + suite("existing modifiers (no regression)")( + test("parses the known simple modifiers") { + assertTrue( + Modifier.parse("silent") == Some(Modifier.Silent), + Modifier.parse("invisible") == Some(Modifier.Invisible), + Modifier.parse("fail") == Some(Modifier.Fail), + Modifier.parse("warn") == Some(Modifier.Warn), + Modifier.parse("crash") == Some(Modifier.Crash), + Modifier.parse("compile-only") == Some(Modifier.CompileOnly), + Modifier.parse("passthrough") == Some(Modifier.Passthrough), + Modifier.parse("zio-app") == Some(Modifier.ZIOApp) + ) + }, + test("returns None for unknown tokens") { + assertTrue( + Modifier.parse("toplevel") == None, + Modifier.parse("top_level") == None, + Modifier.parse("nonsense") == None + ) + } + ) + ) diff --git a/core/src/test/scala/marklit/parser/InfoStringParserSpec.scala b/core/src/test/scala/marklit/parser/InfoStringParserSpec.scala index 1985d69..3945f8e 100644 --- a/core/src/test/scala/marklit/parser/InfoStringParserSpec.scala +++ b/core/src/test/scala/marklit/parser/InfoStringParserSpec.scala @@ -6,6 +6,58 @@ import zio.test.* object InfoStringParserSpec extends ZIOSpecDefault: def spec = suite("InfoStringParser")( + suite("top-level modifier")( + test("top-level parses to Modifier.TopLevel") { + val r = InfoStringParser.parse("scala marklit:top-level") + assertTrue( + r.isScalaBlock, + r.modifiers == Set(Modifier.TopLevel), + r.scopeConfig == ScopeConfig.empty + ) + }, + test("top-level,id=foo carries both the modifier and the scope id") { + val r = InfoStringParser.parse("scala marklit:top-level,id=foo") + assertTrue( + r.isScalaBlock, + r.modifiers == Set(Modifier.TopLevel), + r.scopeConfig.id == Some("foo") + ) + }, + test("top-level,extends=foo carries both the modifier and the parent") { + val r = InfoStringParser.parse("scala marklit:top-level,extends=foo") + assertTrue( + r.isScalaBlock, + r.modifiers == Set(Modifier.TopLevel), + r.scopeConfig.extendsScope == Some("foo") + ) + }, + test("top-level,scala=3 keeps the bare major and the modifier") { + val r = InfoStringParser.parse("scala marklit:top-level,scala=3") + assertTrue( + r.isScalaBlock, + r.modifiers == Set(Modifier.TopLevel), + r.scopeConfig.scalaVersion == Some("3") + ) + }, + test("Top-Level is recognized case-insensitively") { + val r = InfoStringParser.parse("scala marklit:Top-Level") + assertTrue(r.modifiers == Set(Modifier.TopLevel)) + }, + test("top-level alongside an unknown key still recognizes top-level") { + val r = InfoStringParser.parse("scala marklit:top-level,bogus=1") + assertTrue( + r.isScalaBlock, + r.modifiers == Set(Modifier.TopLevel) + ) + }, + test("a non-scala fence with top-level stays passthrough") { + val r = InfoStringParser.parse("text marklit:top-level") + assertTrue( + !r.isScalaBlock, + r.modifiers == Set(Modifier.Passthrough) + ) + } + ), suite("show-warnings option")( test("show-warnings=true sets override to Some(true)") { val r = InfoStringParser.parse("scala marklit:show-warnings=true") diff --git a/core/src/test/scala/marklit/processor/DocumentProcessorSpec.scala b/core/src/test/scala/marklit/processor/DocumentProcessorSpec.scala index 7eed4fc..e85203c 100644 --- a/core/src/test/scala/marklit/processor/DocumentProcessorSpec.scala +++ b/core/src/test/scala/marklit/processor/DocumentProcessorSpec.scala @@ -41,6 +41,11 @@ object DocumentProcessorSpec extends ZIOSpecDefault: val executeCallsWithDir = scala.collection.mutable.ArrayBuffer .empty[(String, Option[java.nio.file.Path])] + // (code, topLevel, topLevelPriorCode) — lets tests assert that top-level + // blocks compile unwrapped and that inherited definitions are hoisted. + val compileCallsTopLevel = + scala.collection.mutable.ArrayBuffer + .empty[(String, Boolean, Vector[String])] override def compile( code: String, @@ -49,10 +54,13 @@ object DocumentProcessorSpec extends ZIOSpecDefault: scalaVersion: Option[String], location: Option[Location], scopeConfig: ScopeConfig, - scopeMode: ScopeMode + scopeMode: ScopeMode, + topLevel: Boolean, + topLevelPriorCode: Vector[String] ): IO[MarklitError, CompileResult] = compileCalls += ((code, priorCode)) compileCallsWithVersion += ((code, priorCode, scalaVersion)) + compileCallsTopLevel += ((code, topLevel, topLevelPriorCode)) ZIO.succeed( compileResults.getOrElse( code, @@ -65,7 +73,9 @@ object DocumentProcessorSpec extends ZIOSpecDefault: priorCode: Vector[String], isZIOApp: Boolean, scalaVersion: Option[String], - classFilesDir: Option[java.nio.file.Path] + classFilesDir: Option[java.nio.file.Path], + topLevel: Boolean, + topLevelPriorCode: Vector[String] ): IO[MarklitError, String] = executeCalls += ((code, priorCode)) executeCallsWithVersion += ((code, priorCode, scalaVersion)) @@ -1095,10 +1105,13 @@ object DocumentProcessorSpec extends ZIOSpecDefault: scalaVersion: Option[String], location: Option[Location], scopeConfig: ScopeConfig, - scopeMode: ScopeMode + scopeMode: ScopeMode, + topLevel: Boolean, + topLevelPriorCode: Vector[String] ): IO[MarklitError, CompileResult] = compileCalls += ((code, priorCode)) compileCallsWithVersion += ((code, priorCode, scalaVersion)) + compileCallsTopLevel += ((code, topLevel, topLevelPriorCode)) val isTwo = scalaVersion.exists(_.startsWith("2")) val sharedFailsHere = code == "bad code" && isTwo ZIO.succeed( @@ -1172,5 +1185,215 @@ object DocumentProcessorSpec extends ZIOSpecDefault: result.blockResults.head.crossExecutions.isEmpty ) } + ), + suite("top-level blocks")( + test("a top-level block compiles unwrapped and does not execute") { + val block = makeBlock("opaque type C = Int", Set(Modifier.TopLevel)) + val compiler = new TestCompiler() + for + scopeManager <- ZIO.service[ScopeManager] + processor = DocumentProcessorLive(scopeManager, compiler) + result <- processor.process(Vector(block)) + yield + val (_, topLevel, hoist) = compiler.compileCallsTopLevel.head + assertTrue( + result.isSuccess, + // compiled with topLevel = true, no hoist prior for a lone block + topLevel, + hoist.isEmpty, + // compile-only: never executed + compiler.executeCalls.isEmpty + ) + }, + test( + "a normal block extending a top-level scope receives the def as hoisted prior code" + ) { + // The motivating shape: definition in a top-level scope, consumed by a + // normal executable block. The mock records that the consumer compiled + // with the definition in its topLevelPriorCode (hoist) bucket, and with + // an empty run-body prior code. + val tlDef = makeBlock( + "opaque type C = Int", + Set(Modifier.TopLevel), + ScopeConfig(id = Some("types")) + ) + val consumer = makeBlock( + "val c: C = ???", + scopeConfig = ScopeConfig(extendsScope = Some("types")) + ) + val compiler = new TestCompiler() + for + scopeManager <- ZIO.service[ScopeManager] + processor = DocumentProcessorLive(scopeManager, compiler) + _ <- processor.process(Vector(tlDef, consumer)) + yield + // calls[0] = the top-level def, calls[1] = the consumer + val (defCode, defTopLevel, _) = compiler.compileCallsTopLevel(0) + val (consCode, consTopLevel, consHoist) = + compiler.compileCallsTopLevel(1) + val (_, consBodyPrior) = compiler.compileCalls(1) + assertTrue( + defCode == "opaque type C = Int", + defTopLevel, + consCode == "val c: C = ???", + !consTopLevel, + consHoist == Vector("opaque type C = Int"), + consBodyPrior.isEmpty + ) + }, + test( + "top-level combined with a behavioral modifier fails the run with a ValidationError" + ) { + val bad = makeBlock( + "opaque type C = Int", + Set(Modifier.TopLevel, Modifier.Silent) + ) + val compiler = new TestCompiler() + for + scopeManager <- ZIO.service[ScopeManager] + processor = DocumentProcessorLive(scopeManager, compiler) + result <- processor.process(Vector(bad)) + yield assertTrue( + !result.isSuccess, + // never reached the compiler — rejected up front + compiler.compileCalls.isEmpty, + result.blockResults.head.error.exists { + case MarklitError.ValidationError(_, msg) => + msg.contains("top-level") + case _ => false + } + ) + }, + test("a top-level block is not folded into the page scope") { + // Under --page-scope, anonymous normal blocks share a chain; a + // top-level block must opt out (it can't live in the run-body chain). + val first = makeBlock("val x = 1") + val tl = makeBlock("opaque type C = Int", Set(Modifier.TopLevel)) + val third = makeBlock("println(x)") + val compiler = new TestCompiler() + for + scopeManager <- ZIO.service[ScopeManager] + processor = DocumentProcessorLive( + scopeManager, + compiler, + scopeMode = ScopeMode.Page + ) + _ <- processor.process(Vector(first, tl, third)) + yield + val (_, tlTopLevel, _) = compiler.compileCallsTopLevel(1) + val (_, thirdPrior) = compiler.compileCalls(2) + assertTrue( + // the top-level block compiled unwrapped + tlTopLevel, + // page scope still threads `val x` to the third block, unaffected + // by the top-level block sitting between them + thirdPrior == Vector("val x = 1") + ) + }, + test( + "cross-version: a top-level block requesting a specific version forwards it" + ) { + val block = makeBlock( + "opaque type C = Int", + Set(Modifier.TopLevel), + ScopeConfig(scalaVersion = Some("3.7.3")) + ) + val compiler = new TestCompiler(defaultScalaVersion = "3.8.3") + for + scopeManager <- ZIO.service[ScopeManager] + processor = DocumentProcessorLive(scopeManager, compiler) + _ <- processor.process(Vector(block)) + yield + val (_, _, version) = compiler.compileCallsWithVersion.head + val (_, topLevel, _) = compiler.compileCallsTopLevel.head + assertTrue( + version == Some("3.7.3"), + topLevel + ) + }, + test( + "cross-version: a bare-major top-level block (scala=2) auto-resolves to a 2.x default and stays top-level" + ) { + val block = makeBlock( + "opaque type C = Int", + Set(Modifier.TopLevel), + ScopeConfig(scalaVersion = Some("2")) + ) + // Default is 3.x; bare-major 2 must resolve to a 2.13 default. + val compiler = new TestCompiler( + defaultScalaVersion = "3.8.3", + majorDefaults = Map("2" -> "2.13.16") + ) + for + scopeManager <- ZIO.service[ScopeManager] + processor = DocumentProcessorLive(scopeManager, compiler) + result <- processor.process(Vector(block)) + yield + val (_, _, version) = compiler.compileCallsWithVersion.head + val (_, topLevel, _) = compiler.compileCallsTopLevel.head + assertTrue( + result.isSuccess, + version == Some("2.13.16"), + topLevel, + result.blockResults.head.effectiveScalaVersion == Some("2.13.16") + ) + }, + test( + "cross-version: a bare-major top-level block with no default for that major is skipped" + ) { + val block = makeBlock( + "opaque type C = Int", + Set(Modifier.TopLevel), + ScopeConfig(scalaVersion = Some("1")) + ) + val compiler = new TestCompiler(defaultScalaVersion = "3.8.3") + for + scopeManager <- ZIO.service[ScopeManager] + processor = DocumentProcessorLive(scopeManager, compiler) + result <- processor.process(Vector(block)) + yield assertTrue( + result.blockResults.head.skipped, + result.isSuccess, // skipped blocks don't fail the run + compiler.compileCalls.isEmpty // never compiled + ) + }, + test( + "cross-version: a normal block extends a version-specific top-level scope and inherits the hoist at that version" + ) { + val tlDef = makeBlock( + "opaque type C = Int", + Set(Modifier.TopLevel), + ScopeConfig(id = Some("types"), scalaVersion = Some("3.7.3")) + ) + val consumer = makeBlock( + "val c: C = ???", + scopeConfig = ScopeConfig( + extendsScope = Some("types"), + scalaVersion = Some("3.7.3") + ) + ) + val compiler = new TestCompiler(defaultScalaVersion = "3.8.3") + for + scopeManager <- ZIO.service[ScopeManager] + processor = DocumentProcessorLive(scopeManager, compiler) + result <- processor.process(Vector(tlDef, consumer)) + yield + val (_, defTopLevel, _) = compiler.compileCallsTopLevel(0) + val (_, defVersion) = + ( + compiler.compileCallsWithVersion(0)._1, + compiler.compileCallsWithVersion(0)._3 + ) + val (_, consTopLevel, consHoist) = compiler.compileCallsTopLevel(1) + val consVersion = compiler.compileCallsWithVersion(1)._3 + assertTrue( + result.isSuccess, + defTopLevel, + defVersion == Some("3.7.3"), + !consTopLevel, + consHoist == Vector("opaque type C = Int"), + consVersion == Some("3.7.3") + ) + } ) ).provide(ScopeManager.layer) @@ TestAspect.sequential diff --git a/core/src/test/scala/marklit/scope/ScopeManagerSpec.scala b/core/src/test/scala/marklit/scope/ScopeManagerSpec.scala index 4d41fd6..92de38e 100644 --- a/core/src/test/scala/marklit/scope/ScopeManagerSpec.scala +++ b/core/src/test/scala/marklit/scope/ScopeManagerSpec.scala @@ -272,6 +272,216 @@ object ScopeManagerSpec extends ZIOSpecDefault: } ), + suite("top-level scopes")( + test( + "a normal block extending a top-level scope hoists the inherited code" + ) { + // The motivating case: an opaque type / enum defined in a top-level + // scope, consumed by a normal executable block. The definition must be + // hoisted (file scope), the consumer's own code stays run-body. + val tlDef = ScopeConfig(id = Some("types")) + val consumer = ScopeConfig(extendsScope = Some("types")) + for + _ <- ScopeManager.resolveScope(tlDef, testLocation, topLevel = true) + _ <- ScopeManager.recordCode("types", "opaque type C = Int") + r <- ScopeManager.resolveScope(consumer, testLocation) + yield assertTrue( + r.hoistCode == Vector("opaque type C = Int"), + r.bodyCode.isEmpty + ) + }, + test( + "a top-level chain accumulates all definitions in the hoist bucket" + ) { + val a = ScopeConfig(id = Some("a")) + val b = ScopeConfig(id = Some("b"), extendsScope = Some("a")) + for + _ <- ScopeManager.resolveScope(a, testLocation, topLevel = true) + _ <- ScopeManager.recordCode("a", "opaque type A = Int") + _ <- ScopeManager.resolveScope(b, testLocation, topLevel = true) + _ <- ScopeManager.recordCode("b", "opaque type B = Int") + // A normal consumer at the bottom of the chain. + consumer <- ScopeManager.resolveScope( + ScopeConfig(extendsScope = Some("b")), + testLocation + ) + yield assertTrue( + consumer.hoistCode == Vector( + "opaque type A = Int", + "opaque type B = Int" + ), + consumer.bodyCode.isEmpty + ) + }, + test("top-level append accumulates into the same top-level scope") { + val base = ScopeConfig(id = Some("tlbase")) + val app = ScopeConfig(extendsScope = Some("tlbase"), append = true) + for + _ <- ScopeManager.resolveScope(base, testLocation, topLevel = true) + _ <- ScopeManager.recordCode("tlbase", "opaque type X = Int") + a <- ScopeManager.resolveScope(app, testLocation, topLevel = true) + _ <- ScopeManager.recordCode(a.scope.id, "opaque type Y = Int") + reread <- ScopeManager.resolveScope( + base, + testLocation, + topLevel = true + ) + yield assertTrue( + a.scope.id == "tlbase", + reread.hoistCode == Vector( + "opaque type X = Int", + "opaque type Y = Int" + ) + ) + }, + test( + "anonymous top-level block has no parent and inherits no shared/default code" + ) { + // Shared run-body code seeded into the per-version default scope must + // NOT flow into a top-level block — it would be illegal at file scope. + for + _ <- ScopeManager.seedDefaultPriorCode("3.7.3", "val helper = 99") + r <- ScopeManager.resolveScope( + ScopeConfig.empty, + testLocation, + effectiveVersion = Some("3.7.3"), + topLevel = true + ) + yield assertTrue( + r.scope.parent.isEmpty, + r.scope.topLevel, + r.hoistCode.isEmpty, + r.bodyCode.isEmpty + ) + }, + test("a top-level block extending a NORMAL scope is rejected") { + val normal = ScopeConfig(id = Some("plain")) + val tlChild = ScopeConfig(extendsScope = Some("plain")) + for + _ <- ScopeManager.resolveScope(normal, testLocation) // normal + result <- ScopeManager + .resolveScope(tlChild, testLocation, topLevel = true) + .either + yield assertTrue( + result.isLeft, + result.left.toOption.exists { + case MarklitError.ValidationError(_, msg) => + msg.toLowerCase.contains("top-level") + case _ => false + } + ) + }, + test("a normal block appending to a top-level scope is rejected") { + val tl = ScopeConfig(id = Some("tlappend")) + val normalAppend = + ScopeConfig(extendsScope = Some("tlappend"), append = true) + for + _ <- ScopeManager.resolveScope(tl, testLocation, topLevel = true) + result <- ScopeManager + .resolveScope(normalAppend, testLocation) // normal + .either + yield assertTrue( + result.isLeft, + result.left.toOption.exists { + case MarklitError.ValidationError(_, msg) => + msg.toLowerCase.contains("top-level") + case _ => false + } + ) + }, + test("a top-level block appending to a NORMAL scope is rejected") { + val normal = ScopeConfig(id = Some("plainappend")) + val tlAppend = + ScopeConfig(extendsScope = Some("plainappend"), append = true) + for + _ <- ScopeManager.resolveScope(normal, testLocation) // normal + result <- ScopeManager + .resolveScope(tlAppend, testLocation, topLevel = true) + .either + yield assertTrue( + result.isLeft, + result.left.toOption.exists { + case MarklitError.ValidationError(_, msg) => + msg.toLowerCase.contains("top-level") + case _ => false + } + ) + }, + test("reusing an id with a different kind is rejected") { + val tl = ScopeConfig(id = Some("dual")) + for + _ <- ScopeManager.resolveScope(tl, testLocation, topLevel = true) + result <- ScopeManager + .resolveScope(tl, testLocation, topLevel = false) // reuse as normal + .either + yield assertTrue( + result.isLeft, + result.left.toOption.exists { + case MarklitError.ValidationError(_, msg) => + msg.toLowerCase.contains("top-level") + case _ => false + } + ) + }, + test("a top-level scope carries its requested Scala version") { + // Cross-version support: a `top-level,scala=3.7.3` definition block + // pins its version like any other scope, so a consumer must match it. + val tl = ScopeConfig(id = Some("tlv"), scalaVersion = Some("3.7.3")) + for resolved <- ScopeManager.resolveScope( + tl, + testLocation, + effectiveVersion = Some("3.7.3"), + topLevel = true + ) + yield assertTrue( + resolved.scope.topLevel, + resolved.scope.scalaVersion == Some("3.7.3") + ) + }, + test( + "a normal block extending a top-level scope of a different version is rejected" + ) { + // The version guard must fire even across kinds: hoisting is allowed + // only when versions agree, just like normal cross-version extends. + val tl = ScopeConfig(id = Some("tl2"), scalaVersion = Some("2.13.16")) + val consumer = ScopeConfig( + extendsScope = Some("tl2"), + scalaVersion = Some("3.7.3") + ) + for + _ <- ScopeManager.resolveScope(tl, testLocation, topLevel = true) + result <- ScopeManager + .resolveScope(consumer, testLocation) // normal, different version + .either + yield assertTrue( + result.isLeft, + result.left.toOption.exists { + case MarklitError.ValidationError(_, msg) => + msg.contains("different version") || msg.contains("Cannot extend") + case _ => false + } + ) + }, + test( + "a normal block extending a same-version top-level scope hoists its code" + ) { + val tl = ScopeConfig(id = Some("tl3"), scalaVersion = Some("3.7.3")) + val consumer = ScopeConfig( + extendsScope = Some("tl3"), + scalaVersion = Some("3.7.3") + ) + for + _ <- ScopeManager.resolveScope(tl, testLocation, topLevel = true) + _ <- ScopeManager.recordCode("tl3", "opaque type C = Int") + r <- ScopeManager.resolveScope(consumer, testLocation) + yield assertTrue( + r.hoistCode == Vector("opaque type C = Int"), + r.bodyCode.isEmpty, + r.scope.scalaVersion == Some("3.7.3") + ) + } + ), + suite("parallel compilation")( test("identifies independent scope trees") { val tree1Root = ScopeConfig(id = Some("tree1")) diff --git a/examples/base/src/main/markdown/index.md b/examples/base/src/main/markdown/index.md index 105c199..60afdb1 100644 --- a/examples/base/src/main/markdown/index.md +++ b/examples/base/src/main/markdown/index.md @@ -20,6 +20,10 @@ type-checked, run, and the output spliced back into the markdown. `shared` / `shared-{major}` setup blocks). 5. **[zio-example.md](zio-example.md)** — worked recipe using `marklit:zio-app` plus named scopes to assemble a small ZIO service. +6. **[top-level.md](top-level.md)** — `top-level` blocks: compile + constructs that are illegal or warn inside the wrapper (`opaque type`, + `@main`, parameterized `enum` matches), and hoist a definition into a + running example. For the opt-in page-scope feature (anonymous blocks sharing state mdoc-style), see the dedicated `markdown-page-scope/tour.md` tree @@ -40,6 +44,7 @@ Modifiers are bare words in the fenced info string, prefixed with | `warn` | Assert ≥1 compile warning AND always render warnings. | [tutorial.md](tutorial.md) | | `crash` | Assert the block throws at runtime; render the exception. | [tutorial.md](tutorial.md) | | `zio-app` | Wrap block as the body of `ZIOAppDefault.run`. | [zio-example.md](zio-example.md) | +| `top-level` | Compile verbatim as a top-level unit (no wrapper); compile-only. | [top-level.md](top-level.md) | | `shared` | Prepend block's code to every per-version default scope. | [scopes-and-versions.md](scopes-and-versions.md) | | `shared-{maj}` | Prepend block to default scopes for one Scala major (`-3`/`-2`). | [scopes-and-versions.md](scopes-and-versions.md) | | `append` | Grow an existing scope in place (combine with `extends=`). | [scopes-and-versions.md](scopes-and-versions.md) | diff --git a/examples/base/src/main/markdown/top-level.md b/examples/base/src/main/markdown/top-level.md new file mode 100644 index 0000000..f71d99c --- /dev/null +++ b/examples/base/src/main/markdown/top-level.md @@ -0,0 +1,125 @@ +# Top-Level Blocks + +By default marklit wraps every block in `object MarklitWrapper: def run(): Unit += …`, so your code is a **method body**. That's the right default for runnable +examples, but some Scala constructs are only legal — or only warning-free — at +the *top level* of a source file. The `top-level` modifier compiles a block +verbatim as its own compilation unit (no wrapper). + +Because top-level code has no entry point, `top-level` blocks are +**compile-only**: marklit type-checks them and renders the code (plus any +diagnostics), but never executes them. To *use* a top-level definition in a +running example, give it an `id=` and `extends=` it from a normal block — marklit +hoists the definition above the wrapper while your example runs inside `run()`. + +## The motivating case: matching a parameterized enum case + +Pattern-matching a parameterized `enum`/ADT case against a non-local scrutinee +needs a runtime type test. When the `enum` is declared *inside* `def run()` it +becomes a **local class**, and the type test "cannot be checked at runtime" — so +the compiler warns. Here is the problem, asserted with `warn`: + +```scala marklit:warn +enum CounterAction: + case Inc + case Set(v: Int) + +val action: Any = CounterAction.Set(10) +val label = action match + case CounterAction.Set(v) => s"set to $v" + case _ => "other" +println(label) +``` + +The warning is real, not cosmetic — it tells you the match is not actually +type-safe at runtime. Moving the `enum` to the top level makes it a genuine +class, so the type test is checkable and the warning disappears. + +Define the `enum` in a `top-level` block (compile-only — note no output below +it): + +```scala marklit:top-level,id=actions +enum CounterAction: + case Inc + case Set(v: Int) +``` + +Then match on it from a normal block that `extends` the top-level scope. The +`enum` is hoisted, so this compiles cleanly **and** runs: + +```scala marklit:extends=actions +val action: Any = CounterAction.Set(10) +val label = action match + case CounterAction.Set(v) => s"set to $v" + case _ => "other" +println(label) +``` + +## `opaque type`: illegal inside a method body + +An `opaque type` can only be declared at the top level — inside the wrapper it +fails to compile outright. `top-level` lets you show one: + +```scala marklit:top-level,id=temperature +opaque type Celsius = Double + +object Celsius: + def apply(d: Double): Celsius = d + extension (c: Celsius) def value: Double = c +``` + +And a normal block can construct and use it, with the opaque type hoisted into +scope: + +```scala marklit:extends=temperature +val t = Celsius(21.5) +println(s"temperature = ${t.value}°C") +``` + +## `@main`: a top-level entry point + +A `@main` method "cannot be a main method since it cannot be accessed +statically" inside the wrapper. As a `top-level` block it type-checks fine +(compile-only — marklit shows it, but does not invoke it): + +```scala marklit:top-level +@main def greet(name: String): Unit = + println(s"Hello, $name!") +``` + +## Combining with a Scala version + +`top-level` composes with the `scala=` selector, so you can pin a top-level +definition to a specific major or version. This `given`/`extension` pair is +compiled against Scala 3: + +```scala marklit:top-level,id=show3,scala=3 +trait Show[A]: + def show(a: A): String + +given Show[Int] with + def show(a: Int): String = s"Int($a)" + +extension [A](a: A)(using s: Show[A]) def shown: String = s.show(a) +``` + +```scala marklit:extends=show3,scala=3 +println(42.shown) +``` + +## Rules + +- `top-level` is **strict**: it may only be combined with scope options + (`id`/`extends`/`append`), a version selector (`scala=3`, `scala=3.7.3`), and + `show-warnings`. Pairing it with a behavioral modifier (`silent`, `fail`, + `crash`, `zio-app`, `scala=shared`, …) is a validation error. +- Scopes are single-kind. A normal block may `extends=` a top-level scope + (hoisting its definitions); a `top-level` block may **not** extend or append to + a normal scope, and `append` must stay within one kind. + +## See also + +- [tutorial.md](tutorial.md) — modifier basics (`silent`, `invisible`, + `compile-only`, `fail`, `warn`, `crash`, `passthrough`). +- [scopes-and-versions.md](scopes-and-versions.md) — how `id=` / `extends=` / + `append` work, and per-block multi-version compilation.