Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -1391,6 +1391,11 @@ Stream<DynamicTest> NamingConventionTest() {
.by(simpleNameOf(WronglyAnnotated.class).notEndingWith("Controller"))
.by(simpleNameOf(SomeEnum.class).notEndingWith("Controller"))
.by(simpleNameOfAnonymousClassOf(UseCaseOneThreeController.class).notEndingWith("Controller"))
.by(violation(""))
.by(violation("Hint: The failing class appears to be a synthetic or anonymous class generated by the compiler (e.g., from lambdas, switch expressions, or inner classes). To exclude these from your rule, consider adding:"))
.by(violation(" .that().doNotHaveModifier(JavaModifier.SYNTHETIC)"))
.by(violation("or:"))
.by(violation(" .that().areNotAnonymousClasses()"))

.ofRule("classes that have simple name containing 'Controller' should reside in a package '..controller..'")
.by(javaClass(AbstractController.class).notResidingIn("..controller.."))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,26 @@ public static Creator simpleNameOfAnonymousClassOf(Class<?> clazz) {
}

public static class Creator {
private static final String SYNTHETIC_CLASS_HINT =
"\n\nHint: The failing class appears to be a synthetic or anonymous class " +
"generated by the compiler (e.g., from lambdas, switch expressions, or inner classes). " +
"To exclude these from your rule, consider adding:\n" +
" .that().doNotHaveModifier(JavaModifier.SYNTHETIC)\n" +
"or:\n" +
" .that().areNotAnonymousClasses()";

private final String className;
private final String simpleName;
private final boolean isAnonymous;

private Creator(String className, String simpleName) {
this(className, simpleName, className.contains("$"));
}

private Creator(String className, String simpleName, boolean isAnonymous) {
this.className = className;
this.simpleName = simpleName;
this.isAnonymous = isAnonymous;
}

public ExpectedMessage notStartingWith(String prefix) {
Expand All @@ -31,8 +45,11 @@ public ExpectedMessage containing(String infix) {
}

private ExpectedMessage expectedClassViolation(String description) {
return new ExpectedMessage(String.format("Class <%s> %s in (%s.java:0)",
className, description, simpleName));
String message = String.format("Class <%s> %s in (%s.java:0)",
className, description, simpleName);
// Note: Hint message is not included in expected message for integration tests
// The hint feature is tested separately in unit tests (ClassesShouldTest)
return new ExpectedMessage(message);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,7 @@ public static ArchCondition<JavaClass> notHaveSimpleName(String name) {

@PublicAPI(usage = ACCESS)
public static ArchCondition<JavaClass> haveSimpleNameStartingWith(String prefix) {
return have(simpleNameStartingWith(prefix));
return haveWithHint(simpleNameStartingWith(prefix));
}

@PublicAPI(usage = ACCESS)
Expand All @@ -540,7 +540,7 @@ public static ArchCondition<JavaClass> haveSimpleNameNotStartingWith(String pref

@PublicAPI(usage = ACCESS)
public static ArchCondition<JavaClass> haveSimpleNameContaining(String infix) {
return have(simpleNameContaining(infix));
return haveWithHint(simpleNameContaining(infix));
}

@PublicAPI(usage = ACCESS)
Expand All @@ -550,7 +550,7 @@ public static ArchCondition<JavaClass> haveSimpleNameNotContaining(String infix)

@PublicAPI(usage = ACCESS)
public static ArchCondition<JavaClass> haveSimpleNameEndingWith(String suffix) {
return have(simpleNameEndingWith(suffix));
return haveWithHint(simpleNameEndingWith(suffix));
}

@PublicAPI(usage = ACCESS)
Expand Down Expand Up @@ -1308,6 +1308,47 @@ public static <T extends HasDescription & HasSourceCodeLocation> ConditionByPred
.describeEventsBy((predicateDescription, satisfied) -> (satisfied ? "has " : "does not have ") + predicateDescription);
}

private static String getSyntheticClassHintMessage() {
String lineSeparator = System.lineSeparator();
return lineSeparator + lineSeparator + "Hint: The failing class appears to be a synthetic or anonymous class " +
"generated by the compiler (e.g., from lambdas, switch expressions, or inner classes). " +
"To exclude these from your rule, consider adding:" + lineSeparator +
" .that().doNotHaveModifier(JavaModifier.SYNTHETIC)" + lineSeparator +
"or:" + lineSeparator +
" .that().areNotAnonymousClasses()";
}

/**
* Like {@link #have(DescribedPredicate)}, but adds a helpful hint when the condition fails on synthetic or anonymous classes.
* This helps new users understand that compiler-generated classes can be excluded from rules.
* @param predicate The predicate determining which objects satisfy/violate the condition
* @return An {@link ArchCondition} that provides hints for failures on synthetic/anonymous classes
*/
private static ArchCondition<JavaClass> haveWithHint(DescribedPredicate<? super JavaClass> predicate) {
return new ArchCondition<JavaClass>(ArchPredicates.have(predicate).getDescription()) {
@Override
public void check(JavaClass javaClass, ConditionEvents events) {
boolean satisfied = predicate.test(javaClass);
String baseMessage = (satisfied ? "has " : "does not have ") + predicate.getDescription();

String message;
if (!satisfied && isSyntheticOrAnonymous(javaClass)) {
message = javaClass.getDescription() + " " + baseMessage +
" in " + javaClass.getSourceCodeLocation() + getSyntheticClassHintMessage();
} else {
message = javaClass.getDescription() + " " + baseMessage +
" in " + javaClass.getSourceCodeLocation();
}

events.add(new SimpleConditionEvent(javaClass, satisfied, message));
}
};
}

private static boolean isSyntheticOrAnonymous(JavaClass javaClass) {
return javaClass.getModifiers().contains(JavaModifier.SYNTHETIC) || javaClass.isAnonymousClass();
}

/**
* Derives an {@link ArchCondition} from a {@link DescribedPredicate}. Similar to {@link ArchCondition#from(DescribedPredicate)},
* but more conveniently creates a message to be used within a 'be'-sentence.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,19 +43,23 @@ private static ViolationLineMatcher createInstance(String lineMatcherClassName)
}

/**
* ignores numbers that are potentially line numbers (digits following a ':' and preceding a ')')
* Ignores numbers that are potentially line numbers (digits following a ':' and preceding a ')')
* or compiler-generated numbers of anonymous classes or lambda expressions (digits following a '$').
* Also ignores hint suffixes (text following line separator + line separator + "Hint:") that provide
* additional guidance but are not part of the core violation message.
*/
private static class FuzzyViolationLineMatcher implements ViolationLineMatcher {
private static final String HINT_MARKER = System.lineSeparator() + System.lineSeparator() + "Hint:";

@Override
public boolean matches(String str1, String str2) {
// Compare relevant substrings, in a more performant way than a regex solution like this:
//
// normalize = str -> str.replaceAll(":\\d+\\)", ":0)").replaceAll("\\$\\d+", "\\$0");
// return normalize.apply(str1).equals(normalize.apply(str2));

RelevantPartIterator relevantPart1 = new RelevantPartIterator(str1);
RelevantPartIterator relevantPart2 = new RelevantPartIterator(str2);
RelevantPartIterator relevantPart1 = new RelevantPartIterator(removeHintSuffix(str1));
RelevantPartIterator relevantPart2 = new RelevantPartIterator(removeHintSuffix(str2));
while (relevantPart1.hasNext() && relevantPart2.hasNext()) {
if (!relevantPart1.next().equals(relevantPart2.next())) {
return false;
Expand All @@ -64,6 +68,11 @@ public boolean matches(String str1, String str2) {
return !relevantPart1.hasNext() && !relevantPart2.hasNext();
}

private static String removeHintSuffix(String str) {
int hintIndex = str.indexOf(HINT_MARKER);
return hintIndex >= 0 ? str.substring(0, hintIndex) : str;
}

static class RelevantPartIterator {
private final String str;
private final int length;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,35 @@ public void haveSimpleNameNotEndingWith(ArchRule rule, String suffix) {
.doesNotContain(SomeClass.class.getName());
}

@Test
public void haveSimpleNameEndingWith_should_show_hint_for_anonymous_classes() {
Class<?> anonymousClass = NestedClassWithSomeMoreClasses.getAnonymousClass();

ArchRule rule = classes()
.should().haveSimpleNameEndingWith("SomethingElse");

EvaluationResult result = rule.evaluate(importClasses(anonymousClass));

assertThat(singleLineFailureReportOf(result))
.contains("does not have simple name ending with 'SomethingElse'")
.contains("Hint:")
.contains("synthetic or anonymous")
.contains("doNotHaveModifier(JavaModifier.SYNTHETIC)")
.contains("areNotAnonymousClasses()");
}

@Test
public void haveSimpleNameEndingWith_should_NOT_show_hint_for_regular_classes() {
ArchRule rule = classes()
.should().haveSimpleNameEndingWith("ValidSuffix");

EvaluationResult result = rule.evaluate(importClasses(WrongNamedClass.class));

assertThat(singleLineFailureReportOf(result))
.contains("does not have simple name ending with 'ValidSuffix'")
.doesNotContain("Hint:");
}

@DataProvider
public static Object[][] resideInAPackage_rules() {
String thePackage = ArchRule.class.getPackage().getName();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,39 @@ public void default_matcher(String str1, String str2, boolean expected) {
.as(String.format("'%s' matches '%s'", str1, str2))
.isEqualTo(expected);
}

@Test
public void default_matcher_ignores_hint_suffix() {
ViolationLineMatcher defaultMatcher = ViolationLineMatcherFactory.create();

String baseViolation = "Class <com.example.MyClass$1> does not have simple name ending with 'Service' in (MyClass.java:42)";
String hintSuffix = System.lineSeparator() + System.lineSeparator() +
"Hint: The failing class appears to be a synthetic or anonymous class " +
"generated by the compiler (e.g., from lambdas, switch expressions, or inner classes).";
String violationWithHint = baseViolation + hintSuffix;

assertThat(defaultMatcher.matches(baseViolation, violationWithHint))
.as("violation without hint should match same violation with hint")
.isTrue();
assertThat(defaultMatcher.matches(violationWithHint, baseViolation))
.as("violation with hint should match same violation without hint")
.isTrue();
assertThat(defaultMatcher.matches(violationWithHint, violationWithHint))
.as("violation with hint should match itself")
.isTrue();
}

@Test
public void default_matcher_ignores_hint_suffix_with_different_line_numbers() {
ViolationLineMatcher defaultMatcher = ViolationLineMatcherFactory.create();

String violation1 = "Class <com.example.MyClass$1> does not have simple name ending with 'Service' in (MyClass.java:42)";
String violation2WithHint = "Class <com.example.MyClass$2> does not have simple name ending with 'Service' in (MyClass.java:100)" +
System.lineSeparator() + System.lineSeparator() +
"Hint: The failing class appears to be a synthetic or anonymous class.";

assertThat(defaultMatcher.matches(violation1, violation2WithHint))
.as("violations with different line numbers and anonymous class numbers should match even with hint")
.isTrue();
}
}