diff --git a/README.md b/README.md index ba606ec..ae10cac 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,36 @@ If you want to render some files page-scoped and others isolated within the same Default remains off: marklit's per-block isolation is the better default for reference docs where each block stands alone. Flip page scope on per-project, per-module, or per-task when the document genuinely reads as one continuous session. +## Run-scoped resources (shared setup/teardown) + +Executable blocks run their side effects on **every** generate run — the block cache stores compiled classes, not output, so execution always replays. If your docs talk to something external and stateful (a database, a temp directory, a server), that external state must be set up before the docs run and reset afterward, or a second `marklitGenerate` in the same session sees the first run's leftovers. + +marklit gives you one lifecycle hook for this: a **run resource** that is acquired once before any file is processed and closed once after the last — inside a scope, so teardown happens even when a file fails. While a resource is configured, every block in the run (including `zio-app` blocks) shares the **same** instance, so a single object set up at run start is visible everywhere. + +The contract is a plain JDK type so your class needs **no dependency on marklit**: implement `java.util.function.Supplier[AutoCloseable]`. `get()` is your setup and returns the teardown as an `AutoCloseable`: + +```scala +// On your docs' compile classpath — no marklit dependency: +package mydocs + +class DocResource extends java.util.function.Supplier[AutoCloseable]: + def get(): AutoCloseable = + val db = Database.start() // run-once setup (your own ZIO/blocking code is fine) + () => db.reset() // close() == run-once teardown +``` + +Point the build at it by fully-qualified class name: + +- **sbt:** `marklitRunResourceClass := Some("mydocs.DocResource")` +- **Mill:** `def marklitRunResourceClass = Some("mydocs.DocResource")` +- **CLI:** `marklit --run-resource mydocs.DocResource ...` + +Notes: + +- The resource class loads from your docs' classpath against the run's Scala version, so it can use your own libraries. Only the `Supplier`/`AutoCloseable` *seam* must be JDK types — internals are yours. +- A setup failure is reported as a notice and the run still proceeds (the blocks that needed it will fail and report on their own); a teardown failure is logged and never fails an otherwise-successful run. +- The warm session factory is untouched — only the resource lives for exactly one run. + ## Build tool integration ### sbt @@ -361,6 +391,7 @@ Common flags: | `--no-show-version` | Suppress the `// Scala x.y.z` annotation on output blocks. | | `--cache-dir` | Persistent on-disk block cache directory (off by default; both build plugins enable it automatically). | | `--page-scope` | Share scope across all anonymous blocks in each file (per Scala version). Off by default. | +| `--run-resource` | FQN of a `java.util.function.Supplier[AutoCloseable]` acquired once per run and closed at run end. See [Run-scoped resources](#run-scoped-resources-shared-setupteardown). | | `--verbose`, `-v` | Verbose logging. | You can also declare dependencies inline in a Markdown file using [scala-cli](https://scala-cli.virtuslab.org/) `using` directives: diff --git a/cli/src/main/scala/marklit/cli/MarklitCli.scala b/cli/src/main/scala/marklit/cli/MarklitCli.scala index 7bad1ec..290b5e9 100644 --- a/cli/src/main/scala/marklit/cli/MarklitCli.scala +++ b/cli/src/main/scala/marklit/cli/MarklitCli.scala @@ -25,7 +25,8 @@ final case class MarklitOptions( daemon: Boolean, idleTimeoutSeconds: Option[Int], cacheDir: Option[Path] = None, - pageScope: Boolean = false + pageScope: Boolean = false, + runResourceClass: Option[String] = None ) object MarklitCli extends ZIOCliDefault: @@ -141,7 +142,13 @@ object MarklitCli extends ZIOCliDefault: Options.boolean("page-scope") ?? "Share scope across all anonymous blocks in each file (default: each block isolated)" - // zio-cli's `++` flattens via `Zippable`, so the result is a flat 16-tuple. + // --run-resource: FQN of a java.util.function.Supplier[AutoCloseable] on the + // docs' classpath, acquired once before any file and closed after the last. + val runResourceClass: Options[Option[String]] = + Options.text("run-resource").optional ?? + "FQN of a java.util.function.Supplier[AutoCloseable] acquired once per run and closed at run end" + + // zio-cli's `++` flattens via `Zippable`, so the result is a flat 17-tuple. val combinedOptions: Options[ ( Option[Path], @@ -159,10 +166,11 @@ object MarklitCli extends ZIOCliDefault: Boolean, Option[Int], Option[Path], - Boolean + Boolean, + Option[String] ) ] = - outputDir ++ watch ++ verbose ++ check ++ showVersionInOutput ++ showWarningsInOutput ++ classpath ++ classpath2 ++ classpath3 ++ dependencies ++ repositories ++ scalaVersion ++ daemon ++ idleTimeoutSeconds ++ cacheDir ++ pageScope + outputDir ++ watch ++ verbose ++ check ++ showVersionInOutput ++ showWarningsInOutput ++ classpath ++ classpath2 ++ classpath3 ++ dependencies ++ repositories ++ scalaVersion ++ daemon ++ idleTimeoutSeconds ++ cacheDir ++ pageScope ++ runResourceClass // Main command val marklitCommand: Command[MarklitOptions] = @@ -184,7 +192,8 @@ object MarklitCli extends ZIOCliDefault: d, idle, cache, - ps + ps, + runRes ) = opts MarklitOptions( @@ -204,7 +213,8 @@ object MarklitCli extends ZIOCliDefault: d, idle, cache, - ps + ps, + runRes ) } .withHelp( @@ -259,6 +269,7 @@ object MarklitCli extends ZIOCliDefault: repos = options.repositories.toVector, cacheDir = options.cacheDir, pageScope = options.pageScope, + runResourceClass = options.runResourceClass, check = options.check, showVersion = options.showVersionInOutput, showWarnings = options.showWarningsInOutput, diff --git a/compiler/src/main/scala/marklit/Marklit.scala b/compiler/src/main/scala/marklit/Marklit.scala index 3f186dc..741821b 100644 --- a/compiler/src/main/scala/marklit/Marklit.scala +++ b/compiler/src/main/scala/marklit/Marklit.scala @@ -87,15 +87,21 @@ object Marklit: defaultScalacOptions: Vector[String] = Vector.empty, majorClasspaths: Map[String, Vector[String]] = Map.empty, cacheDir: Option[Path] = None, - scopeMode: ScopeMode = ScopeMode.Isolated + scopeMode: ScopeMode = ScopeMode.Isolated, + runtimeParent: Option[ClassLoader] = None ): ZLayer[CompilerFactory, Nothing, Marklit] = ZLayer.fromZIO { for factory <- ZIO.service[CompilerFactory] + // When a run resource is configured, `runtimeParent` is the per-run + // user loader U: the default compiler executes blocks against it so the + // resource is one shared instance for the whole run. defaultC <- factory.forVersion( defaultScalaVersion, defaultExtraClasspath, - defaultScalacOptions + defaultScalacOptions, + runtimeParent, + shareUserClasses = runtimeParent.isDefined ) cache = cacheDir.map(BlockCache.disk).getOrElse(BlockCache.noop) adapter = CompilerServiceAdapter.fromFactory( diff --git a/compiler/src/main/scala/marklit/MarklitRun.scala b/compiler/src/main/scala/marklit/MarklitRun.scala index b875158..8147b29 100644 --- a/compiler/src/main/scala/marklit/MarklitRun.scala +++ b/compiler/src/main/scala/marklit/MarklitRun.scala @@ -30,7 +30,8 @@ final case class MarklitRunConfig( check: Boolean = false, showVersion: Boolean = true, showWarnings: Boolean = true, - verbose: Boolean = false + verbose: Boolean = false, + runResourceClass: Option[String] = None ) /** Per-file outcome with no Console side effects. `rendered` is populated when @@ -196,24 +197,40 @@ object MarklitRun: } // Process each file with its own effective default version (file directive - // wins over the run default). - reports <- ZIO.foreach(perFile) { case (path, directives) => - val fileDefault = directives.scalaVersion.getOrElse(cliDefault) - val absPath = path.toAbsolutePath - Marklit - .processFile(absPath) - .map(result => buildReport(result, config)) - .provide( - ZLayer.succeed(factory), - Marklit.liveWithFactory( - fileDefault, - fullClasspath, - allScalacOptions, - majorClasspaths, - config.cacheDir, - if config.pageScope then ScopeMode.Page else ScopeMode.Isolated - ) - ) + // wins over the run default). The whole pass runs inside one scope: a + // build-provided run resource (when configured) is acquired once before + // any file and released after the last — so external state set up for the + // docs (a DB container, a temp dir, …) lives for exactly one run and is + // torn down before the next, even on failure. + reports <- ZIO.scoped { + acquireRunResource( + config, + factory, + fullClasspath, + cliDefault, + addNote + ).flatMap { runLoader => + ZIO.foreach(perFile) { case (path, directives) => + val fileDefault = directives.scalaVersion.getOrElse(cliDefault) + val absPath = path.toAbsolutePath + Marklit + .processFile(absPath) + .map(result => buildReport(result, config)) + .provide( + ZLayer.succeed(factory), + Marklit.liveWithFactory( + fileDefault, + fullClasspath, + allScalacOptions, + majorClasspaths, + config.cacheDir, + if config.pageScope then ScopeMode.Page + else ScopeMode.Isolated, + runLoader + ) + ) + } + } } // Write outputs only when the whole run succeeded — matching the CLI's @@ -244,6 +261,63 @@ object MarklitRun: noticeLines <- notices.get yield MarklitRunResult(reports, noticeLines.toVector) + /** Acquire the build-provided run resource (when configured) for the duration + * of the enclosing [[Scope]]. A no-op when `config.runResourceClass` is + * unset. + * + * The resource is a user class on the docs' own classpath implementing the + * JDK type `java.util.function.Supplier[AutoCloseable]`: `get()` performs + * setup (start a DB container, create a schema, …) and returns an + * `AutoCloseable` whose `close()` is the teardown. Using only JDK types + * keeps the instance usable across marklit's classloader boundary — see + * [[CompilerFactory.userClassLoader]]. + * + * Both `get()` and `close()` are best-effort with respect to the run: a + * setup failure is surfaced as a notice and lets the run proceed (the docs + * that need the resource will fail on their own and report it); a teardown + * failure is logged and swallowed so it never masks the run's real result. + */ + private def acquireRunResource( + config: MarklitRunConfig, + factory: CompilerFactory, + userClasspath: Vector[String], + defaultVersion: String, + addNote: String => UIO[Unit] + ): URIO[Scope, Option[ClassLoader]] = + config.runResourceClass match + case None => ZIO.none + case Some(fqn) => + for + // Build the per-run user loader U first. The resource instance lives + // in U, and every block executes against U (see liveWithFactory), so + // all blocks share the one instance for this run. + loader <- factory.userClassLoader(defaultVersion, userClasspath) + _ <- ZIO + .acquireRelease( + ZIO.attempt { + val supplier = Class + .forName(fqn, true, loader) + .getDeclaredConstructor() + .newInstance() + .asInstanceOf[java.util.function.Supplier[AutoCloseable]] + supplier.get() + } + )(handle => + ZIO + .attempt(handle.close()) + .catchAll(e => + addNote( + s"run resource '$fqn' teardown failed: ${e.getMessage}" + ) + ) + ) + .foldZIO( + e => + addNote(s"run resource '$fqn' setup failed: ${e.getMessage}"), + _ => ZIO.unit + ) + yield Some(loader) + /** Build a side-effect-free per-file report from a [[MarklitResult]], * rendering output markdown when an output dir is configured and the run is * not in check mode. diff --git a/compiler/src/main/scala/marklit/compiler/CompilerFactory.scala b/compiler/src/main/scala/marklit/compiler/CompilerFactory.scala index 327e56a..8cea58d 100644 --- a/compiler/src/main/scala/marklit/compiler/CompilerFactory.scala +++ b/compiler/src/main/scala/marklit/compiler/CompilerFactory.scala @@ -43,9 +43,26 @@ trait CompilerFactory: def forVersion( scalaVersion: String, extraClasspath: Vector[String] = Vector.empty, - scalacOptions: Vector[String] = Vector.empty + scalacOptions: Vector[String] = Vector.empty, + runtimeParent: Option[ClassLoader] = None, + shareUserClasses: Boolean = false ): UIO[Compiler] + /** A classloader that can load *user* classes (`extraClasspath`) against the + * per-version compiler stdlib — the same parent loader block execution uses + * ([[ScalaCompiler.executeCompiled]]). + * + * Used to instantiate a build-provided run resource (a + * `java.util.function.Supplier[AutoCloseable]`) once per run, outside the + * per-block execution path. The returned loader is scoped: it is closed when + * the surrounding [[Scope]] closes, so callers acquire it inside the run's + * `ZIO.scoped` boundary. + */ + def userClassLoader( + scalaVersion: String, + extraClasspath: Vector[String] + ): URIO[Scope, ClassLoader] + object CompilerFactory: /** Default Scala 3 version — the version the bundled 3.x shim was compiled @@ -226,7 +243,9 @@ object CompilerFactory: override def forVersion( scalaVersion: String, extraClasspath: Vector[String] = Vector.empty, - scalacOptions: Vector[String] = Vector.empty + scalacOptions: Vector[String] = Vector.empty, + runtimeParent: Option[ClassLoader] = None, + shareUserClasses: Boolean = false ): UIO[Compiler] = bundleFor(scalaVersion).map { bundle => // outputDir per call is fine — the heavy work (classloader + Coursier @@ -239,10 +258,36 @@ object CompilerFactory: scalacOptions = scalacOptions, outputDir = outputDir, scalaVersion = scalaVersion, - runtimeLoader = Some(bundle.loader) + // When a run resource is configured, `runtimeParent` is the per-run + // user loader U (itself a child of bundle.loader, so it still sees the + // matching stdlib). Blocks then load user classes from U and share the + // resource instance. Otherwise execution parents off bundle.loader as + // before. + runtimeLoader = Some(runtimeParent.getOrElse(bundle.loader)), + shareUserClasses = shareUserClasses ) } + override def userClassLoader( + scalaVersion: String, + extraClasspath: Vector[String] + ): URIO[Scope, ClassLoader] = + bundleFor(scalaVersion).flatMap { bundle => + ZIO.acquireRelease( + ZIO.succeed { + val urls: Array[URL] = + extraClasspath + .map(p => java.nio.file.Paths.get(p).toUri.toURL) + .toArray + new URLClassLoader(urls, bundle.loader): ClassLoader + } + ) { + case cl: URLClassLoader => + ZIO.attempt(cl.close()).ignore + case _ => ZIO.unit + } + } + private def bundleFor(scalaVersion: String): UIO[VersionBundle] = cache.get.flatMap { existing => existing.get(scalaVersion) match diff --git a/compiler/src/main/scala/marklit/compiler/ScalaCompiler.scala b/compiler/src/main/scala/marklit/compiler/ScalaCompiler.scala index 95c9f54..43296b6 100644 --- a/compiler/src/main/scala/marklit/compiler/ScalaCompiler.scala +++ b/compiler/src/main/scala/marklit/compiler/ScalaCompiler.scala @@ -34,6 +34,14 @@ import scala.jdk.CollectionConverters.* * classloader used to resolve user-code classes at execution time. When * provided, this is the per-version compiler loader from the factory; when * None we fall back to `getClass.getClassLoader` (legacy in-process path). + * @param shareUserClasses + * when true, every block (including ZIO blocks) loads user classes + * parent-first from [[runtimeLoader]] instead of child-first. This makes a + * build-provided run resource a single shared instance across all blocks in + * the run — at the cost of the per-block-fresh init that the child-first + * loader otherwise gives ZIO blocks. Enabled only when a run resource is + * configured (see [[CompilerFactory.userClassLoader]]); the default path is + * unchanged. */ final class ScalaCompiler( invoker: DotcInvoker, @@ -41,7 +49,8 @@ final class ScalaCompiler( scalacOptions: Vector[String], outputDir: Path, override val scalaVersion: String, - runtimeLoader: Option[ClassLoader] = None + runtimeLoader: Option[ClassLoader] = None, + shareUserClasses: Boolean = false ) extends Compiler: /** Whether we're compiling Scala 3 code */ @@ -204,7 +213,17 @@ final class ScalaCompiler( // current loader — fine for single-version use. val parent = runtimeLoader.getOrElse(getClass.getClassLoader) val classLoader = - if context.isZIOApp then new ChildFirstClassLoader(urls, parent) + if shareUserClasses then + // Run-resource mode: `parent` is the per-run user loader U, which + // already holds the user classpath (incl. ZIO). A standard + // parent-first URLClassLoader resolves every user class from U, so + // a build-provided resource is one shared instance across all + // blocks; only this block's own `MarklitWrapper$` (in + // classFilesDir, not in U) loads from the child URLs. ZIO blocks + // use this path too — sharing is chosen over per-block-fresh init. + new java.net.URLClassLoader(urls, parent) + else if context.isZIOApp then + new ChildFirstClassLoader(urls, parent) else new java.net.URLClassLoader(urls, parent) // Redirect scala.Console on the *user code's* classloader. Each diff --git a/compiler/src/test/java/marklit/testfixtures/EventLog.java b/compiler/src/test/java/marklit/testfixtures/EventLog.java new file mode 100644 index 0000000..5985adf --- /dev/null +++ b/compiler/src/test/java/marklit/testfixtures/EventLog.java @@ -0,0 +1,31 @@ +package marklit.testfixtures; + +import java.nio.file.Files; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; + +/** + * Append-only lifecycle event channel keyed by the {@code marklit.test.events} + * system property. Because {@code System} is a platform class shared by every + * classloader, the test and marklit's per-run user loader {@code U} agree on the + * file path, letting the test observe acquire/teardown events recorded from + * {@code U} across the classloader boundary. + */ +public final class EventLog { + private EventLog() {} + + public static void record(String event) { + String path = System.getProperty("marklit.test.events"); + if (path == null) return; + try { + Files.write( + Paths.get(path), + (event + "\n").getBytes("UTF-8"), + StandardOpenOption.CREATE, + StandardOpenOption.APPEND + ); + } catch (Exception e) { + // best-effort; a test will fail on the missing event instead. + } + } +} diff --git a/compiler/src/test/java/marklit/testfixtures/RecordingResource.java b/compiler/src/test/java/marklit/testfixtures/RecordingResource.java new file mode 100644 index 0000000..d439d90 --- /dev/null +++ b/compiler/src/test/java/marklit/testfixtures/RecordingResource.java @@ -0,0 +1,17 @@ +package marklit.testfixtures; + +import java.util.function.Supplier; + +/** + * A well-behaved run resource: records {@code acquire} on setup and {@code close} + * on teardown, exactly once each per run. Implements the JDK seam + * {@code Supplier} that marklit instantiates by FQN on its per-run + * user loader. + */ +public final class RecordingResource implements Supplier { + @Override + public AutoCloseable get() { + EventLog.record("acquire"); + return () -> EventLog.record("close"); + } +} diff --git a/compiler/src/test/java/marklit/testfixtures/SharedRunState.java b/compiler/src/test/java/marklit/testfixtures/SharedRunState.java new file mode 100644 index 0000000..4754e97 --- /dev/null +++ b/compiler/src/test/java/marklit/testfixtures/SharedRunState.java @@ -0,0 +1,21 @@ +package marklit.testfixtures; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Shared in-JVM state observed by doc blocks via their printed output. + * + * When a run resource is configured, blocks execute against marklit's per-run + * user loader {@code U}, so every block sees this one instance and successive + * {@code incrementAndGet()} calls count up across blocks. Without a resource, + * each block reloads the class fresh and always reads {@code 1}. + * + * Written in Java (not Scala) on purpose: a Java class file carries no TASTy, so + * marklit's bundled compiler reads it regardless of the Scala version the test + * project was built with. This also mirrors the real seam, which is JDK-typed. + */ +public final class SharedRunState { + private SharedRunState() {} + + public static final AtomicInteger counter = new AtomicInteger(0); +} diff --git a/compiler/src/test/java/marklit/testfixtures/ThrowingSetupResource.java b/compiler/src/test/java/marklit/testfixtures/ThrowingSetupResource.java new file mode 100644 index 0000000..f408f25 --- /dev/null +++ b/compiler/src/test/java/marklit/testfixtures/ThrowingSetupResource.java @@ -0,0 +1,14 @@ +package marklit.testfixtures; + +import java.util.function.Supplier; + +/** + * A run resource whose setup throws — exercises the contract that a setup + * failure is surfaced as a notice and lets the run proceed. + */ +public final class ThrowingSetupResource implements Supplier { + @Override + public AutoCloseable get() { + throw new RuntimeException("boom-setup"); + } +} diff --git a/compiler/src/test/java/marklit/testfixtures/ThrowingTeardownResource.java b/compiler/src/test/java/marklit/testfixtures/ThrowingTeardownResource.java new file mode 100644 index 0000000..8694177 --- /dev/null +++ b/compiler/src/test/java/marklit/testfixtures/ThrowingTeardownResource.java @@ -0,0 +1,14 @@ +package marklit.testfixtures; + +import java.util.function.Supplier; + +/** + * A run resource whose teardown throws — exercises the contract that a teardown + * failure is logged as a notice and swallowed, never failing the run. + */ +public final class ThrowingTeardownResource implements Supplier { + @Override + public AutoCloseable get() { + return () -> { throw new RuntimeException("boom-teardown"); }; + } +} diff --git a/compiler/src/test/scala/marklit/RunResourceSpec.scala b/compiler/src/test/scala/marklit/RunResourceSpec.scala new file mode 100644 index 0000000..1cbbe2d --- /dev/null +++ b/compiler/src/test/scala/marklit/RunResourceSpec.scala @@ -0,0 +1,271 @@ +package marklit + +import zio.* +import zio.test.* + +import java.nio.file.{Files, Path} + +/** End-to-end tests for the build-provided run-scoped resource + * ([[MarklitRunConfig.runResourceClass]]). + * + * The resource class is loaded by marklit's per-run user loader `U`, a + * different classloader from this test, so state is observed across the + * boundary via (a) a file named by the `marklit.test.events` system property + * for lifecycle events, and (b) rendered block output for the shared in-JVM + * counter. See `marklit.testfixtures.RunResourceFixtures`. + */ +object RunResourceSpec extends ZIOSpecDefault: + + /** User classpath for the run, gathered from the test's own classloader URL + * chain (sbt's test runner uses layered classloaders, so `java.class.path` + * is unreliable here). Minus the Scala stdlib, which the per-version + * compiler loader supplies — leaving the host's copy on would clash. This + * mirrors how a build forwards its `fullClasspath` and gives `U` the + * fixtures *and* the full ZIO dependency set a zio-app block needs to + * compile (marklit's `ApiOnlyParent` hides the host's copies, so they must + * be passed explicitly). + */ + private val testClasspath: Vector[String] = + def loaderUrls(cl: ClassLoader): Vector[String] = + val here = cl match + case u: java.net.URLClassLoader => + u.getURLs.toVector.map(url => + java.nio.file.Paths.get(url.toURI).toString + ) + case _ => Vector.empty + val parent = Option(cl.getParent).map(loaderUrls).getOrElse(Vector.empty) + parent ++ here + loaderUrls(getClass.getClassLoader).distinct.filterNot { entry => + val name = java.nio.file.Paths.get(entry).getFileName.toString + name.startsWith("scala-library") || name.startsWith("scala3-library") + } + + private def tempMd(content: String): ZIO[Scope, Throwable, Path] = + ZIO.acquireRelease( + ZIO.attempt { + val p = Files.createTempFile("marklit-runres-", ".md") + Files.writeString(p, content) + p + } + )(p => ZIO.attempt(Files.deleteIfExists(p)).ignore) + + private def tempDir: ZIO[Scope, Throwable, Path] = + ZIO.acquireRelease( + ZIO.attempt(Files.createTempDirectory("marklit-runres-out-")) + )(p => + ZIO.attempt { + if Files.exists(p) then + Files + .walk(p) + .sorted(java.util.Comparator.reverseOrder()) + .forEach(f => Files.deleteIfExists(f): Unit) + }.ignore + ) + + /** A fresh temp file whose path is published to the `marklit.test.events` + * system property for the lifetime of the enclosing scope, then cleared. The + * property is how the fixture (on loader `U`) and this test agree on the + * event file across the classloader boundary. + */ + private def eventsFile: ZIO[Scope, Throwable, Path] = + ZIO.acquireRelease( + ZIO.attempt { + val p = Files.createTempFile("marklit-events-", ".log") + java.lang.System.setProperty("marklit.test.events", p.toString) + p + } + )(p => + ZIO.attempt { + java.lang.System.clearProperty("marklit.test.events") + Files.deleteIfExists(p): Unit + }.ignore + ) + + private def readLines(p: Path): ZIO[Any, Throwable, Vector[String]] = + ZIO.attempt { + import scala.jdk.CollectionConverters.* + if Files.exists(p) then + Files.readAllLines(p).asScala.toVector.filter(_.nonEmpty) + else Vector.empty + } + + /** Pin the run to the Scala version this test was built with, so the ZIO jar + * on [[testClasspath]] (built for that version) has TASTy the resolved + * compiler can read. The bundled default shim is older and would reject + * newer ZIO TASTy — a version-skew artifact of the test harness, not of the + * feature. The per-run user loader `U` is built at this same version, so + * sharing lines up. + */ + private val hostScalaVersion: String = + scala.util.Properties.versionNumberString + + private def runConfig( + file: Path, + outDir: Path, + resource: Option[String] + ): MarklitRunConfig = + MarklitRunConfig( + inputFiles = Vector(file), + outputDir = Some(outDir), + scalaVersion = Some(hostScalaVersion), + classpath = testClasspath, + runResourceClass = resource + ) + + // Two anonymous blocks, each incrementing the shared counter and printing it. + private val counterDoc = + """```scala + |println("counter=" + marklit.testfixtures.SharedRunState.counter.incrementAndGet()) + |``` + | + |```scala + |println("counter=" + marklit.testfixtures.SharedRunState.counter.incrementAndGet()) + |``` + |""".stripMargin + + def spec = suite("RunResource")( + test("no resource configured: lifecycle never runs, counter not shared") { + // Baseline / no-op contract: without runResourceClass, behavior is exactly + // as before — each block reloads user classes fresh, so the shared counter + // reads 1 in both blocks, and no events file is written. + ZIO.scoped { + for + events <- eventsFile + file <- tempMd(counterDoc) + outDir <- tempDir + result <- MarklitRun.run(runConfig(file, outDir, None)) + rendered = result.files.head.rendered.getOrElse("") + eventLines <- readLines(events) + yield assertTrue( + result.success, + // Each block sees a freshly-loaded counter (starts at 0 → 1). + rendered.split("counter=1").length - 1 == 2, + !rendered.contains("counter=2"), + eventLines.isEmpty + ) + } + }, + test("resource lifecycle runs exactly once, acquire before teardown") { + ZIO.scoped { + for + events <- eventsFile + file <- tempMd(counterDoc) + outDir <- tempDir + result <- MarklitRun.run( + runConfig( + file, + outDir, + Some("marklit.testfixtures.RecordingResource") + ) + ) + eventLines <- readLines(events) + yield assertTrue( + result.success, + // Acquired once before any doc, closed once after the last. + eventLines == Vector("acquire", "close") + ) + } + }, + test("resource configured: a plain object is shared across blocks") { + ZIO.scoped { + for + _ <- eventsFile + file <- tempMd(counterDoc) + outDir <- tempDir + result <- MarklitRun.run( + runConfig( + file, + outDir, + Some("marklit.testfixtures.RecordingResource") + ) + ) + rendered = result.files.head.rendered.getOrElse("") + yield assertTrue( + result.success, + // One shared counter (loaded by U) → blocks count up 1 then 2. + rendered.contains("counter=1"), + rendered.contains("counter=2") + ) + } + }, + test("resource configured: sharing reaches a zio-app block too") { + // The critical zio-app check: when sharing is on, a zio-app block loads + // ZIO from the same loader U as the shared object, so `Console.printLine` + // type-checks against the shared ZIO (no LinkageError) AND the zio-app + // block sees the same counter a preceding plain block incremented. + val mixedDoc = + """```scala + |println("counter=" + marklit.testfixtures.SharedRunState.counter.incrementAndGet()) + |``` + | + |```scala marklit:zio-app + |zio.Console.printLine( + | "counter=" + marklit.testfixtures.SharedRunState.counter.incrementAndGet() + |) + |``` + |""".stripMargin + ZIO.scoped { + for + _ <- eventsFile + file <- tempMd(mixedDoc) + outDir <- tempDir + result <- MarklitRun.run( + runConfig( + file, + outDir, + Some("marklit.testfixtures.RecordingResource") + ) + ) + rendered = result.files.head.rendered.getOrElse("") + yield assertTrue( + result.success, + rendered.contains("counter=1"), + // zio-app block saw the plain block's increment → 2. + rendered.contains("counter=2") + ) + } + }, + test("setup failure is a notice; the run still proceeds") { + ZIO.scoped { + for + _ <- eventsFile + file <- tempMd(counterDoc) + outDir <- tempDir + result <- MarklitRun.run( + runConfig( + file, + outDir, + Some("marklit.testfixtures.ThrowingSetupResource") + ) + ) + yield assertTrue( + // The run is not failed by a resource setup error — docs still process. + result.success, + result.notices.exists(n => + n.contains("setup failed") && n.contains("boom-setup") + ) + ) + } + }, + test("teardown failure is a notice; the run still succeeds") { + ZIO.scoped { + for + _ <- eventsFile + file <- tempMd(counterDoc) + outDir <- tempDir + result <- MarklitRun.run( + runConfig( + file, + outDir, + Some("marklit.testfixtures.ThrowingTeardownResource") + ) + ) + yield assertTrue( + result.success, + result.notices.exists(n => + n.contains("teardown failed") && n.contains("boom-teardown") + ) + ) + } + } + ) @@ TestAspect.sequential @@ TestAspect.timeout(180.seconds) diff --git a/mill-plugin/src/marklit/mill/MarklitModule.scala b/mill-plugin/src/marklit/mill/MarklitModule.scala index 59f34dd..585fd02 100644 --- a/mill-plugin/src/marklit/mill/MarklitModule.scala +++ b/mill-plugin/src/marklit/mill/MarklitModule.scala @@ -55,6 +55,15 @@ trait MarklitModule extends ScalaModule { */ def marklitPageScope: T[Boolean] = false + /** FQN of a build-provided run resource: a class on the docs' compile + * classpath implementing `java.util.function.Supplier[AutoCloseable]`. Its + * `get()` runs once before any doc is processed (start a DB container, create + * a schema, …) and the returned `AutoCloseable.close()` once after the last + * doc, even on failure. With a resource set, all blocks share one instance + * for the run. Defaults to `None` (disabled). + */ + def marklitRunResourceClass: T[Option[String]] = None + /** Additional classpath entries to pass to marklit. By default, uses this * module's compile classpath, filtering out Scala standard library jars to * avoid version conflicts (marklit resolves its own Scala library). @@ -159,6 +168,7 @@ trait MarklitModule extends ScalaModule { val scalaVer = scalaVersion() val cacheDirOpt = marklitCacheDir().map(_.path) val pageScope = marklitPageScope() + val runResourceClass = marklitRunResourceClass() val worker = marklitWorker() if (!os.exists(sourceDir)) { @@ -186,7 +196,8 @@ trait MarklitModule extends ScalaModule { taskLabel = "generation", log = Task.log, cacheDir = cacheDirOpt, - pageScope = pageScope + pageScope = pageScope, + runResourceClass = runResourceClass ) sources.map(source => PathRef(targetDir / source.last)) @@ -206,6 +217,7 @@ trait MarklitModule extends ScalaModule { val scalaVer = scalaVersion() val cacheDirOpt = marklitCacheDir().map(_.path) val pageScope = marklitPageScope() + val runResourceClass = marklitRunResourceClass() val worker = marklitWorker() if (!os.exists(sourceDir)) { @@ -231,7 +243,8 @@ trait MarklitModule extends ScalaModule { taskLabel = "check", log = Task.log, cacheDir = cacheDirOpt, - pageScope = pageScope + pageScope = pageScope, + runResourceClass = runResourceClass ) } } @@ -254,7 +267,8 @@ trait MarklitModule extends ScalaModule { taskLabel: String, log: mill.api.daemon.Logger, cacheDir: Option[os.Path], - pageScope: Boolean + pageScope: Boolean, + runResourceClass: Option[String] ): Unit = { val config = MarklitRunConfig( inputFiles = sources.map(_.toNIO).toVector, @@ -265,6 +279,7 @@ trait MarklitModule extends ScalaModule { classpath3 = majorClasspaths.getOrElse("3", Seq.empty).toVector, cacheDir = cacheDir.map(_.toNIO), pageScope = pageScope, + runResourceClass = runResourceClass, check = check, showVersion = showVersion, showWarnings = showWarnings, diff --git a/sbt-plugin/src/main/scala/marklit/sbt/MarklitPlugin.scala b/sbt-plugin/src/main/scala/marklit/sbt/MarklitPlugin.scala index d241f45..83f9d38 100644 --- a/sbt-plugin/src/main/scala/marklit/sbt/MarklitPlugin.scala +++ b/sbt-plugin/src/main/scala/marklit/sbt/MarklitPlugin.scala @@ -40,6 +40,16 @@ object MarklitPlugin extends AutoPlugin { "Per-major classpath overrides for cross-version blocks (key = Scala major like \"2\" or \"3\")" ) + // Fully-qualified name of a build-provided run resource: a class on the + // docs' compile classpath implementing `java.util.function.Supplier< + // AutoCloseable>`. Its `get()` is invoked once before any doc is processed + // (start a DB container, create a schema, …) and the returned + // `AutoCloseable.close()` once after the last doc (even on failure). With a + // resource set, all blocks share one instance for the run. `None` disables. + val marklitRunResourceClass = settingKey[Option[String]]( + "FQN of a java.util.function.Supplier[AutoCloseable] acquired once per run and closed at run end (None disables)" + ) + // Tasks val marklitCompile = taskKey[Unit]("Compile and verify markdown code blocks") @@ -270,6 +280,7 @@ object MarklitPlugin extends AutoPlugin { marklitShowWarnings := true, marklitVerbose := false, marklitPageScope := false, + marklitRunResourceClass := None, marklitCacheDirectory := Some(target.value / "marklit-cache"), // Uncached: yields Map[String, Seq[File]], not a cacheable output type. marklitMajorClasspaths := Def.uncached(autoMajorClasspaths.value), @@ -300,6 +311,7 @@ object MarklitPlugin extends AutoPlugin { val (cp2, cp3) = majorClasspathStrings(marklitMajorClasspaths.value) val cacheDir = marklitCacheDirectory.value val pageScope = marklitPageScope.value + val runResourceClass = marklitRunResourceClass.value if (!sourceDir.exists()) { log.info(s"[marklit] No source directory: $sourceDir") @@ -318,6 +330,7 @@ object MarklitPlugin extends AutoPlugin { classpath3 = cp3, cacheDir = cacheDir.map(_.toPath), pageScope = pageScope, + runResourceClass = runResourceClass, check = true, verbose = verbose ) @@ -344,6 +357,7 @@ object MarklitPlugin extends AutoPlugin { val (cp2, cp3) = majorClasspathStrings(marklitMajorClasspaths.value) val cacheDir = marklitCacheDirectory.value val pageScope = marklitPageScope.value + val runResourceClass = marklitRunResourceClass.value if (!sourceDir.exists()) { log.info(s"[marklit] No source directory: $sourceDir") @@ -367,6 +381,7 @@ object MarklitPlugin extends AutoPlugin { classpath3 = cp3, cacheDir = cacheDir.map(_.toPath), pageScope = pageScope, + runResourceClass = runResourceClass, check = false, showVersion = showVersion, showWarnings = showWarnings, diff --git a/sbt-plugin/src/sbt-test/marklit/run-resource/build.sbt b/sbt-plugin/src/sbt-test/marklit/run-resource/build.sbt new file mode 100644 index 0000000..9ce43f6 --- /dev/null +++ b/sbt-plugin/src/sbt-test/marklit/run-resource/build.sbt @@ -0,0 +1,50 @@ +// Lifecycle-event log file, shared with the run resource via a system property. +val eventsFile = settingKey[File]("Run-resource lifecycle event log") + +lazy val docs = project + .in(file(".")) + .settings( + scalaVersion := "3.8.2", + marklitSourceDirectory := baseDirectory.value / "markdown", + marklitTargetDirectory := baseDirectory.value / "target" / "docs", + // The run resource lives in this project's own source tree, so it's on the + // docs compile classpath that marklit forwards. + marklitRunResourceClass := Some("example.CounterResource"), + marklitVerbose := true, + eventsFile := baseDirectory.value / "target" / "events.log", + // Publish the events-file path to the JVM the marklit task runs in, so the + // resource (loaded on marklit's own classloader) writes to the same file. + Global / onLoad := { + System.setProperty( + "marklit.scripted.events", + (baseDirectory.value / "target" / "events.log").getAbsolutePath + ) + (Global / onLoad).value + } + ) + +// Assert the executed block saw the run-scoped shared counter at value 1 — the +// resource was acquired (and the counter reset) before the block ran. +TaskKey[Unit]("checkOutput") := { + val out = baseDirectory.value / "target" / "docs" / "doc.md" + val content = IO.read(out) + if (!content.contains("counter = 1")) + sys.error(s"expected 'counter = 1' in $out, got:\n$content") +} + +// Assert the lifecycle ran exactly once per generate, acquire before close. +// After two generates in one session the log must be acquire,close,acquire,close. +TaskKey[Unit]("checkLifecycle") := { + val log = eventsFile.value + val lines = + if (log.exists()) IO.readLines(log).filter(_.nonEmpty) else Nil + val expected = List("acquire", "close", "acquire", "close") + if (lines != expected) + sys.error(s"expected lifecycle $expected, got: $lines") +} + +// Reset the event log before the run sequence. +TaskKey[Unit]("resetEvents") := { + val log = eventsFile.value + IO.delete(log) +} diff --git a/sbt-plugin/src/sbt-test/marklit/run-resource/markdown/doc.md b/sbt-plugin/src/sbt-test/marklit/run-resource/markdown/doc.md new file mode 100644 index 0000000..58eeff6 --- /dev/null +++ b/sbt-plugin/src/sbt-test/marklit/run-resource/markdown/doc.md @@ -0,0 +1,9 @@ +# Run-scoped resource + +The build configures `marklitRunResourceClass`, so every block shares one +`example.Counter` instance for the whole run, and the resource resets it at run +end. This block increments and prints it: + +```scala +println(s"counter = ${example.Counter.value.incrementAndGet()}") +``` diff --git a/sbt-plugin/src/sbt-test/marklit/run-resource/project/build.properties b/sbt-plugin/src/sbt-test/marklit/run-resource/project/build.properties new file mode 100644 index 0000000..666624a --- /dev/null +++ b/sbt-plugin/src/sbt-test/marklit/run-resource/project/build.properties @@ -0,0 +1 @@ +sbt.version=2.0.0 diff --git a/sbt-plugin/src/sbt-test/marklit/run-resource/project/plugins.sbt b/sbt-plugin/src/sbt-test/marklit/run-resource/project/plugins.sbt new file mode 100644 index 0000000..c0fd9e5 --- /dev/null +++ b/sbt-plugin/src/sbt-test/marklit/run-resource/project/plugins.sbt @@ -0,0 +1,3 @@ +addSbtPlugin( + "io.github.russwyte" % "sbt-marklit" % sys.props("plugin.version") +) diff --git a/sbt-plugin/src/sbt-test/marklit/run-resource/src/main/scala/example/CounterResource.scala b/sbt-plugin/src/sbt-test/marklit/run-resource/src/main/scala/example/CounterResource.scala new file mode 100644 index 0000000..0341863 --- /dev/null +++ b/sbt-plugin/src/sbt-test/marklit/run-resource/src/main/scala/example/CounterResource.scala @@ -0,0 +1,45 @@ +package example + +import java.nio.file.{Files, Paths, StandardOpenOption} +import java.util.concurrent.atomic.AtomicInteger +import java.util.function.Supplier + +/** Run-scoped shared state for the scripted test, mirroring the real-world + * pattern (e.g. a database handle): a single instance that doc blocks mutate. + * + * Because the build sets `marklitRunResourceClass`, every block in a run + * executes against marklit's per-run user loader, so they all see this one + * `value`. + */ +object Counter: + val value = new AtomicInteger(0) + +/** Append-only lifecycle log, keyed by a system property so the build's + * `checkLifecycle` task and the resource (which marklit loads on its own + * classloader) agree on the file. `System` is a platform class shared by every + * loader, so the path crosses the classloader boundary. + */ +object Events: + // `import` order intentionally avoids shadowing; this is plain java.lang.System. + def record(event: String): Unit = + val path = java.lang.System.getProperty("marklit.scripted.events") + if path != null then + Files.write( + Paths.get(path), + (event + "\n").getBytes("UTF-8"), + StandardOpenOption.CREATE, + StandardOpenOption.APPEND + ) + +/** The build-provided run resource. `get()` is setup (run once before any doc), + * and the returned `AutoCloseable` is teardown (run once after the last doc, + * even on failure). Implements the JDK `Supplier[AutoCloseable]` seam, so this + * class needs no dependency on marklit. + */ +final class CounterResource extends Supplier[AutoCloseable]: + def get(): AutoCloseable = + Counter.value.set(0) + Events.record("acquire") + () => + Counter.value.set(0) + Events.record("close") diff --git a/sbt-plugin/src/sbt-test/marklit/run-resource/test b/sbt-plugin/src/sbt-test/marklit/run-resource/test new file mode 100644 index 0000000..d4dd0e2 --- /dev/null +++ b/sbt-plugin/src/sbt-test/marklit/run-resource/test @@ -0,0 +1,18 @@ +# Start with a clean lifecycle log. +> resetEvents + +# First generate: resource acquired once, block sees the shared counter at 1, +# resource closed once. +> docs/marklitGenerate +$ exists target/docs/doc.md +> checkOutput + +# Second generate in the SAME sbt session: the warm factory survives, but the +# run resource is re-acquired and re-closed for this run — so the block again +# reads 1, not 2. +> docs/marklitGenerate +> checkOutput + +# Across the two runs the lifecycle fired exactly once each, acquire before +# close: acquire,close,acquire,close. +> checkLifecycle