Writing built-in linting rules
So you’ve decided to write a built-in linting rule. That means that you’ve read Should you write built-in linting rules or custom ones?
For the sake of the example, we are going to look at the noDuplicateRelationships rule.
That rule checks that there are no duplicates in Rel / Rel_D / Rel_R / Rel_U / Rel_L declarations for a given source file.
What you will learn by reading that page:
Getting started
If you recall the diagrams on the architecture page, the linter has two important layers:
- The library layer
- The MOJO layer
We are going to modify both.
The library layer
First, we need to create a new linting rule, and an ANTLR listener. The rule will take care of creating the listener instance, passing custom parameters to it, and will call it at the end if we need to collect information. The listener will contain the linting business logic.
Understanding how the parsing is done
You must understand that parsing a C4 source file means that we get an abstract syntax tree, and that we travel to each node of the tree and call the listener for each of these nodes.
For example, let’s take the following C4 snippet:
@startuml
!include test.iuml
C4_L1("C4 snippet")
Person(customer, "Customer", "A customer to our sample system")
System(portal, "Portal", "A sample system")
Rel(customer, portal, "Does any action you want to the sample portal")
@enduml
Once parsed, it will be represented by the following abstract syntax tree:

The rule listener will be called exactly twice for each node of that tree: once when entering the node, and once when exiting the node.
The order in which the nodes are visited is not guaranteed as it could be changed any time.
What does that imply exactly? Well, let’s get back to our noDuplicateRelationships listener.
The NoDuplicateRelationshipsListener class
The NoDuplicateRelationshipsListener implements the C4BaseListener, which means that it is able to parse any C4 source file (L1, L2 and L3). If you think that your listener should only work for one or two types of grammar, then you can simply choose to implement one or many of:
As describe on the sequence diagram below, every time the linter will visit a node, it will call the appropriate enter and exit methods.
So here, every time the linter will enter a L1 relationship, it will call the following method:
@Override
public void enterRelationship(final C4L1Parser.RelationshipContext ctx) {
final List<TerminalNode> aliases = ctx.Alias();
final TerminalNode sourceAlias = aliases.get(0);
final TerminalNode targetAlias = aliases.get(1);
final TerminalNode label = ctx.String();
checkRelationshipIsUnique(sourceAlias, targetAlias, label);
}
As you can see, the implementation here is really simple: we simply collect relationships source, target and label, and if we encounter that triplet more than once, we raise a linting error. We could have implemented the exitRelationship method instead and it would have worked just as well.
Now, let’s imagine for a second that we cannot collect and report at the same time - for example because we would need to check that an alias is declared before being used. We could then implement the AbstractLintingRule wrapUp method. That method is called once the parsing (collecting part) is over.
Testing the rule and its listener
Linting rules are fundamentally the same class, but each time with some small differences. Writing tests can be a hassle, and maintaining them sure is one. That’s why we developed a testbed for rules. It is far from being perfect but it should allow you to focus on the results of the tests while the implementation can be updated at once for every written test, because it relies in the testbed.
Here are the tests that you should implement for your rule, and how to actually do that:
- Testing the list of parsers accepted by the rule.
Remember, you could write a rule that is intended to work on C4 L1 only. To test that, your test class has to implement the AcceptableParsersTypesLintingRuleTestCase interface, and to provide the expected test output through the @AcceptableParsersTypes annotation:
@AcceptableParsersTypes({ C4L1Parser.class, C4L2Parser.class, C4L3Parser.class }) public NoDuplicateRelationshipsRuleTest implements AcceptableParsersTypesLintingRuleTestCase ...{ ... } - Testing the ANTLR
ParseTreeproduced by the rule when you feed it with a C4 file.To test that, your test class has to implement the SelectParseTreeLintingRuleTestCase interface, and to provide the test input and the expected output through the @SelectParseTrees annotation:
@SelectParseTrees({ @SelectParseTree( sourceFilename = "/org/thewonderlemming/c4plantuml/linter/rules/builtin/c4l1__gaming_portal.puml", sourceType = SourceType.C4_L1, expectedParseTreeType = C4L1Parser.DiagramContext.class), @SelectParseTree( sourceFilename = "/org/thewonderlemming/c4plantuml/linter/rules/builtin/c4l2__gaming_portal.puml", sourceType = SourceType.C4_L2, expectedParseTreeType = C4L2Parser.DiagramContext.class), @SelectParseTree( sourceFilename = "/org/thewonderlemming/c4plantuml/linter/rules/builtin/c4l3__gaming_portal__backoffice.puml", sourceType = SourceType.C4_L3, expectedParseTreeType = C4L3Parser.DiagramContext.class) }) public NoDuplicateRelationshipsRuleTest implements SelectParseTreeLintingRuleTestCase ...{ ... } - Testing that the expected parse tree listener is created, given an
ANTLRparse tree.To test that, your test class has to implement CreateParseTreeListenerLintingRuleTestCase interface, and to provide the test input and expected output through the @CreateParseTreeListener annotation:
@CreateParseTreeListeners({ @CreateParseTreeListener( expectedListenerType = NoDuplicateRelationshipsListener.class, givenParserType = C4L1Parser.class), @CreateParseTreeListener( expectedListenerType = NoDuplicateRelationshipsListener.class, givenParserType = C4L2Parser.class), @CreateParseTreeListener( expectedListenerType = NoDuplicateRelationshipsListener.class, givenParserType = C4L3Parser.class) }) public NoDuplicateRelationshipsRuleTest implements CreateParseTreeListenerLintingRuleTestCase ...{ ... } - Testing that the rule detects the expected errors and writes the expected messages.
To test that, your test class has to implement the LintSourceFileLintingRuleTestCase interface, and to provide the test input and expected output through the @LintSourceFile annotation:
@LintSourceFiles({ @LintSourceFile( filename = "/org/thewonderlemming/c4plantuml/linter/rules/builtin/c4l1__gaming_portal.puml", expectedReports = { }), @LintSourceFile( filename = "/org/thewonderlemming/c4plantuml/linter/rules/builtin/c4l2__gaming_portal.puml", expectedReports = { }), @LintSourceFile( filename = "/org/thewonderlemming/c4plantuml/linter/rules/builtin/c4l3__gaming_portal__backoffice.puml", expectedReports = { }), @LintSourceFile( filename = "/org/thewonderlemming/c4plantuml/linter/rules/builtin/NoDuplicateRelationshipsRule/c4l1__duplicate_relationships.puml", expectedReports = { "Duplicate relationship found for <'support' -> 'portal': '\"Support players through\"'>", "Duplicate relationship found for <'portal' -> 'paypartner': '\"Handles players subscriptions and purchases through\"'>" }), @LintSourceFile( filename = "/org/thewonderlemming/c4plantuml/linter/rules/builtin/NoDuplicateRelationshipsRule/c4l2__duplicate_relationships.puml", expectedReports = { "Duplicate relationship found for <'player' -> 'website': '\"Downloads the client from\"'>", "Duplicate relationship found for <'player' -> 'website': '\"Downloads the client from\"'>" }), @LintSourceFile( filename = "/org/thewonderlemming/c4plantuml/linter/rules/builtin/NoDuplicateRelationshipsRule/c4l3__duplicate_relationships.puml", expectedReports = { "Duplicate relationship found for <'authweb' -> 'forums': '\"Creates accounts on behalf of the players into\"'>", "Duplicate relationship found for <'paymentmgr' -> 'authmgr': '\"Retrieves player's account status from\"'>", "Duplicate relationship found for <'paymentmgr' -> 'authmgr': '\"Retrieves player's account status from\"'>" }), }) public NoDuplicateRelationshipsRuleTest implements LintSourceFileLintingRuleTestCase ...{ ... } - Implementing at least one @SUT annotated method.
Please note that only the first one (alphabetical order) will be used to instance the system under test (a.k.a. the rule instance being tested). If you skip that step, none of the previous test will run, as there will be no test instance to run.
public class NoDuplicateRelationshipsRuleTest implements ... { @SUT NoDuplicateRelationshipsRule getSutWithDefaults() { return new NoDuplicateRelationshipsRule( RuleParameters .builder() .build()); } }You can of course test various SUT setup by making use of JUnit’s
@Nestedannotation and all of the previous information:public class NoDuplicateRelationshipsRuleTest { // PUT ALL YOUR ANNOTATIONS HERE AS PREVIOUSLY SHOWED @Nested class WithDefaultSettings implements AcceptableParsersTypesLintingRuleTestCase, SelectParseTreeLintingRuleTestCase, CreateParseTreeListenerLintingRuleTestCase, LintSourceFileLintingRuleTestCase { @SUT NoDuplicateRelationshipsRule getSut() { return new NoDuplicateRelationshipsRule( RuleParameters .builder() .build()); } } // PUT ALL YOUR ANNOTATIONS HERE AS PREVIOUSLY SHOWED @Nested class WithSomeCustomSettings implements AcceptableParsersTypesLintingRuleTestCase, SelectParseTreeLintingRuleTestCase, CreateParseTreeListenerLintingRuleTestCase, LintSourceFileLintingRuleTestCase { @SUT NoDuplicateRelationshipsRule getSut() { return new NoDuplicateRelationshipsRule( RuleParameters ... // Some custom settings here .builder() .build()); } } }
That was easy, right? Now that you’re done testing the rule, let’s move to the next step and see how to integrate it to the plugin to make it available.
The MOJO layer
At this point, you have a working linting rule, which you need to be able to configure through a pom.xml file, and this is we are going to do.
Integrating to the plugin
The linting rules configuration is held by the BuiltInRules POJO. Every new rule configuration will appear as a new property of that POJO.
As an example, our noDuplicateRelationships rule will be added as the noDuplicateRelationships property, and we will define a POJO named NoDuplicateRelationships as a type. That POJO will hold any property that would be required/useful to our rule.
This will allow our users to do the following in his pom.xml file:
<plugin>
<groupId>org.thewonderlemming.c4plantuml</groupId>
<artifactId>c4plantuml-maven-plugin</artifactId>
...
<executions>
<execution>
...
<configuration>
<rules>
<noDuplicateRelationships>
...
</noDuplicateRelationships>
</rules>
</configuration>
</execution>
</executions>
</plugin>
Here are the steps to follow:
-
Create a new POJO named after your rule (e.g.
NoDuplicateRelationships) in theorg.thewonderlemming.c4plantuml.mojo.lintingpackage.That POJO must at least contain one property named
isActivated, which will allow our users to disable the rule within their projects if they don’t need it:public class NoDuplicateRelationships { private boolean isActivated = true; // PUT ANY REQUIRED PROPERTY HERE (AND INITIALIZE IT TO AVOID NULL POINTERS EXCEPTIONS) public boolean isActivated() { return isActivated; } public void setActivated(final boolean isActivated) { this.isActivated = isActivated; } // PUT THE MISSING SETTERS/GETTERS HERE... } -
Add your new POJO to the BuiltInRules POJO as a new property:
public class BuiltInRules { ... private NoDuplicateRelationships noDuplicateRelationships; ... } -
Add the setters and getters, and do not forget to initialize the property in the constructor:
public class BuiltInRules { ... public BuiltInRules() { ... this.noDuplicateRelationships = new NoDuplicateRelationships(); ... } ... public NoDuplicateRelationships getNoDuplicateRelationships() { return noDuplicateRelationships; } ... public void setNoDuplicateRelationships(final NoDuplicateRelationships noDuplicateRelationships) { this.noDuplicateRelationships = noDuplicateRelationships; } ... } -
Create a
NoDuplicateRelationshipsRuleParametersFactoryfactory in theorg.thewonderlemming.c4plantuml.mojo.linting.rules.builtin.parameterpackage. It must implement the parameterized AbstractRuleParametersFactory abstract class. You can have a look at the parameters directory on Gitlab to get examples. It is fairly simple. -
Update the BuiltInLintingRulesFactory enumeration by adding a new label that glues everything together:
- the name of the rule as it should appear in the
pom.xmlfile. - the linting rule class.
- the linting rule factory class.
As an example, this is what it looks like for our
NoDuplicateRelationshipsrule:public enum BuiltInLintingRulesFactory { ... NO_DUPLICATES_IN_RELATIONSHIPS( "noDuplicateRelationships", NoDuplicateRelationshipsRule.class, NoDuplicateRelationshipsRuleParametersFactory.class), ... } - the name of the rule as it should appear in the
Testing the integration to the plugin
Now it is time for testing. There are two things to do here:
- Updating a unit test and disabling your rule to avoid an unexpected failure: open the LintingMojoTest file and add a new line around the
GET_TEST_PARAMETERS_GIVEN_FAIL_ON_LINT_ERROR_PARAMETERarguments provider.
As an example, here’s what it looks like with our NoDuplicateRelationships rule:
...
static class Provider {
public static final Function<Boolean, Collection<Object[]>> GET_TEST_PARAMETERS_GIVEN_FAIL_ON_LINT_ERROR_PARAMETER = failOnLintError -> {
...
builtinRules.getNoDuplicateRelationships().setActivated(false);
...
}
}
- Writing of integration tests.
Writing tests and maintaining them can be a hassle, remember?
That’s why we created another testbed, based on Maven’s testbed. Basically, it allows you to create new tests by dropping small projects configuration structures in the resources directory.
Let’s have a look at our
NoDuplicateRelationshipsrule, to understand how it is done.We are going to open the LintingMojoIT test class and add a new test that should fail the Maven build when duplicate relationships are found.
We will call that method
shouldThrow_givenDuplicatedRelationship. Remember the naming as the testbed is relying on a naming convention to find its files: it will look in its classpath for a directory calledLintingMojoIT_shouldThrow_givenDuplicatedRelationship.public class LintingMojoIT { ... static class TestData { static class ErrorMessage { ... static final String DUPLICATED_RELATIONSHIPS = "Duplicate relationship found for <'src' -> 'dest': '\"Test\"'>"; ... } } ... @VerifierConfig(resetStreamsAtTheEnd = false, verifyErrorFreeLogs = false) @Test public void shouldThrow_givenDuplicatedRelationship() throws VerificationException { this.verifier.verifyTextInLog(TestData.ErrorMessage.DUPLICATED_RELATIONSHIPS); this.verifier.verifyTextInLog(TestData.ErrorMessage.BUILD_FAILURE); this.verifier.resetStreams(); } ... }The
@VerifierConfigannotation is there to prevent the testbed from clearing the Maven logs at the end of the test and to fail the test on a Maven build failure/error, as we want to perform some checking.First, we are looking for our expected error message in the Maven logs. Then, we check that the Maven build failed, and finaly we clean the logs.
Finally, for our test to work, we need to create the expected directory structure in the resources package.
Please notice how the pom.xml file is configured:
-
Notice the artifact name.
-
Notice the parent.
-
Notice that the version is parameterized.
-
Notice that every other rule is disabled. Yes, that is annoying but it is better if you want to avoid side effects.
That might seem a little complicated at first, but if you think about it, adding integration tests is as easy as adding a small
pom.xmlalong with a set of source files! -
Updating the linting rules page
We’re almost there! The only thing left to do is to is to update the Built-in linting rules page so that our users will be able to understand and use your rule.
Nothing complicated here. Just update the built-in-rules.md.vm file. It is already filled by samples, and is relying on the Markdown syntax and Velocity: every Maven property that you write will be filtered and replaced by the build current value (ex: ${project.version}, to get the current version of the plugin).
Please notice that the rules are sorted alphabetically. Thank you for keeping it that way.
Congratulations, you’ve written your first linting rule! The only thing left is to submit your merge request :)


