Skip to content

Commit 00ddc31

Browse files
authored
PMD integration & test (#5393)
- Implemented PmdModule analogical to CheckstyleModule - Added test with some PMD violations and simple ruleset Resolves #5379 Pull request: #5393
1 parent 4540af4 commit 00ddc31

File tree

12 files changed

+391
-2
lines changed

12 files changed

+391
-2
lines changed
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// `PmdModule` Performs checks on Java source files using https://pmd.github.io/[PMD] and generates reports from these checks.
2+
//
3+
// PMD is a source code analyzer for Java that finds common programming flaws like unused variables,
4+
// empty catch blocks, unnecessary object creation, and more.
5+
//
6+
// To use this plugin in a Java module:
7+
//
8+
// 1. Extend JavaModule with PmdModule.
9+
// 2. [Optional] Define a PMD ruleset file(s) with `pmdRulesets` (default is `pmd-rules.xml` in root).
10+
// 3. [Optional] Define a version with `pmdVersion`.
11+
// 3. Run the `pmd` command.
12+
13+
package build
14+
15+
import mill._, javalib._
16+
import mill.javalib.pmd.PmdModule
17+
18+
object `package` extends JavaModule with PmdModule {
19+
def pmdVersion = "7.15.0"
20+
}
21+
22+
// Here's an example of simple PMD ruleset file, which is used to setup analysis rules.
23+
//
24+
// Be careful, rule formats can differ for different PMD versions.
25+
/** See Also: pmd-ruleset.xml */
26+
27+
/** Usage
28+
> ./mill pmd -s true -v false # default format is text
29+
...src/EmptyCatchBlock.java:7: EmptyCatchBlock: Avoid empty catch blocks...
30+
PMD found 1 violation(s)
31+
*/
32+
33+
// The `pmd` command accepts several options to customize its behavior. Mill supports:
34+
//
35+
// * --stdout / -s : Enable output to stdout. False by default. (PMD will write output to a file regardless of this option).
36+
//
37+
// * --format / -f : Output format of the report (`text`, `xml`, `html`). Defaults to `text`.
38+
//
39+
// * --fail-on-violation / -v : Fail if violations are found (true by default).
40+
//
41+
// For a full list of PMD options, see the PMD documentation:
42+
// https://pmd.github.io/pmd/pmd_userdocs_cli_reference.html.
43+
//
44+
// Here's an example of producing HTML report:
45+
/** Usage
46+
> ./mill pmd -f "html" -s true -v false
47+
...<html><head><title>PMD</title></head><body>...
48+
...<center><h3>PMD report</h3></center><center><h3>Problems found</h3></center><table align="center" cellspacing="0" cellpadding="3"><tr>...
49+
...<th>#</th><th>File</th><th>Line</th><th>Problem</th></tr>...
50+
...<tr bgcolor="lightgrey">...
51+
...<td align="center">1</td>...
52+
...<td width="*%">...src/EmptyCatchBlock.java</td>...
53+
...<td align="center" width="5%">7</td>...
54+
...<td width="*"><a href="https://docs.pmd-code.org/pmd-doc-7.15.0/pmd_rules_java_errorprone.html#emptycatchblock">Avoid empty catch blocks</a></td>...
55+
...</tr>...
56+
...</table></body></html>...
57+
...PMD found 1 violation(s)...
58+
*/
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<ruleset name="Example ruleset"
3+
xmlns="http://pmd.sourceforge.net/ruleset/2.0.0"
4+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
5+
xsi:schemaLocation="http://pmd.sourceforge.net/ruleset/2.0.0
6+
https://pmd.sourceforge.io/ruleset_2_0_0.xsd">
7+
<description>
8+
Example PMD ruleset for Mill integration (Updated for PMD 7.15.0)
9+
</description>
10+
<rule ref="category/java/errorprone.xml/EmptyCatchBlock"/>
11+
</ruleset>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package src;
2+
3+
public class EmptyCatchBlock {
4+
public void bar() {
5+
try {
6+
// do something
7+
} catch (Exception e) {
8+
// violation
9+
}
10+
}
11+
}

libs/jvmlib/package.mill

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,8 @@ object `package` extends MillStableScalaModule {
103103
s"${dep.group}:${dep.id}:${dep.version}"
104104
},
105105
"The dependency containing the worker implementation to be loaded at runtime."
106-
)
106+
),
107+
BuildInfo.Value("pmdVersion", Deps.RuntimeDeps.pmdDist.version)
107108
)
108109
}
109110

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package mill.javalib.pmd
2+
3+
import mainargs.{Leftover, ParserForClass, arg, main}
4+
5+
@main(doc = "Arguments for PmdModule")
6+
case class PmdArgs(
7+
@arg(name = "fail-on-violation", short = 'v', doc = "Fail if violations are found")
8+
failOnViolation: Boolean = true,
9+
@arg(name = "stdout", short = 's', doc = "Output to stdout")
10+
stdout: Boolean = false,
11+
@arg(name = "format", short = 'f', doc = "Output format (text, xml, html, etc.)")
12+
format: String = "text",
13+
@arg(doc = "Specify sources to check")
14+
sources: Leftover[String]
15+
)
16+
17+
object PmdArgs {
18+
implicit val PFC: ParserForClass[PmdArgs] = ParserForClass[PmdArgs]
19+
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
package mill.javalib.pmd
2+
3+
import mill.*
4+
import mill.api.{Discover, ExternalModule, TaskCtx}
5+
import mill.jvmlib.api.Versions
6+
import mill.scalalib.scalafmt.ScalafmtModule.sources
7+
import mill.scalalib.{CoursierModule, Dep, DepSyntax, OfflineSupportModule}
8+
import mill.util.Jvm
9+
10+
/**
11+
* Checks Java source files with PMD static code analyzer [[https://pmd.github.io/]].
12+
*/
13+
trait PmdModule extends CoursierModule, OfflineSupportModule {
14+
15+
/**
16+
* Runs PMD and returns the number of violations found (exit code).
17+
*
18+
* @note [[sources]] are processed when no [[PmdArgs.sources]] are specified.
19+
*/
20+
def pmd(@mainargs.arg pmdArgs: PmdArgs): Command[(exitCode: Int, outputPath: PathRef)] =
21+
Task.Command {
22+
val (outputPath, exitCode) = pmd0(pmdArgs.format, pmdArgs.sources)()
23+
pmdHandleExitCode(
24+
pmdArgs.stdout,
25+
pmdArgs.failOnViolation,
26+
exitCode,
27+
outputPath,
28+
pmdArgs.format
29+
)
30+
(exitCode, outputPath)
31+
}
32+
33+
protected def pmd0(format: String, leftover: mainargs.Leftover[String]) =
34+
Task.Anon {
35+
val output = Task.dest / s"pmd-output.$format"
36+
os.makeDir.all(output / os.up)
37+
val baseArgs = Seq(
38+
"-d",
39+
if (leftover.value.nonEmpty) leftover.value.mkString(",")
40+
else sources().map(_.path.toString()).mkString(","),
41+
"-R",
42+
pmdRulesets().map(_.path.toString).mkString(","),
43+
"-f",
44+
format,
45+
"-r",
46+
output.toString
47+
)
48+
49+
val args =
50+
if (isPmd6OrOlder(this.pmdVersion())) pmdOptions() ++ baseArgs
51+
else pmdOptions() ++ (Seq("check") ++ baseArgs)
52+
val mainCls =
53+
if (isPmd6OrOlder(this.pmdVersion())) "net.sourceforge.pmd.PMD"
54+
else "net.sourceforge.pmd.cli.PmdCli"
55+
val jvmArgs = pmdLanguage().map(lang => s"-Duser.language=$lang").toSeq
56+
57+
Task.log.info("Running PMD...")
58+
Task.log.debug(s"with $args")
59+
Task.log.info(s"Writing PMD output to: $output...")
60+
61+
val exitCode = Jvm.callProcess(
62+
mainCls,
63+
classPath = pmdClasspath().map(_.path).toVector,
64+
mainArgs = args,
65+
cwd = moduleDir,
66+
stdin = os.Inherit,
67+
stdout = os.Inherit,
68+
check = false,
69+
jvmArgs = jvmArgs
70+
).exitCode
71+
72+
(PathRef(output), exitCode)
73+
}
74+
75+
private def pmdHandleExitCode(
76+
stdout: Boolean,
77+
failOnViolation: Boolean,
78+
exitCode: Int,
79+
output: PathRef,
80+
format: String
81+
)(implicit ctx: TaskCtx): Int = {
82+
exitCode match
83+
case 0 => Task.log.info("No violations found and no recoverable error occurred.")
84+
case 1 => Task.log.error("PMD finished with an exception.")
85+
case 2 => Task.log.error("PMD command-line parameters are invalid or missing.")
86+
case 4 =>
87+
reportViolations(failOnViolation, countViolations(output, format, stdout))
88+
case 5 =>
89+
reportViolations(failOnViolation, countViolations(output, format, stdout))
90+
throw new RuntimeException("At least one recoverable PMD error has occurred.")
91+
case x => Task.log.error(s"Unsupported PMD exit code: $x")
92+
exitCode
93+
}
94+
95+
private def countViolations(
96+
output: PathRef,
97+
format: String,
98+
stdout: Boolean
99+
)(implicit ctx: TaskCtx): Option[Int] = {
100+
var violationCount: Option[Int] = None
101+
val lines = os.read.lines(output.path)
102+
if (lines.nonEmpty) {
103+
if (stdout) {
104+
Task.log.info("PMD violations:")
105+
lines.foreach(line => Task.log.info(line))
106+
}
107+
// For "text" format: each line is a violation
108+
if (format == "text") {
109+
violationCount = Some(lines.size)
110+
}
111+
// For "xml" format: count <violation ...> tags
112+
else if (format == "xml") {
113+
violationCount = Some(lines.count(_.trim.startsWith("<violation")))
114+
}
115+
// For "html" format: count lines with <tr but skip the header row
116+
else if (format == "html") {
117+
violationCount =
118+
Some(lines.count(line => line.trim.startsWith("<tr") && !line.contains("<th")))
119+
}
120+
} else {
121+
violationCount = Some(0)
122+
}
123+
violationCount
124+
}
125+
126+
private def reportViolations(
127+
failOnViolation: Boolean,
128+
violationCount: Option[Int]
129+
)(implicit ctx: TaskCtx): Unit = {
130+
if (failOnViolation)
131+
throw new RuntimeException(s"PMD found ${violationCount.getOrElse("some")} violation(s)")
132+
else
133+
Task.log.error(s"PMD found ${violationCount.getOrElse("some")} violation(s)")
134+
}
135+
136+
/**
137+
* Classpath for running PMD.
138+
*/
139+
def pmdClasspath: T[Seq[PathRef]] = Task {
140+
val version = pmdVersion()
141+
defaultResolver().classpath(Seq(mvn"net.sourceforge.pmd:pmd-dist:$version"))
142+
}
143+
144+
/** PMD rulesets files. Defaults to `pmd-ruleset.xml`. */
145+
def pmdRulesets: Sources = Task.Sources(moduleDir / "pmd-ruleset.xml")
146+
147+
/** Additional arguments for PMD. */
148+
def pmdOptions: T[Seq[String]] = Task {
149+
Seq.empty[String]
150+
}
151+
152+
/** User language of the JVM running PMD. */
153+
def pmdLanguage: T[Option[String]] = Task.Input {
154+
sys.props.get("user.language")
155+
}
156+
157+
/** Helper to check if the version is <= 6. False by default. */
158+
private def isPmd6OrOlder(version: String): Boolean = {
159+
version
160+
.split(":").lastOption
161+
.flatMap(_.takeWhile(_ != '.').toIntOption)
162+
.exists(_ < 7)
163+
}
164+
165+
/** PMD version. */
166+
def pmdVersion: T[String] = Task { Versions.pmdVersion }
167+
}
168+
169+
/**
170+
* External module for PMD integration.
171+
* Allows usage via `import mill.javalib.pmd.PmdModule` in build.sc.
172+
*/
173+
object PmdModule extends ExternalModule, PmdModule {
174+
lazy val millDiscover: Discover = Discover[this.type]
175+
def defaultCommandName() = "pmd"
176+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package mill.javalib
2+
3+
import mill.api.ExternalModule
4+
5+
package object pmd extends ExternalModule.Alias(PmdModule)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<ruleset name="Test Ruleset"
2+
xmlns="https://pmd.github.io/ruleset/2.0.0"
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xsi:schemaLocation="https://pmd.github.io/ruleset/2.0.0 https://pmd.github.io/ruleset_2_0_0.xsd"
5+
description="A minimal ruleset for PMD testing.">
6+
<rule ref="category/java/bestpractices.xml/UnusedLocalVariable"/>
7+
<rule ref="category/java/codestyle.xml/UnnecessaryConstructor"/>
8+
<rule ref="category/java/errorprone.xml/NullAssignment"/>
9+
</ruleset>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package sources;
2+
3+
public class TestSource {
4+
5+
public TestSource() { // violation (UnnecessaryConstructor)
6+
}
7+
8+
public void doSomething() {
9+
int x = 5; // violation (UnusedLocalVariable)
10+
}
11+
}

0 commit comments

Comments
 (0)