Skip to content

Commit c329fe0

Browse files
committed
changes to package object and companion object handling
1 parent d40873f commit c329fe0

7 files changed

Lines changed: 361 additions & 32 deletions

File tree

plugin/src/main/scala/io/estatico/newtype/compat/NewTypeTreeTransformer.scala

Lines changed: 69 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -19,32 +19,51 @@ class NewTypeTreeTransformer extends UntypedTreeMap:
1919

2020
/** Transform a list of statements, expanding @newtype/@newsubtype classes. */
2121
private def transformStats(stats: List[Tree])(using Context): List[Tree] =
22+
val statsList = stats.toIndexedSeq
23+
// Pre-scan: for each @newtype TypeDef, find a ModuleDef anywhere later in the same
24+
// statement list whose term-name matches. Macro paradise on Scala 2 finds companions via the
25+
// typer's symbol lookup, which works regardless of intervening `type`/`val` decls. We need to
26+
// emulate that here — using only the immediately-next stat is too narrow for codebases that
27+
// place companion objects after `type` aliases (a common idiom inside `package object`s).
28+
val consumedCompanion = scala.collection.mutable.Set.empty[Int]
29+
val companionForNewtype = scala.collection.mutable.Map.empty[Int, Int]
30+
for i <- statsList.indices do
31+
statsList(i) match
32+
case td: TypeDef if td.rhs.isInstanceOf[Template] && findNewTypeAnnotation(td).isDefined =>
33+
val target = td.name.toTermName
34+
var j = i + 1
35+
var found = -1
36+
while j < statsList.length && found < 0 do
37+
statsList(j) match
38+
case md: ModuleDef if md.name == target && !consumedCompanion(j) =>
39+
found = j
40+
case _ =>
41+
j += 1
42+
if found >= 0 then
43+
companionForNewtype(i) = found
44+
consumedCompanion += found
45+
case _ =>
46+
2247
val result = ListBuffer.empty[Tree]
2348
var i = 0
24-
val statsList = stats.toIndexedSeq
2549
while i < statsList.length do
26-
val stat = statsList(i)
27-
stat match
28-
case td: TypeDef if td.rhs.isInstanceOf[Template] =>
29-
findNewTypeAnnotation(td) match
30-
case Some((annotation, isSubtype)) =>
31-
val params = AnnotationParams.extract(annotation, isSubtype)
32-
// Look ahead for companion object
33-
val companionOpt = if i + 1 < statsList.length then
34-
statsList(i + 1) match
35-
case md: ModuleDef if md.name.toTermName == td.name.toTermName => Some(md)
36-
case _ => None
37-
else None
38-
39-
val expanded = expandNewType(td, companionOpt, params)
40-
result ++= expanded
41-
42-
if companionOpt.isDefined then i += 1 // skip companion, we merged it
43-
case None =>
44-
result += transform(stat)
45-
case _ =>
46-
result += transform(stat)
47-
i += 1
50+
if consumedCompanion(i) then
51+
i += 1
52+
else
53+
statsList(i) match
54+
case td: TypeDef if td.rhs.isInstanceOf[Template] =>
55+
findNewTypeAnnotation(td) match
56+
case Some((annotation, isSubtype)) =>
57+
val params = AnnotationParams.extract(annotation, isSubtype)
58+
val companionOpt = companionForNewtype.get(i)
59+
.map(j => statsList(j).asInstanceOf[ModuleDef])
60+
val expanded = expandNewType(td, companionOpt, params)
61+
result ++= expanded
62+
case None =>
63+
result += transform(statsList(i))
64+
case _ =>
65+
result += transform(statsList(i))
66+
i += 1
4867
result.toList
4968

5069
/** Find @newtype or @newsubtype annotation on a TypeDef. */
@@ -138,11 +157,6 @@ class NewTypeTreeTransformer extends UntypedTreeMap:
138157
| def derivingK[TC[_[_]]](implicit ev: TC[Repr]): TC[Type] = ev.asInstanceOf[TC[Type]]""".stripMargin
139158
else ""
140159

141-
// Merge with existing companion body
142-
val existingCompanionCode = companionOpt.map { md =>
143-
md.impl.body.map(plain(_)).mkString("\n ", "\n ", "")
144-
}.getOrElse("")
145-
146160
// Coercible implicits: for parameterized types, use def with type params
147161
val C = "_root_.io.estatico.newtype.Coercible"
148162
val coercibleImplicits = if tparams.isEmpty then
@@ -182,15 +196,35 @@ class NewTypeTreeTransformer extends UntypedTreeMap:
182196
|$opsCode
183197
|$derivingCode
184198
|$derivingKCode
185-
|$existingCompanionCode
186199
|}""".stripMargin
187200

188201
if params.debug then
189202
println(s"[newtype-compat] Generated source for @${if params.isSubtype then "newsubtype" else "newtype"} $nameStr:")
190203
source.linesIterator.foreach(line => println(s"[newtype-compat] $line"))
191204

192-
// Parse the generated source code into untyped trees
193-
parseStats(source)
205+
// Parse the generated source code into untyped trees, then splice the companion's body
206+
// structurally (rather than via tree.show round-trip — which emits the `module` soft keyword
207+
// for nested objects and produces source that won't reparse).
208+
val stats = parseStats(source)
209+
companionOpt match
210+
case None => stats
211+
case Some(companion) =>
212+
val companionTermName = name.toTermName
213+
stats.map {
214+
case md: ModuleDef if md.name == companionTermName =>
215+
// Splice the companion's full Template into the synthesized one. We need to carry
216+
// over `parents` (e.g. `extends WithGenericSalesforceId[...]`), `derived` (any
217+
// `derives` clauses), and `self` from the original — the synthesized object only
218+
// provides default-empty values for those. Body members from both are concatenated.
219+
val mergedImpl = cpy.Template(md.impl)(
220+
parents = if companion.impl.parents.nonEmpty then companion.impl.parents else md.impl.parents,
221+
derived = if companion.impl.derived.nonEmpty then companion.impl.derived else md.impl.derived,
222+
self = if companion.impl.self != md.impl.self then companion.impl.self else md.impl.self,
223+
body = md.impl.body ++ companion.impl.body
224+
)
225+
untpd.ModuleDef(md.name, mergedImpl).withMods(md.mods).withSpan(md.span)
226+
case other => other
227+
}
194228

195229
private def buildOpsCodeFromMethods(
196230
nameStr: String, reprNameStr: String, reprTypeStr: String,
@@ -285,6 +319,9 @@ class NewTypeTreeTransformer extends UntypedTreeMap:
285319
self = transformSub(md.impl.self),
286320
body = transformStats(md.impl.body)
287321
)
288-
untpd.ModuleDef(md.name, newImpl).withSpan(md.span)
322+
// Preserve the original modifiers (incl. the package-object marker) — without `withMods`,
323+
// a `package object` would be reconstructed as a regular `object`, breaking sub-packages
324+
// and parent-package-object scope inheritance.
325+
untpd.ModuleDef(md.name, newImpl).withMods(md.mods).withSpan(md.span)
289326
case _ =>
290327
super.transform(tree)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package io.estatico.newtype.macros
2+
3+
import io.estatico.newtype.ops._
4+
import org.scalatest.flatspec.AnyFlatSpec
5+
import org.scalatest.matchers.should.Matchers
6+
7+
/*
8+
* Regression: a `@newtype` declaration followed by `type` aliases, and then its companion object.
9+
*
10+
* On Scala 2 this is fine because macro paradise looks up the companion symbol via the typer
11+
* regardless of intervening declarations. The original Scala 3 plugin only inspected the
12+
* immediately-next stat (`statsList(i + 1)`) for a companion, so this idiom (common inside
13+
* `package object` blocks where helper aliases are colocated with the newtype) caused the
14+
* companion to be missed entirely. The synthesized companion and the original then coexisted as
15+
* two `object Foo` declarations, producing `is already defined` errors.
16+
*/
17+
class NewTypeCompanionAfterTypeAliasesTest extends AnyFlatSpec with Matchers {
18+
19+
import NewTypeCompanionAfterTypeAliasesTest._
20+
21+
behavior of "@newtype companion separated from declaration by type aliases"
22+
23+
it should "still merge the companion that follows after intervening `type` decls" in {
24+
val id = OrderId("ord-1")
25+
id.coerce[String] shouldBe "ord-1"
26+
OrderId.fromString("ord-2").coerce[String] shouldBe "ord-2"
27+
OrderId.Default.coerce[String] shouldBe "ord-default"
28+
}
29+
}
30+
31+
object NewTypeCompanionAfterTypeAliasesTest {
32+
@newtype final case class OrderId(value: String)
33+
// Intervening type aliases — must not prevent the companion below from being recognized.
34+
type OrderIdHelper = String
35+
type OrderIdAnotherHelper = OrderIdHelper
36+
object OrderId {
37+
def fromString(s: String): OrderId = OrderId(s)
38+
val Default: OrderId = OrderId("ord-default")
39+
}
40+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package io.estatico.newtype.macros
2+
3+
import io.estatico.newtype.ops._
4+
import org.scalatest.flatspec.AnyFlatSpec
5+
import org.scalatest.matchers.should.Matchers
6+
7+
/*
8+
* Regression: a `@newtype` whose companion `extends` a trait (and supplies abstract members or
9+
* overrides via the trait's API).
10+
*
11+
* Mirrors the chili pattern in `salesforce/package.scala`:
12+
*
13+
* @newtype final case class ContactId(value: String Refined ContactIdRefinement)
14+
* object ContactId extends WithGenericSalesforceId[ContactIdRefinement] { override type N = ContactId }
15+
*
16+
* The plugin merges `companion.impl.body` into the synthesized object's body, but it does not
17+
* propagate `companion.impl.parents`, `companion.impl.derived`, or `companion.impl.self`. As a
18+
* result the trait extension is silently dropped — the synthesized object becomes a plain
19+
* `object Foo { ... }` with the body but without the parent it was declared to extend.
20+
*
21+
* The visible failure is downstream: callers expecting `Foo: HasN` lose the inherited members,
22+
* and `override`s in the companion body refer to symbols that no longer exist on the companion.
23+
*/
24+
class NewTypeCompanionExtendsTraitTest extends AnyFlatSpec with Matchers {
25+
26+
import NewTypeCompanionExtendsTraitTest._
27+
28+
behavior of "@newtype companion that extends a trait"
29+
30+
it should "preserve the parent trait so inherited members are reachable" in {
31+
Foo.label shouldBe "foo-label"
32+
Foo("x").coerce[String] shouldBe "x"
33+
(Foo: HasLabel) shouldBe Foo
34+
}
35+
36+
it should "preserve overrides that depend on the parent trait" in {
37+
Bar.emptyString shouldBe ""
38+
(Bar: HasEmptyString[BarMarker]).emptyString shouldBe ""
39+
}
40+
41+
it should "preserve generic parent traits with type-member overrides" in {
42+
// Mirrors the chili pattern: `object ContactId extends WithGenericSalesforceId[ContactIdRefinement] { override type N = ContactId }`.
43+
Baz.label shouldBe "baz-refined"
44+
val ev: WithRefinement[BazRefinement] = Baz
45+
ev.label shouldBe "baz-refined"
46+
}
47+
}
48+
49+
object NewTypeCompanionExtendsTraitTest {
50+
51+
trait HasLabel {
52+
def label: String
53+
}
54+
55+
trait HasEmptyString[Phantom] {
56+
def emptyString: String
57+
}
58+
59+
trait WithRefinement[R] {
60+
type N
61+
def label: String
62+
}
63+
64+
@newtype final case class Foo(value: String)
65+
object Foo extends HasLabel {
66+
val label: String = "foo-label"
67+
}
68+
69+
final class BarMarker
70+
@newtype final case class Bar(value: String)
71+
object Bar extends HasEmptyString[BarMarker] {
72+
val emptyString: String = ""
73+
}
74+
75+
// BazRefinement is a phantom type used as a refinement marker, like `ContactIdRefinement` in chili.
76+
final class BazRefinement
77+
@newtype final case class Baz(value: String)
78+
object Baz extends WithRefinement[BazRefinement] {
79+
override type N = Baz
80+
val label: String = "baz-refined"
81+
}
82+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package io.estatico.newtype.macros
2+
3+
import io.estatico.newtype.ops._
4+
import org.scalatest.flatspec.AnyFlatSpec
5+
import org.scalatest.matchers.should.Matchers
6+
7+
/*
8+
* Regression: `@newtype` with a companion whose body contains a nested `object`.
9+
*
10+
* The plugin merges the existing companion body by calling `tree.show` on each member of
11+
* `companionOpt.impl.body`, then splicing the resulting strings into a generated source that
12+
* gets re-parsed by `Parsers.Parser`. For nested `object`s, Dotty's untyped `tree.show` emits the
13+
* `module` soft keyword (e.g. `module object Patterns { ... }`), which is not valid Scala source.
14+
* The resulting source fails parsing inside `<newtype-generated-N>`.
15+
*
16+
* This is the same failure mode observed when cross-compiling chili `core-domain` to Scala 3:
17+
*
18+
* [error] <newtype-generated-84>:35:10: end of statement expected but 'object' found
19+
* [error] module object Predef {
20+
*
21+
* A fix likely needs to render companion body members through a printer that does not emit the
22+
* `module` modifier, or rebuild the companion structurally rather than via string splicing.
23+
*/
24+
class NewTypeCompanionWithNestedObjectTest extends AnyFlatSpec with Matchers {
25+
26+
import NewTypeCompanionWithNestedObjectTest._
27+
28+
behavior of "@newtype companion containing a nested object"
29+
30+
it should "preserve a nested object declared in the companion" in {
31+
val s = Phone("+1 555 0100")
32+
s.coerce[String] shouldBe "+1 555 0100"
33+
Phone.Patterns.us shouldBe "^\\+1.*"
34+
Phone.fromString("+1 555 0100").coerce[String] shouldBe "+1 555 0100"
35+
}
36+
}
37+
38+
object NewTypeCompanionWithNestedObjectTest {
39+
@newtype final case class Phone(value: String)
40+
object Phone {
41+
object Patterns {
42+
val us: String = "^\\+1.*"
43+
val intl: String = "^\\+\\d{1,3}.*"
44+
}
45+
def fromString(s: String): Phone = Phone(s)
46+
}
47+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package io.estatico.newtype.macros
2+
3+
import io.estatico.newtype.ops._
4+
import org.scalatest.flatspec.AnyFlatSpec
5+
import org.scalatest.matchers.should.Matchers
6+
7+
/*
8+
* Tests for `@newtype` declarations placed directly inside a `package object` body, with and
9+
* without companions, plus a nested `package object` that depends on its parent's declarations.
10+
*
11+
* Fixtures live in:
12+
* - tests/.../pkgobj/package.scala
13+
* - tests/.../pkgobj/nested/package.scala
14+
*
15+
* On Scala 3, the rebuilt `ModuleDef` for a package object drops the `Package` modifier, so the
16+
* package object is emitted as a regular object. The runtime tests below illustrate the user-
17+
* visible consequences (nested package inaccessible, parent type alias not in scope from the
18+
* nested package). They are expected to pass once the plugin preserves package-object identity.
19+
*/
20+
class NewTypeInPackageObjectTest extends AnyFlatSpec with Matchers {
21+
22+
behavior of "@newtype inside a package object"
23+
24+
it should "expand a @newtype with a companion containing typeclass instances" in {
25+
val t = pkgobj.Tenant("acme")
26+
t.coerce[String] shouldBe "acme"
27+
pkgobj.Tenant.Default.coerce[String] shouldBe "default"
28+
pkgobj.Tenant.of("foo").coerce[String] shouldBe "foo"
29+
val ord = implicitly[Ordering[pkgobj.Tenant]]
30+
ord.compare(pkgobj.Tenant("a"), pkgobj.Tenant("b")) should be < 0
31+
}
32+
33+
it should "expand a @newtype whose companion only declares vals" in {
34+
pkgobj.User.Anonymous.coerce[String] shouldBe "anonymous"
35+
}
36+
37+
it should "expand a @newtype that has no companion at all" in {
38+
pkgobj.JobName("nightly").coerce[String] shouldBe "nightly"
39+
}
40+
41+
behavior of "@newtype inside a nested package object"
42+
43+
it should "resolve type aliases declared in the parent package object" in {
44+
pkgobj.nested.SubField("x").coerce[String] shouldBe "x"
45+
pkgobj.nested.SubField.Sentinel.coerce[String] shouldBe "sentinel"
46+
}
47+
48+
it should "expand a @newtype without a companion in a nested package object" in {
49+
pkgobj.nested.SubLabel("y").coerce[String] shouldBe "y"
50+
}
51+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package io.estatico.newtype.macros.pkgobj
2+
3+
import io.estatico.newtype.macros.newtype
4+
5+
/*
6+
* Fixture: a nested package object inside a package whose parent declares a `package object`.
7+
*
8+
* Mirrors the chili `package object common.crm { @newtype case class CrmObject(value: RefinedSaneString) }`
9+
* pattern, where `RefinedSaneString` lives in the parent `package object common`.
10+
*
11+
* Two pieces are exercised together:
12+
* - the parent's package-object types must remain in scope inside this nested package, and
13+
* - `@newtype` expansion must work inside a nested package object's body too.
14+
*
15+
* If the plugin loses the `Package` flag on the parent's package object, both fail: the parent is
16+
* emitted as a regular object, the package `pkgobj.nested` cannot exist underneath a class named
17+
* `pkgobj`, and `SaneString` is no longer reachable here without a fully-qualified import.
18+
*/
19+
package object nested {
20+
21+
@newtype final case class SubField(value: SaneString)
22+
object SubField {
23+
val Sentinel: SubField = SubField("sentinel")
24+
}
25+
26+
@newtype final case class SubLabel(value: SaneString)
27+
}

0 commit comments

Comments
 (0)