Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
23 changes: 17 additions & 6 deletions cli/src/main/scala/marklit/cli/MarklitCli.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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],
Expand All @@ -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] =
Expand All @@ -184,7 +192,8 @@ object MarklitCli extends ZIOCliDefault:
d,
idle,
cache,
ps
ps,
runRes
) =
opts
MarklitOptions(
Expand All @@ -204,7 +213,8 @@ object MarklitCli extends ZIOCliDefault:
d,
idle,
cache,
ps
ps,
runRes
)
}
.withHelp(
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 8 additions & 2 deletions compiler/src/main/scala/marklit/Marklit.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
112 changes: 93 additions & 19 deletions compiler/src/main/scala/marklit/MarklitRun.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
51 changes: 48 additions & 3 deletions compiler/src/main/scala/marklit/compiler/CompilerFactory.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
23 changes: 21 additions & 2 deletions compiler/src/main/scala/marklit/compiler/ScalaCompiler.scala
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,23 @@ 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,
classpath: Vector[String],
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 */
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading