Owner's headline feature: Chimney's OWN macro extensions where an integration is asked whether it special-cases a pair of types, with the handlers getting access to the derivation engine (incl. recursive derivation), so they support both top-level types AND types whose outer layer the extension builds while deferring inner values to Chimney recursively.
Reuses Hearth's generic MacroExtension[Macro]/ServiceLoader machinery with a Chimney-specific Macro type (NOT a
parallel loader) - exactly mirroring hearth.std.StandardMacroExtension = MacroExtension[MacroCommons & StdExtensions].
io.scalaland.chimney.integrations.ChimneyMacroExtension(public-facing base,private[chimney]for now):abstract class ChimneyMacroExtension extends hearth.MacroExtension[hearth.MacroCommons & ChimneyEngineExtensionApi]. Integration authors extend it and implementdef extend(ctx: MacroCommons & ChimneyEngineExtensionApi): Unit, registered viaMETA-INF/services/io.scalaland.chimney.integrations.ChimneyMacroExtension.…derivation.transformer.ChimneyEngineExtensionApi(private[chimney], mixed into the transformerDerivationcake;extends Contexts with rules.TransformationRulesso the re-exposed engine types resolve onctxcross-file):type SpecialCaseContext[From, To] = TransformationContext[From, To],type DerivedExpr[A] = TransformationExpr[A](re-exposed engine types),trait SpecialCaseHandler { def apply[From, To](implicit Type[From], Type[To]): Option[SpecialCasedTransformation[From, To]] },trait SpecialCasedTransformation[From, To] { def specialCase(implicit ctx: SpecialCaseContext[From, To]): MIO[Option[DerivedExpr[To]]] }(Some(expr)= produced;None= decline-after-matching → derivation continues),object IsChimneySpecialCased { def unapply[From, To]((Type[From], Type[To])): Option[SpecialCasedTransformation[From, To]] }(owner's extractor),registerSpecialCase(handler), result buildersspecialCasedTotal/Partial/Expr,specialCaseYield,- recursion:
deriveInner[InnerFrom: Type, InnerTo: Type](Expr[InnerFrom])(implicit SpecialCaseContext[?, ?]): MIO[DerivedExpr[InnerTo]](delegates to the engine'sderiveRecursiveTransformationExpr; N inners = N calls, composed viaDerivedExpr.flatMap/map), - context accessors
sourceOf/isPartialContext/prefersPartialTransformer(theTransformationContextmembers stayprotected; reached only via these facade helpers), ensureChimneyMacroExtensionsLoaded()load-once guard (mirrorsensureStandardExtensionsLoaded), callingEnvironment.loadMacroExtensions[ChimneyMacroExtension].
- New rule
…rules.TransformSpecialCasedRuleModule(TransformSpecialCasedRule): loads handlers once, then(Type[From], Type[To]) match { case IsChimneySpecialCased(handler) => handler.specialCase.map{Some→Expanded, None→AttemptNextRule}; case _ => attemptNextRule }.
Matching the owner's sketch literally:
(Type[From], Type[To]) match {
case IsChimneySpecialCased(handler) => handler.specialCase(using ctx) // rule matched
case _ => // rule yielded
}
rulesAvailableForPlatform: inserted AFTER the 4 implicit rules
(TransformImplicit, …PartialFallbackToTotal, …ImplicitOuterTransformer, …ImplicitConversion) and BEFORE
TransformSubtypesRule. So user/integrations implicits keep priority; a registered handler beats the built-in
structural rules. Precedence test (engine-test module) confirms a user implicit Transformer[Int, TestSpecialLeaf]
BEATS the handler (+1000 marker observed).
The handler builds the OUTER Expr and calls deriveInner[InnerFrom, InnerTo](innerExpr) per inner value; each returns
MIO[DerivedExpr[InnerTo]] (the engine's TransformationExpr, which is MIO-deferred and only run inside the rule's
expansion, i.e. inside the splicing context - CROSS-QUOTES contract respected, same as TransformImplicitOuterTransformerRule).
The handler composes N of them via DerivedExpr.flatMap/map (which thread total↔partial automatically), so a partial
inner makes the whole outer partial. Proven with TestBox2[A, B] → TestSpecialBox2[C, D] (N = 2), where the inner
Int → TestSpecialLeaf re-hits the very same rule recursively.
Reimplemented as ProtobufsChimneyMacroExtension handlers (registered via
META-INF/services/io.scalaland.chimney.integrations.ChimneyMacroExtension), DELETED the corresponding implicits:
Became handlers (leaf, no inner derivation - pure outer transforms):
- proto
Duration↔java.time.Duration(total both ways), - proto
Duration↔FiniteDuration(total both ways), - proto
Duration→scala.concurrent.duration.Duration(total, upcast) andDuration→ protoDuration(PARTIAL, rejectsDuration.Infinite) — the total/partial asymmetryIsValueType(one inner type) can't express, Empty→Unit(total; PARTIALfromEmptywhenenableImplicitConflictResolution(PreferPartialTransformer)— the handler reproduces the old total/partial conflict-resolution over the single pair),- any
A→Empty(total; incl. case objects),Empty→ anyA(partialfromEmpty; yields in total context).
Deleted implicits: totalTransformerFromEmptyToUnitInstance, totalTransformerToEmptyInstance,
partialTransformerFromEmptyInstance, and all 5 total + 1 partial Duration instances.
ProtobufsTransformerImplicits is now EMPTY.
STAYED as implicits (documented): the empty-oneof/sealedoneof/UnrecognizedEnum partial instances - they match a
BOUNDED From type FAMILY for ANY To (whole-family Implicit-rule hook, not a concrete pair a handler matches), and
partialTransformerFromEmptySealedOneOfInstance is summoned directly by a spec; plus DefaultValue[UnknownFieldSet].
Semantic refinement (documented): for the Empty ↔ Unit pair, an UNRESOLVED partial context (no
enableImplicitConflictResolution) previously reported implicit ambiguity (total vs partial); the single handler now
resolves it deterministically toward the total path. No spec asserted that ambiguity; both resolution tests
(PreferTotal → Some(()), PreferPartial → None) pass.
ProtobufExtensionProvidersSpec"kept-implicit boundary" group (which asserted Duration/Empty STILL need the import) REWRITTEN to prove they now derive import-free through the handler (all Duration partners incl. the partial Infinite-rejection, and Empty↔Unit / any↔Empty / partial Empty→A). All protobuf specs now 39 (was 36), all green ×2.- New module
chimney-chimney-extension-test(self-contained,dependsOn(chimney); the chimney-protobufs pattern):TestChimneyMacroExtension(Compile) +ChimneyMacroExtensionSpec(Test, 5 tests) prove (a) special-cased pair derives total+partial (Int/String → TestSpecialLeaf), (b) recursive N=2 inner derivation (TestBox2 → TestSpecialBox2, incl. partiality propagation, inner re-hits the rule), (c) precedence (user implicit BEATS the handler), (d) it works from a SEPARATELY-COMPILED artifact viaServiceLoader(no import brings the conversions into scope). Green ×2.
- Reused Hearth's
Environment.loadMacroExtensions[ChimneyMacroExtension](same asloadStandardExtensions). chimney-protobufsand the newchimney-chimney-extension-testeach ship aMETA-INF/services/io.scalaland.chimney.integrations.ChimneyMacroExtension.- Could NOT put the proof handler in
chimney-engine-test-extension: implementing a Chimney SPI needschimneyon the Compile classpath, butchimneydepends on that module% Test→ adding the back-dep makes sbt's build DSL a recursivelazy val(and a project-level cycle). The self-contained new module (chimney does NOT depend on it) is the cycle-free pattern, identical tochimney-protobufs. Registered in the root.aggregate.
- Do NOT name the handler's implicit
SpecialCaseContextparamctx: it shadows the extensionctxthatimport ctx.*relies on, so the Scala-2 reifier prefixes imported facade methods (sourceOf,Type) with the wrong receiver → "value sourceOf is not a member of …SpecialCaseContext". Renamed tocontext. - Do NOT reference package-object /
enumCONSTANTS insideExpr.quoteon Scala 2 (scala.concurrent.duration.SECONDS,java.util.concurrent.TimeUnit.SECONDS): reification emits them by SIMPLE name → "not found: value SECONDS" at the splice site. Used the companion METHODscala.concurrent.duration.Duration.fromNanos(sec*1e9 + nanos)instead. Expr/Type/Optionmust not appear in TYPE-annotation position underimport ctx.*(the term shadows the type): either infer, or fully-qualify (scala.Option).
- A
private[chimney]type member does not resolve cross-file through a path on Scala 3 ("classfile … missing"): had to widenTransformationContext/TransformationExprfromprotectedto fully public (they live in the mima-excludedinternal.compiletimepackage; their members are still reached only via facade helpers). Aprivate[chimney]def member resolves fine cross-file - only the type-member case is affected. (Not filed as a hearth bug - it is a Chimney visibility choice; noting the asymmetry.)
- clean-then-test per module (zinc quirk):
chimney31118 (4 ignored),chimney980,chimneyProtobufs3/chimneyProtobufs39/39 (the migration referee),chimney-chimney-extension-test×2 5/5. chimneyCats3/chimneyCats,chimneyJavaCollections3/…,chimneySandwichTests3/…,chimneyJS3,chimneyEngineTestExtension3/…: see run log (all green).- Docs snippets:
cd docs && just test-snippets→ GLOBAL "All snippets run succesfully!".
Nothing committed. Branch: hearth-migration.
Owner correction addressed: "you are not following best practices if you call runSync manually."
All 6 gateway entry points now run their derivation program through
hearth.MIOIntegrations.MioExprOps.runToExprOrFail instead of program.unsafe.runSync.
Files changed (only the Gateway/rendering path + docs, as scoped):
chimney/.../derivation/GatewayCommons.scala— rewroteextractExprAndLog; dropped the MLocal smuggling + bespokerenderOldJournalShape; moved the macro-logging trailer lines into the program.chimney/.../derivation/transformer/Gateway.scala— threaddisplayMacrosLoggingintoextractExprAndLog; hoist config read out of the twoinstanceclosures.chimney/.../derivation/patcher/Gateway.scala— same threading/hoist for the 2 patcher entry points.docs/docs/under-the-hood.md— removed the "flame graphs don't affect Chimney yet" caveat.
LOC delta: git diff --stat = 4 files, +147 / -144 (net -3; GatewayCommons -~40 after dropping
renderOldJournalShape, offset by the nested-timeout workaround + comments).
-
Failure output BYTE-IDENTICAL — YES.
renderFailure(renderedLogs, errors)rebuilds the exacterrorHeader + DerivationError.printErrors(errors) + doc-URL footertext.errors: NonEmptyVector[Throwable]isMErrors, fed straight toDerivationError.printErrors(same partitioning as before). For every error-message test macro-logging is off ⇒renderedLogs == ""⇒ purerichLines⇒ byte-identical. All 25 error-asserting specs pass unchanged. -
Macro-logging journal dump BYTE-IDENTICAL — NOT ACHIEVABLE via
runToExprOrFail; documented gap. Two independent reasons, both investigated:LogRenderingis a closed ADT (DontRender/RenderFrom/RenderOnly) — it only filters by level. The tree-rendering scheme (├/└guides,[Info]prefixes, root-scope header, scope durations) is hardcoded inLog.render/renderTreeandprivate[effect]; it cannot be swapped for Chimney's old+/|shape.runToExprOrFaildoes not exposestate.logsto the caller, and MIO has no public combinator for a program to read its own logs, so Chimney can't render its own shape post-run either. Resolution: since the ONLYenableMacrosLoggingtest usage is commented out (no spec asserts the journal shape), we let Hearth render the journal in ITS shape viainfoRendering = RenderFrom(Info)(gated on the flag;DontRenderotherwise so non-logging derivations stay silent). The two trailer lines ("Derived final expression is: …" / "Derivation took …") are now emitted asInfologs inside the program (viaenableLoggingIfFlagEnabled) so they still appear. Divergence documented in the GatewayCommons scaladoc.Warn/Errorkept atDontRenderto match the old behavior of never surfacing MIO-internal entries.
-
Fatal StackOverflow "-Xss64m" guidance — PRESERVED. Confirmed
runToExprOrFail'shandleMioTerminationExceptiononly catchesMioTerminationException/timeout, and MIO's run loop only catchesNonFatal— a realStackOverflowErrorflies out uncaught. Kept a narrowcatch { case e: StackOverflowError => reportError(renderFailure("", one(MacroException(e)))) }so the guidance text stays byte-identical. (Catching only SOE, notThrowable, avoids swallowing Hearth's internal abort control-flow fromreportErrorAndAbort.) -
Flame-graph flags NOW WORK — PROBED OK.
runToExprOrFailcallsconfigureMioBenchmarking+writeFlameGraphIfConfigured. Compiled atransformIntoprobe with-Xmacro-settings:hearth.mioBenchmarkScopes=true+hearth.mioBenchmarkFlameGraphDir=…; a validProbe.scala_5_69_Chimney.speedscope.json(7390 bytes, schema OK, 24 frames, 1 profile) was written. Removed the "doesn't affect Chimney expansions yet" caveat in under-the-hood.md and stated it works. -
Timeout — Chimney had none (unbounded). Set a generous
10 minutes(macroExpansionTimeout) so ordinary compiles never time out spuriously; Ctrl+C termination still works viaTerminationObserver.
runToExprOrFail cannot be nested: it always calls Environment.withMioTimeout, which throws
HearthAssertionError("MIO timeout is already set") on re-entry. Chimney's macro-dependent transformers
(e.g. implicit Transformer[Option[List[A]], List[B]] needing Transformer.AutoDerived[A, B]) summon
implicits MID-derivation, triggering a nested macro expansion / nested runToExprOrFail. This regressed the
TotalTransformerProductSpec "Option[List[A]] -> List[B]" test (nested derivation failed → fell back to a
"can't transform coproduct instance None to List" error).
Filed: kubuszok/hearth#342 (cited in GatewayCommons.scala).
Workaround in Chimney: timeoutDeadlineNanos is a public var; save it, set Long.MaxValue before the call,
restore in finally — the nested withMioTimeout then installs its own deadline and the outer resumes.
chimney3/test: 1118 passed (4 ignored);chimney/test: 980 passed. (clean before each — zinc quirk)chimneyCats/test312,chimneyCats3/test312,chimneyProtobufs/test36,chimneyProtobufs3/test36,chimneySandwichTests/test6,chimneySandwichTests3/test7,chimneyJS3/test1083 — all passed.- Flame-graph probe:
.speedscope.jsonproduced and validated. - Docs snippets:
just test-snippets→ GLOBAL "All snippets run succesfully!"
Nothing committed. Branch: hearth-migration.