Skip to content

Add top-level fence marker for unwrapped, compile-only Scala blocks#2

Merged
russwyte merged 2 commits into
mainfrom
add-top-scope
May 31, 2026
Merged

Add top-level fence marker for unwrapped, compile-only Scala blocks#2
russwyte merged 2 commits into
mainfrom
add-top-scope

Conversation

@russwyte

Copy link
Copy Markdown
Collaborator

What

Adds a top-level fence modifier that compiles a Scala block verbatim as its own compilation unit — no object MarklitWrapper: def run() wrapper. Such blocks are compile-only (code + diagnostics render, never executed). A top-level definition can be hoisted into a running example via id=/extends=.

```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")   // compiles clean, prints "set 10"
  case _                    => println("other")
```

Why

Wrapping every block in def run() makes user code a method body, which breaks or warns on top-level-only constructs (empirically confirmed against the bundled shim):

  • Local-class warning (the real driver): matching a parameterized enum/ADT case against a non-local scrutinee emits the type test for X cannot be checked at runtime because it's a local class — because an enum inside def run() is a local class. This is the warning that forced show-warnings=false across the conduit docs. Hoisting the enum to file scope removes it.
  • opaque type and @main def fail outright inside the wrapper.

vs. mdoc

Confirmed from mdoc's own docs: mdoc wraps blocks in a class, and its only escape (reset-object) wraps in an object and resets the scope. It has no top-level modifier and can't share a definition forward. marklit's top-level compiles at genuine file scope and hoists forward.

How

  • Modifier.TopLevel + CodeBlock.isTopLevel/modifierConflictstop-level is strict (only scope options, a version selector, and show-warnings may accompany it).
  • ScopeContext carries topLevel + topLevelPriorCode; ScalaCompiler bypasses the wrapper/marker for top-level blocks and hoists inherited definitions above the wrapper for normal consumers.
  • ScopeManager tags scopes by kind, splits inherited code into hoist/body buckets, and guards cross-kind extends/append/id-reuse. A normal block may extend a top-level scope; the reverse is rejected.
  • Version selectors compose: top-level,scala=3.7.3 compiles against that version, and a consumer must agree.
  • BlockCacheKey includes the new inputs (key prefix bumped to v3).

Docs & example

  • README: feature-matrix rows (incl. the mdoc comparison) + a "Top-level blocks" section.
  • New worked example examples/base/src/main/markdown/top-level.md (added to the index), demonstrating the warning contrast, opaque type, @main, and a cross-version given/extension. Verified by rendering it through the example's sbt build.

Tests

Positive / negative / pathological coverage across model, parser, compiler, cache, scope, processor, and end-to-end (MarklitSpec, both isolated and --page-scope) layers, including a regression test pinning the local-class warning behavior and cross-version cases. Full suite green (261 core + 63 compiler + Java).

russwyte added 2 commits May 31, 2026 12:56
Every Scala block is normally wrapped in `object MarklitWrapper: def
run(): Unit = …`, so user code is a method body. Constructs that are
only legal — or only warning-free — at the top level of a source file
can't be demonstrated that way:

  - `opaque type` and `@main def` fail to compile inside the wrapper.
  - 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()` is
    a local class. This is the warning that forced `show-warnings=false`
    across the conduit docs.

The `top-level` modifier compiles a block verbatim as its own
compilation unit (no wrapper). Such blocks are compile-only: the code
and any diagnostics render, but they never execute. A top-level
definition can be hoisted into a running example by giving it an `id=`
and `extends=`-ing it from a normal block — marklit emits the definition
at file scope while the example runs inside the wrapper, so it compiles
cleanly and shows output.

Details:
  - Modifier.TopLevel + CodeBlock.isTopLevel/modifierConflicts; top-level
    is strict (only scope options, a version selector, and show-warnings
    may accompany it).
  - ScopeContext carries topLevel + topLevelPriorCode; ScalaCompiler
    bypasses the wrapper/marker for top-level blocks and hoists inherited
    top-level definitions above the wrapper for normal consumers.
  - ScopeManager tags scopes by kind, partitions inherited code into
    hoist/body buckets, and guards cross-kind extends/append/reuse. A
    normal block may extend a top-level scope; the reverse is rejected.
  - Version selectors compose: top-level blocks honor scala=<major> and
    scala=<version>, and a consumer must agree on the version.
  - BlockCacheKey includes topLevel + topLevelPriorCode (key prefix v3).

Adds README docs (feature matrix rows + a section, with an mdoc
comparison), a worked example (examples/.../top-level.md, indexed), and
positive/negative/pathological tests across the model, parser, compiler,
cache, scope, processor, and end-to-end layers.
Adds .github/workflows/ci.yml: on push to main and on every PR, runs
`sbt "scalafmtCheckAll;test"` — verifies formatting, then compiles and
runs the full test suite in a single sbt invocation.

Reformats the repo with scalafmt so the new format gate passes. The
build.sbt and MarkdownRenderer.scala changes are pre-existing formatting
drift unrelated to the top-level feature; the rest are the feature's own
files. All changes are whitespace/wrapping only.
@russwyte russwyte merged commit a2aa840 into main May 31, 2026
1 check passed
@russwyte russwyte deleted the add-top-scope branch May 31, 2026 18:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant