Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

cowwoc
Copy link

@cowwoc cowwoc commented Sep 21, 2025

Summary

This PR adds Java Platform Module System (JPMS) support to Truth while maintaining full backward compatibility with Java 8.

Changes

  • Core module: Added module-info.java with exports for com.google.common.truth
  • Extension modules: Added module descriptors for:
    • com.google.truth.extensions.proto
    • com.google.truth.extensions.liteproto
    • com.google.truth.extensions.re2j
  • Multi-release JAR: Configured Maven compiler and JAR plugins for multi-release compilation
  • Dependencies: Properly declared module dependencies including Guava, JUnit, JSpecify, Error Prone, and ASM

Benefits

  • JPMS compatibility for modern Java applications (Java 9+)
  • Enhanced module encapsulation and security
  • Clear dependency boundaries
  • Maintains Java 8 compatibility through multi-release JAR structure
  • Supports both modular and non-modular usage patterns

Implementation Details

  • Uses multi-release JAR approach with module-info.java in src/main/java9/
  • Static dependencies for optional libraries (ASM, JSpecify, Error Prone)
  • Automatic module names for non-modular dependencies
  • Maven compiler plugin configured for Java 9+ module compilation

Test plan

  • Core module compiles successfully with Java 9+ module system
  • Multi-release JAR structure verified (META-INF/versions/9/module-info.class)
  • Module dependencies resolve correctly (--describe-module verification)
  • Java 8 compatibility maintained through multi-release structure
  • Line ending normalization applied

- Add module-info.java descriptors for core and extension modules
- Configure multi-release JAR compilation for Java 9+ compatibility
- Maintain Java 8 backward compatibility through multi-release structure
- Support for Guava, JUnit, JSpecify, Error Prone, and ASM dependencies
- Extension modules: truth-proto-extension, truth-liteproto-extension, truth-re2j-extension
@cowwoc
Copy link
Author

cowwoc commented Sep 21, 2025

Fixes #605

Copy link
Member

@cpovirk cpovirk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I should have thought of this after the Guava modularization landed.

@cpovirk cpovirk self-assigned this Sep 21, 2025
@cpovirk cpovirk added P2 has an ETA type=enhancement Make an existing feature better labels Sep 21, 2025
@scordio
Copy link
Contributor

scordio commented Sep 21, 2025

If I may give a suggestion, I find the setup demonstrated by Maven more flexible, i.e., Java 9 by default and one extra compilation with Java 8 excluding the module descriptor to make sure no Java 9 API is used.

Plus, it's IDE-friendly because the module descriptor is loaded properly.

See also a concrete example here and here.

@cowwoc
Copy link
Author

cowwoc commented Sep 21, 2025

@scordio Good point. I'll give that a try.

@cowwoc
Copy link
Author

cowwoc commented Sep 22, 2025

I ended up breaking my teeth trying to make the Javadoc output modular. It's not going to be technically possible until the Protobuf dependency is made modular. I'm trying to move in that direction at protocolbuffers/protobuf#16133 but success is not guaranteed.

Resolves module system conflicts by moving liteproto extension from shared
com.google.common.truth.extensions.proto package to unique
com.google.common.truth.extensions.liteproto namespace. This eliminates
split package violations that prevented proper module boundaries.

Enables aggregated Javadoc generation using legacyMode=true to work around
protobuf dependency conflicts between liteproto (protobuf-lite:3.0.1) and
proto (protobuf-java:4.32.1) extensions that caused module-path failures.
@cowwoc
Copy link
Author

cowwoc commented Sep 22, 2025

I pushed a new commit that addresses your comments, and fixed some split packages that I missed before. Please let me know what you think.

@cowwoc
Copy link
Author

cowwoc commented Sep 22, 2025

It looks like I botched up the latest commit. Update coming soon.

- Separate CI build (JDK 11) from test phases (JDK 8/11/17)
- Centralize module-info compilation config in parent POM
- Use two-stage compilation: module-info with release=9, base code with Java 8
- Maintain JDK 8 runtime compatibility via multi-release JAR structure
- Add package-info.java for com.google.common.truth.extensions.proto
- Add package-info.java for com.google.common.truth.extensions.liteproto
- Clarify differences between full protobuf and protobuf-lite extensions
- Maintain proper Javadoc coverage for exported packages

These were inadvertently removed during JPMS package separation.
@cowwoc
Copy link
Author

cowwoc commented Sep 22, 2025

Okay, I think I fixed all the problems. Please review.

Copy link
Member

@cpovirk cpovirk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I suggest once or twice below, please do feel free to scope this down if that is sufficient to accomplish your goals.

*/

package com.google.common.truth.extensions.proto;
package com.google.common.truth.extensions.liteproto;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I should have thought of this, too. It serves me right for saying how "unlikely" it was that we'd need to release a Truth 2.0.0... :)

Changing the package is a tough sell. We might still be able to pull it off, but it would take some effort for both us and our users, including an internal migration that would probably be mostly straightforward but would take some time.

I'm wondering about a potential alternative: What if we were to put both ProtoTruth and LiteProtoTruth into a single artifact with a dependency on protobuf-java that is optional?

Ideally we'd call that artifact truth-proto-extension, but we might "need to" call it truth-liteproto-extension instead: That would ensure that users of both of the existing artifacts get the combined artifact automatically—directly for users of truth-liteproto-extension, transitively for users of truth-proto-extension (which we'd continue to release but which would become an empty jar that still depends on truth-liteproto-extension). (If a build tool forbids use of transitive dependencies, then users of that tool will have to explicitly move from truth-proto-extension to truth-liteproto-extension.)

This would be roughly the approach that we already used when merging the Java 8 extensions into the core of Truth. The difference would be that, in our previous merge, we could reasonably expect for users of the Java 8 extension to also already declare a dependency on core Truth (or, failing that, to be using a build tool that was happy to let them rely on transitive dependencies). Here, we wouldn't expect a project that uses ProtoTruth to also declare a dependency on LiteProtoTruth. Again, though, that won't be a problem unless the user uses a build tool that is strict about transitive deps.

(Could we have Maven redirect things automatically? I think I saw that done for GWT, but maybe that can be done only for a change to groupId?)

The other option, of course, is to give up on modularizing the protobuf extensions. I don't know if you're modularizing them because you have a need for that or just because you want to be an even better citizen as you work to modularize the core.

Let me know if you have thoughts on the approach. I can think about it more before encouraging you to redo things yet again.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly, the fact that we're not able to make any traction at modularizing the protobuf library is a real pain.

Putting aside the protobuf library for a minute, would this approach work for the truth library? protocolbuffers/protobuf#16133 (comment)

Or do you have a similar hierarchy problem as they do?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, of course, we can't modularize "for real" until Protobuf does :(

I would really like to avoid duplicating all the existing classes, even those just in the protobuf extensions. Truth (especially ProtoTruth) doesn't get extended the way that Protobuf does, but it would still mean two parallel mini-ecosystems. I'd like to think that we can work out the merging of the two protobuf artifacts if it comes to that.

module com.google.truth {
requires com.google.common;
requires junit;
requires java.compiler;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that this is for @Generated from AutoValue:

[ERROR] /usr/local/google/home/cpovirk/clients/truth-green/truth/core/target/generated-sources/annotations/com/google/common/truth/AutoValue_ActualValueInference_OpaqueEntry.java:[4,24] package javax.annotation.processing is not visible
[ERROR]   (package javax.annotation.processing is declared in module java.compiler, but module com.google.truth does not read it)
$ sed -n '4 p' core/target/generated-sources/annotations/com/google/common/truth/AutoValue_ActualValueInference_OpaqueEntry.java
import javax.annotation.processing.Generated;

If I were more ambitious, I would think about this more and possibly open an issue against AutoValue or AutoCommon or something.

It might be that we could avoid this by not performing a "real" Java 9 build, instead only using Java 9 for the module-info build, as I think you had things initially. A downside to that is that we could more easily omit lines from the module-info that we ought to have included (as ably demonstrated by Guava in google/guava#7744 and google/guava#7748). (And any change gives us a chance to get anything wrong.)

I think I'm tentatively in favor of leaving this line here but tweaking it to requires static (here and in whatever other artifacts you are up for pushing through):

Suggested change
requires java.compiler;
requires static java.compiler;

<configuration>
<archive>
<manifestEntries>
<Multi-Release>true</Multi-Release>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the current configuration, I don't think we actually end up producing a multi-release jar, so I think we can get by without this.

<groupId>com.google.truth</groupId>
<artifactId>truth-parent</artifactId>
<version>HEAD-SNAPSHOT</version>
<version>999.0.0-SNAPSHOT</version>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error #1 from mvn clean install:

[INFO] Running com.google.common.truth.extension.EmployeeSubjectTest
[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0 s <<< FAILURE! -- in com.google.common.truth.extension.EmployeeSubjectTest
[ERROR] com.google.common.truth.extension.EmployeeSubjectTest.initializationError -- Time elapsed: 0 s <<< ERROR!
java.lang.reflect.InaccessibleObjectException: Unable to make public void com.google.common.truth.extension.EmployeeSubjectTest.username() accessible: module com.google.truth does not "exports com.google.common.truth.extension" to module junit
        at java.base/java.lang.reflect.AccessibleObject.throwInaccessibleObjectException(AccessibleObject.java:353)
        at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:329)
        at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:277)
        at java.base/java.lang.reflect.Method.checkCanSetAccessible(Method.java:182)
        at java.base/java.lang.reflect.Method.setAccessible(Method.java:176)
        at [email protected]/org.junit.runners.model.FrameworkMethod.<init>(FrameworkMethod.java:35)
        at [email protected]/org.junit.runners.model.TestClass.scanAnnotatedMembers(TestClass.java:66)
        at [email protected]/org.junit.runners.model.TestClass.<init>(TestClass.java:57)
        at [email protected]/org.junit.runners.JUnit4.<init>(JUnit4.java:23)
        at java.base/jdk.internal.reflect.DirectConstructorHandleAccessor.newInstance(DirectConstructorHandleAccessor.java:62)
        at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:499)
        at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:483)
        at [email protected]/org.junit.internal.builders.AnnotatedBuilder.buildRunner(AnnotatedBuilder.java:104)
        at [email protected]/org.junit.internal.builders.AnnotatedBuilder.runnerForClass(AnnotatedBuilder.java:86)
        at [email protected]/org.junit.runners.model.RunnerBuilder.safeRunnerForClass(RunnerBuilder.java:70)
        at [email protected]/org.junit.internal.builders.AllDefaultPossibilitiesBuilder.runnerForClass(AllDefaultPossibilitiesBuilder.java:37)
        at [email protected]/org.junit.runners.model.RunnerBuilder.safeRunnerForClass(RunnerBuilder.java:70)
        at [email protected]/org.junit.internal.requests.ClassRequest.createRunner(ClassRequest.java:28)
        at [email protected]/org.junit.internal.requests.MemoizingRequest.getRunner(MemoizingRequest.java:19)
        at org.apache.maven.surefire.junit4.JUnit4Provider.execute(JUnit4Provider.java:314)
        at org.apache.maven.surefire.junit4.JUnit4Provider.executeWithRerun(JUnit4Provider.java:240)
        at org.apache.maven.surefire.junit4.JUnit4Provider.executeTestSet(JUnit4Provider.java:214)
        at org.apache.maven.surefire.junit4.JUnit4Provider.invoke(JUnit4Provider.java:155)
        at org.apache.maven.surefire.booter.ForkedBooter.runSuitesInProcess(ForkedBooter.java:385)
        at org.apache.maven.surefire.booter.ForkedBooter.execute(ForkedBooter.java:162)
        at org.apache.maven.surefire.booter.ForkedBooter.run(ForkedBooter.java:507)
        at org.apache.maven.surefire.booter.ForkedBooter.main(ForkedBooter.java:495)
 
[INFO] Running com.google.common.truth.extension.FakeHrDatabaseTest
[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0 s <<< FAILURE! -- in com.google.common.truth.extension.FakeHrDatabaseTest
[ERROR] com.google.common.truth.extension.FakeHrDatabaseTest.initializationError -- Time elapsed: 0 s <<< ERROR!
java.lang.reflect.InaccessibleObjectException: Unable to make public void com.google.common.truth.extension.FakeHrDatabaseTest.relocatePresent() accessible: module com.google.truth does not "exports com.google.common.truth.extension" to module junit
        at java.base/java.lang.reflect.AccessibleObject.throwInaccessibleObjectException(AccessibleObject.java:353)
        at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:329)
        at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:277)
        at java.base/java.lang.reflect.Method.checkCanSetAccessible(Method.java:182)
        at java.base/java.lang.reflect.Method.setAccessible(Method.java:176)
        at [email protected]/org.junit.runners.model.FrameworkMethod.<init>(FrameworkMethod.java:35)
        at [email protected]/org.junit.runners.model.TestClass.scanAnnotatedMembers(TestClass.java:66)
        at [email protected]/org.junit.runners.model.TestClass.<init>(TestClass.java:57)
        at [email protected]/org.junit.runners.JUnit4.<init>(JUnit4.java:23)
        at java.base/jdk.internal.reflect.DirectConstructorHandleAccessor.newInstance(DirectConstructorHandleAccessor.java:62)
        at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:499)
        at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:483)
        at [email protected]/org.junit.internal.builders.AnnotatedBuilder.buildRunner(AnnotatedBuilder.java:104)
        at [email protected]/org.junit.internal.builders.AnnotatedBuilder.runnerForClass(AnnotatedBuilder.java:86)
        at [email protected]/org.junit.runners.model.RunnerBuilder.safeRunnerForClass(RunnerBuilder.java:70)
        at [email protected]/org.junit.internal.builders.AllDefaultPossibilitiesBuilder.runnerForClass(AllDefaultPossibilitiesBuilder.java:37)
        at [email protected]/org.junit.runners.model.RunnerBuilder.safeRunnerForClass(RunnerBuilder.java:70)
        at [email protected]/org.junit.internal.requests.ClassRequest.createRunner(ClassRequest.java:28)
        at [email protected]/org.junit.internal.requests.MemoizingRequest.getRunner(MemoizingRequest.java:19)
        at org.apache.maven.surefire.junit4.JUnit4Provider.execute(JUnit4Provider.java:314)
        at org.apache.maven.surefire.junit4.JUnit4Provider.executeWithRerun(JUnit4Provider.java:240)
        at org.apache.maven.surefire.junit4.JUnit4Provider.executeTestSet(JUnit4Provider.java:214)
        at org.apache.maven.surefire.junit4.JUnit4Provider.invoke(JUnit4Provider.java:155)
        at org.apache.maven.surefire.booter.ForkedBooter.runSuitesInProcess(ForkedBooter.java:385)
        at org.apache.maven.surefire.booter.ForkedBooter.execute(ForkedBooter.java:162)
        at org.apache.maven.surefire.booter.ForkedBooter.run(ForkedBooter.java:507)
        at org.apache.maven.surefire.booter.ForkedBooter.main(ForkedBooter.java:495) 

That's kind of just a weird case: We probably "should" have a separate pom.xml and a separate module for the example extension. Then it could export this extension package.

I am 100% on board with just removing the sample extension from the testing: It's just a sample, and we'll continue to build and test it internally, so it will remain up to date.

<Multi-Release>true</Multi-Release>
</manifestEntries>
</archive>
</configuration>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error #2 from mvn clean install:

[INFO] Running com.google.common.truth.CorrespondenceExceptionStoreTest
[ERROR] Tests run: 6, Failures: 2, Errors: 0, Skipped: 0, Time elapsed: 0.009 s <<< FAILURE! -- in com.google.common.truth.CorrespondenceExceptionStoreTest
[ERROR] com.google.common.truth.CorrespondenceExceptionStoreTest.describeAsMainCause_notEmpty -- Time elapsed: 0.007 s <<< FAILURE!
value of:
    getValue()
expected to match:
    compare\(null, 123\) threw com.google.common.truth.TestCorrespondences\$NullPointerExceptionFromWithin10Of\s+at com\.google\.common\.truth\.TestCorrespondences(.|\n)*\n---
but was:
    compare(null, 123) threw com.google.common.truth.TestCorrespondences$NullPointerExceptionFromWithin10Of
        at [email protected]/com.google.common.truth.TestCorrespondences.lambda$static$1(TestCorrespondences.java:76)
        at [email protected]/com.google.common.truth.Correspondence$FromBinaryPredicate.compare(Correspondence.java:150)
        at [email protected]/com.google.common.truth.Correspondence$FormattingDiffs.compare(Correspondence.java:453)
    
    ---
        at [email protected]/com.google.common.truth.CorrespondenceExceptionStoreTest.assertExpectedFacts(CorrespondenceExceptionStoreTest.java:99)
        at [email protected]/com.google.common.truth.CorrespondenceExceptionStoreTest.describeAsMainCause_notEmpty(CorrespondenceExceptionStoreTest.java:58)
 
[ERROR] com.google.common.truth.CorrespondenceExceptionStoreTest.describeAsAdditionalInfo_notEmpty -- Time elapsed: 0.002 s <<< FAILURE!
value of:
    getValue()
expected to match:
    compare\(null, 123\) threw com.google.common.truth.TestCorrespondences\$NullPointerExceptionFromWithin10Of\s+at com\.google\.common\.truth\.TestCorrespondences(.|\n)*\n---
but was:
    compare(null, 123) threw com.google.common.truth.TestCorrespondences$NullPointerExceptionFromWithin10Of
        at [email protected]/com.google.common.truth.TestCorrespondences.lambda$static$1(TestCorrespondences.java:76)
        at [email protected]/com.google.common.truth.Correspondence$FromBinaryPredicate.compare(Correspondence.java:150)
        at [email protected]/com.google.common.truth.Correspondence$FormattingDiffs.compare(Correspondence.java:453)
    
    ---
        at [email protected]/com.google.common.truth.CorrespondenceExceptionStoreTest.assertExpectedFacts(CorrespondenceExceptionStoreTest.java:99)
        at [email protected]/com.google.common.truth.CorrespondenceExceptionStoreTest.describeAsAdditionalInfo_notEmpty(CorrespondenceExceptionStoreTest.java:73)
 

That should just be a matter of loosening the assertion to accept either style.

Of course, another option for this (and for Error #1) is to see if we can keep maven-surefire-plugin out of "modules mode" for its testing. Having the modular mode set up is nice if it's straightforward, but I don't want you to feel like you need to block the user-facing improvements you need on an internal build improvement, albeit one that might someday help us catch modules-related bugs before you encounter them.

</manifestEntries>
</archive>
</configuration>
</plugin>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error from mvn clean install -DskipTests:

[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.14.0:testCompile (default-testCompile) on project truth-liteproto-extension: Compilation failure: Compilation failure:
[ERROR] /usr/local/google/home/cpovirk/clients/truth-green/truth/extensions/liteproto/src/test/java/com/google/common/truth/extensions/liteproto/test/LiteProtoSubjectTest.java:[24,63] package com.google.common.truth.extensions.liteproto.test.proto does not exist
[ERROR] /usr/local/google/home/cpovirk/clients/truth-green/truth/extensions/liteproto/src/test/java/com/google/common/truth/extensions/liteproto/test/LiteProtoSubjectTest.java:[25,63] package com.google.common.truth.extensions.liteproto.test.proto does not exist
[ERROR] /usr/local/google/home/cpovirk/clients/truth-green/truth/extensions/liteproto/src/test/java/com/google/common/truth/extensions/liteproto/test/LiteProtoSubjectTest.java:[26,63] package com.google.common.truth.extensions.liteproto.test.proto does not exist
[ERROR] /usr/local/google/home/cpovirk/clients/truth-green/truth/extensions/liteproto/src/test/java/com/google/common/truth/extensions/liteproto/test/LiteProtoSubjectTest.java:[27,63] package com.google.common.truth.extensions.liteproto.test.proto does not exist
[ERROR] /usr/local/google/home/cpovirk/clients/truth-green/truth/extensions/liteproto/src/test/java/com/google/common/truth/extensions/liteproto/test/LiteProtoSubjectTest.java:[28,63] package com.google.common.truth.extensions.liteproto.test.proto does not exist

I haven't thought about that one, but it might be a matter of moving more stuff or updating more references. I'd encourage you not to worry about it until we discuss the proto/liteproto business more, but I'm noting it here as a record of potential remaining work.

Make the JUnit 4 dependency optional

Co-authored-by: Chris Povirk <[email protected]>
@cowwoc
Copy link
Author

cowwoc commented Sep 26, 2025

Sorry, work sucked all the energy out of me this week. I'd love for someone else to pick up where I left off; otherwise, I'll try getting back to this in a couple of weeks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
P2 has an ETA type=enhancement Make an existing feature better
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants