diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 65ee93263..31c750ae3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -83,7 +83,7 @@ ### Testing - In-game (`./src/testMod/java/dan200/computercraft/ingame/`): These tests are run on an actual Minecraft server, using the same system Mojang do][mc-test]. The aim of these is to test in-game behaviour of blocks and peripherals. - These tests are run with `./gradlew testServer`. + These tests are run with `./gradlew runGametest`. ## CraftOS tests CraftOS's tests are written using a test system called "mcfly", heavily inspired by [busted] (and thus RSpec). Groups of diff --git a/build.gradle b/build.gradle index 8559c9150..03016cc71 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,4 @@ plugins { - id "checkstyle" - id "jacoco" id "maven-publish" id "com.matthewprenger.cursegradle" version "1.4.0" id "com.github.breadmoirai.github-release" version "2.2.12" @@ -12,9 +10,10 @@ id "com.github.johnrengelman.shadow" version "7.1.2" id("cc-tweaked.illuaminate") id("cc-tweaked.java-convention") + id("cc-tweaked") } - +import cc.tweaked.gradle.CheckChangelog import cc.tweaked.gradle.ExtensionsKt import cc.tweaked.gradle.IlluaminateExec import cc.tweaked.gradle.IlluaminateExecToDir @@ -25,16 +24,7 @@ group = "org.squiddev" archivesBaseName = "cc-tweaked-${mc_version}" -def javaVersion = JavaLanguageVersion.of(8) -java { - toolchain { - languageVersion = javaVersion - } - - withSourcesJar() - withJavadocJar() - registerFeature("extraMods") { usingSourceSet(sourceSets.main) } -} +java.registerFeature("extraMods") { usingSourceSet(sourceSets.main) } sourceSets { main.resources { @@ -115,14 +105,6 @@ accessTransformer file('src/testMod/resources/META-INF/accesstransformer.cfg') shadowJar {} } -repositories { - mavenCentral() - maven { - name "SquidDev" - url "https://squiddev.cc/maven" - } -} - configurations { shade { transitive = false } implementation.extendsFrom shade @@ -133,8 +115,6 @@ accessTransformer file('src/testMod/resources/META-INF/accesstransformer.cfg') } dependencies { - checkstyle "com.puppycrawl.tools:checkstyle:8.25" - minecraft "net.minecraftforge:forge:${mc_version}-${forge_version}" annotationProcessor 'org.spongepowered:mixin:0.8.4:processor' @@ -222,77 +202,21 @@ accessTransformer file('src/testMod/resources/META-INF/accesstransformer.cfg') assemble.dependsOn("shadowJar") -[ - tasks.named("compileJava", JavaCompile.class), - tasks.named("compileTestJava", JavaCompile.class), - tasks.named("compileTestModJava", JavaCompile.class) -].forEach { - it.configure { - options.compilerArgs << "-Xlint" << "-Xlint:-processing" - } -} - processResources { inputs.property "version", mod_version inputs.property "mcversion", mc_version - def hash = 'none' - Set contributors = [] - try { - hash = ["git", "-C", projectDir, "rev-parse", "HEAD"].execute().text.trim() + inputs.property("gitHash", cct.gitHash) - def blacklist = ['GitHub', 'Daniel Ratcliffe', 'Weblate'] - - // Extract all authors, commiters and co-authors from the git log. - def authors = ["git", "-C", projectDir, "log", "--format=tformat:%an <%ae>%n%cn <%ce>%n%(trailers:key=Co-authored-by,valueonly)"] - .execute().text.readLines().unique() - - // We now pass this through git's mailmap to de-duplicate some authors. - def remapAuthors = ["git", "check-mailmap", "--stdin"].execute() - remapAuthors.withWriter { stdin -> - if (stdin !instanceof BufferedWriter) stdin = new BufferedWriter(stdin) - - authors.forEach { - if (it == "") return - if (!it.endsWith(">")) it += ">" // Some commits have broken Co-Authored-By lines! - stdin.writeLine(it) - } - stdin.close() - } - - // And finally extract out the actual name. - def emailRegex = ~/^([^<]+) <.+>$/ - remapAuthors.text.readLines().forEach { - def matcher = it =~ emailRegex - matcher.find() - def name = matcher.group(1) - if (!blacklist.contains(name)) contributors.add(name) - } - } catch (Exception e) { - e.printStackTrace() - } - inputs.property "commithash", hash - duplicatesStrategy = DuplicatesStrategy.INCLUDE - - from(sourceSets.main.resources.srcDirs) { - include 'META-INF/mods.toml' - include 'data/computercraft/lua/rom/help/credits.txt' - - expand 'version': mod_version, - 'mcversion': mc_version, - 'gitcontributors': contributors.sort(false, String.CASE_INSENSITIVE_ORDER).join('\n') + filesMatching("data/computercraft/lua/rom/help/credits.txt") { + expand("gitContributors": cct.gitContributors.get().join("\n")) } - from(sourceSets.main.resources.srcDirs) { - exclude 'META-INF/mods.toml' - exclude 'data/computercraft/lua/rom/help/credits.txt' + filesMatching("META-INF/mods.toml") { + expand("version": mod_version, "mcversion": mc_version) } } -sourcesJar { - duplicatesStrategy = DuplicatesStrategy.INCLUDE -} - // Web tasks List mkCommand(String command) { @@ -378,16 +302,6 @@ commandLine mkCommand('"node_modules/.bin/ts-node" -T --esm src/web/transform.ts } } -jacocoTestReport { - dependsOn('test') - reports { - xml.required = true - html.required = true - } -} - -test.finalizedBy("jacocoTestReport") - def lintLua = tasks.register("lintLua", IlluaminateExec.class) { group = JavaBasePlugin.VERIFICATION_GROUP description = "Lint Lua (and Lua docs) with illuaminate" @@ -417,88 +331,29 @@ commandLine mkCommand('"node_modules/.bin/ts-node" -T --esm src/web/transform.ts into "test-files/server" } -def testServerClassDumpDir = new File(buildDir, "jacocoClassDump/runTestServer") - -def testServer = tasks.register("testServer", JavaExec.class) { +def runGametest = tasks.register("runGametest", JavaExec.class) { group("In-game tests") description("Runs tests on a temporary Minecraft instance.") - dependsOn(setupServer, "cleanTestServer") - finalizedBy("jacocoTestServerReport") + dependsOn(setupServer, "cleanRunGametest") // Copy from runTestServer. We do it in this slightly odd way as runTestServer // isn't created until the task is configured (which is no good for us). ExtensionsKt.copyToFull(tasks.getByName("runTestServer"), it) - - // Jacoco and modlauncher don't play well together as the classes loaded in-game don't - // match up with those written to disk. We get Jacoco to dump all classes to disk, and - // use that when generating the report. - jacoco.applyTo(it) - it.jacoco.setIncludes(["dan200.computercraft.*"]) - it.jacoco.setClassDumpDir(testServerClassDumpDir) - outputs.dir(testServerClassDumpDir) - // Older versions of modlauncher don't include a protection domain (and thus no code - // source). Jacoco skips such classes by default, so we need to explicitly include them. - it.jacoco.setIncludeNoLocationClasses(true) } -tasks.register("jacocoTestServerReport", JacocoReport.class) { - group("In-game tests") - description("Generate coverage reports for testServer") - dependsOn(testServer) +cct.jacoco(runGametest) - executionData(new File(buildDir, "jacoco/testServer.exec")) - sourceDirectories.from(sourceSets.main.allJava.srcDirs) - classDirectories.from(testServerClassDumpDir) - - reports { - xml.enabled true - html.enabled true - } -} - -check.dependsOn(testServer) +tasks.check { dependsOn(runGametest) } // Upload tasks -def checkRelease = tasks.register("checkRelease") { - group "upload" - description "Verifies that everything is ready for a release" - - inputs.property "version", mod_version - inputs.file("src/main/resources/data/computercraft/lua/rom/help/changelog.md") - inputs.file("src/main/resources/data/computercraft/lua/rom/help/whatsnew.md") - - doLast { - def ok = true - - // Check we're targetting the current version - def whatsnew = new File(projectDir, "src/main/resources/data/computercraft/lua/rom/help/whatsnew.md").readLines() - if (whatsnew[0] != "New features in CC: Tweaked $mod_version") { - ok = false - project.logger.error("Expected `whatsnew.md' to target $mod_version.") - } - - // Check "read more" exists and trim it - def idx = whatsnew.findIndexOf { it == 'Type "help changelog" to see the full version history.' } - if (idx == -1) { - ok = false - project.logger.error("Must mention the changelog in whatsnew.md") - } else { - whatsnew = whatsnew.getAt(0..().named("libs") + checkstyle(libs.findLibrary("checkstyle").get()) +} + +// Configure default JavaCompile tasks with our arguments. +sourceSets.all { + tasks.named(compileJavaTaskName, JavaCompile::class.java) { + // Processing just gives us "No processor claimed any of these annotations", so skip that! + options.compilerArgs.addAll(listOf("-Xlint", "-Xlint:-processing")) + } +} + +tasks.withType(JavaCompile::class.java).configureEach { + options.encoding = "UTF-8" +} + +tasks.test { + finalizedBy("jacocoTestReport") + + useJUnitPlatform() + testLogging { + events("skipped", "failed") + } +} + +tasks.withType(JacocoReport::class.java).configureEach { + reports.xml.required.set(true) + reports.html.required.set(true) +} + spotless { encoding = StandardCharsets.UTF_8 lineEndings = LineEnding.UNIX diff --git a/buildSrc/src/main/kotlin/cc/tweaked/gradle/CCTweakedExtension.kt b/buildSrc/src/main/kotlin/cc/tweaked/gradle/CCTweakedExtension.kt new file mode 100644 index 000000000..8e2eb2360 --- /dev/null +++ b/buildSrc/src/main/kotlin/cc/tweaked/gradle/CCTweakedExtension.kt @@ -0,0 +1,124 @@ +package cc.tweaked.gradle + +import org.gradle.api.NamedDomainObjectProvider +import org.gradle.api.Project +import org.gradle.api.attributes.TestSuiteType +import org.gradle.api.file.FileSystemOperations +import org.gradle.api.provider.Provider +import org.gradle.api.reporting.ReportingExtension +import org.gradle.api.tasks.JavaExec +import org.gradle.api.tasks.SourceSetContainer +import org.gradle.configurationcache.extensions.capitalized +import org.gradle.kotlin.dsl.get +import org.gradle.language.base.plugins.LifecycleBasePlugin +import org.gradle.testing.jacoco.plugins.JacocoCoverageReport +import org.gradle.testing.jacoco.plugins.JacocoPluginExtension +import org.gradle.testing.jacoco.plugins.JacocoTaskExtension +import org.gradle.testing.jacoco.tasks.JacocoReport +import java.io.BufferedWriter +import java.io.IOException +import java.io.OutputStreamWriter +import java.util.regex.Pattern + +abstract class CCTweakedExtension( + private val project: Project, + private val fs: FileSystemOperations, +) { + /** Get the hash of the latest git commit. */ + val gitHash: Provider = gitProvider(project) { + ProcessHelpers.captureOut("git", "-C", project.projectDir.absolutePath, "rev-parse", "HEAD") + } + + /** Get the current git branch. */ + val gitBranch: Provider = gitProvider(project) { + ProcessHelpers.captureOut("git", "-C", project.projectDir.absolutePath, "rev-parse", "--abbrev-ref", "HEAD") + } + + /** Get a list of all contributors to the project. */ + val gitContributors: Provider> = gitProvider(project) { + val authors: Set = HashSet( + ProcessHelpers.captureLines( + "git", "-C", project.projectDir.absolutePath, "log", + "--format=tformat:%an <%ae>%n%cn <%ce>%n%(trailers:key=Co-authored-by,valueonly)", + ), + ) + val process = ProcessHelpers.startProcess("git", "check-mailmap", "--stdin") + BufferedWriter(OutputStreamWriter(process.outputStream)).use { writer -> + for (authorName in authors) { + var author = authorName + + if (author.isEmpty()) continue + if (!author.endsWith(">")) author += ">" // Some commits have broken Co-Authored-By lines! + writer.write(author) + writer.newLine() + } + } + val contributors: MutableSet = HashSet() + for (authorLine in ProcessHelpers.captureLines(process)) { + val matcher = EMAIL.matcher(authorLine) + matcher.find() + val name = matcher.group(1) + if (!IGNORED_USERS.contains(name)) contributors.add(name) + } + + contributors.sortedWith(String.CASE_INSENSITIVE_ORDER) + } + + fun jacoco(task: NamedDomainObjectProvider) { + val classDump = project.buildDir.resolve("jacocoClassDump/${task.name}") + val reportTaskName = "jacoco${task.name.capitalized()}Report" + + val jacoco = project.extensions.getByType(JacocoPluginExtension::class.java) + task.configure { + finalizedBy(reportTaskName) + + doFirst("Clean class dump directory") { fs.delete { delete(classDump) } } + + jacoco.applyTo(this) + extensions.configure(JacocoTaskExtension::class.java) { + includes = listOf("dan200.computercraft.*") + classDumpDir = classDump + + // Older versions of modlauncher don't include a protection domain (and thus no code + // source). Jacoco skips such classes by default, so we need to explicitly include them. + isIncludeNoLocationClasses = true + } + } + + project.tasks.register(reportTaskName, JacocoReport::class.java) { + group = LifecycleBasePlugin.VERIFICATION_GROUP + description = "Generates code coverage report for the ${task.name} task." + + executionData(task.get()) + classDirectories.from(classDump) + + // Don't want to use sourceSets(...) here as we have a custom class directory. + val sourceSets = project.extensions.getByType(SourceSetContainer::class.java) + sourceDirectories.from(sourceSets["main"].allSource.sourceDirectories) + } + + project.extensions.configure(ReportingExtension::class.java) { + reports.register("${task.name}CodeCoverageReport", JacocoCoverageReport::class.java) { + testType.set(TestSuiteType.INTEGRATION_TEST) + } + } + } + + companion object { + private val EMAIL = Pattern.compile("^([^<]+) <.+>$") + private val IGNORED_USERS = setOf( + "GitHub", "Daniel Ratcliffe", "Weblate", + ) + + private fun gitProvider(project: Project, supplier: () -> T): Provider { + return project.provider { + try { + supplier() + } catch (e: IOException) { + project.logger.error("Cannot read Git Repository", e) + null + } + } + } + } +} diff --git a/buildSrc/src/main/kotlin/cc/tweaked/gradle/CCTweakedPlugin.kt b/buildSrc/src/main/kotlin/cc/tweaked/gradle/CCTweakedPlugin.kt new file mode 100644 index 000000000..c2bd83b49 --- /dev/null +++ b/buildSrc/src/main/kotlin/cc/tweaked/gradle/CCTweakedPlugin.kt @@ -0,0 +1,13 @@ +package cc.tweaked.gradle + +import org.gradle.api.Plugin +import org.gradle.api.Project + +/** + * Configures projects to match a shared configuration. + */ +class CCTweakedPlugin : Plugin { + override fun apply(project: Project) { + project.extensions.create("cct", CCTweakedExtension::class.java) + } +} diff --git a/buildSrc/src/main/kotlin/cc/tweaked/gradle/CheckChangelog.kt b/buildSrc/src/main/kotlin/cc/tweaked/gradle/CheckChangelog.kt new file mode 100644 index 000000000..b164c82cc --- /dev/null +++ b/buildSrc/src/main/kotlin/cc/tweaked/gradle/CheckChangelog.kt @@ -0,0 +1,65 @@ +package cc.tweaked.gradle + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.* +import org.gradle.language.base.plugins.LifecycleBasePlugin +import java.nio.charset.StandardCharsets + +/** + * Checks the `changelog.md` and `whatsnew.md` files are well-formed. + */ +@CacheableTask +abstract class CheckChangelog : DefaultTask() { + init { + group = LifecycleBasePlugin.VERIFICATION_GROUP + description = "Verifies the changelog and whatsnew file are consistent." + } + + @get:Input + abstract val version: Property + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val changelog: RegularFileProperty + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val whatsNew: RegularFileProperty + + @TaskAction + fun check() { + val version = version.get() + + var ok = true + + // Check we're targetting the current version + var whatsNew = whatsNew.get().asFile.readLines() + if (whatsNew[0] != "New features in CC: Tweaked $version") { + ok = false + logger.error("Expected `whatsnew.md' to target $version.") + } + + // Check "read more" exists and trim it + val idx = whatsNew.indexOfFirst { it == "Type \"help changelog\" to see the full version history." } + if (idx == -1) { + ok = false + logger.error("Must mention the changelog in whatsnew.md") + } else { + whatsNew = whatsNew.slice(0 until idx) + } + + // Check whatsnew and changelog match. + val expectedChangelog = sequenceOf("# ${whatsNew[0]}") + whatsNew.slice(1 until whatsNew.size).asSequence() + val changelog = changelog.get().asFile.readLines() + val mismatch = expectedChangelog.zip(changelog.asSequence()).filter { (a, b) -> a != b }.firstOrNull() + if (mismatch != null) { + ok = false + logger.error("whatsnew and changelog are not in sync") + } + + if (!ok) throw GradleException("Could not check release") + } +} diff --git a/buildSrc/src/main/kotlin/cc/tweaked/gradle/CheckLicense.kt b/buildSrc/src/main/kotlin/cc/tweaked/gradle/CheckLicense.kt index 2217d26be..a29d13880 100644 --- a/buildSrc/src/main/kotlin/cc/tweaked/gradle/CheckLicense.kt +++ b/buildSrc/src/main/kotlin/cc/tweaked/gradle/CheckLicense.kt @@ -25,7 +25,7 @@ fun create(api: File, main: File): FormatterStep = FormatterStep.createLazy( ) private fun getTemplateText(file: File): String = - file.readText(StandardCharsets.UTF_8).trim().replace("\${year}", "$YEAR") + file.readText().trim().replace("\${year}", "$YEAR") private fun formatFile(licenses: Licenses, contents: String, file: File): String { val license = getLicense(contents) @@ -38,8 +38,8 @@ private fun formatFile(licenses: Licenses, contents: String, file: File): String } } - private fun getExpectedLicense(licenses: Licenses, file: File): String { - var file: File? = file + private fun getExpectedLicense(licenses: Licenses, root: File): String { + var file: File? = root while (file != null) { if (file.name == "api" && file.parentFile?.name == "computercraft") return licenses.api file = file.parentFile diff --git a/buildSrc/src/main/kotlin/cc/tweaked/gradle/ProcessHelpers.kt b/buildSrc/src/main/kotlin/cc/tweaked/gradle/ProcessHelpers.kt new file mode 100644 index 000000000..15643de3e --- /dev/null +++ b/buildSrc/src/main/kotlin/cc/tweaked/gradle/ProcessHelpers.kt @@ -0,0 +1,34 @@ +package cc.tweaked.gradle + +import org.codehaus.groovy.runtime.ProcessGroovyMethods +import java.io.BufferedReader +import java.io.IOException +import java.io.InputStreamReader + +internal object ProcessHelpers { + fun startProcess(vararg command: String): Process { + // Something randomly passes in "GIT_DIR=" as an environment variable which clobbers everything else. Don't + // inherit the environment array! + return Runtime.getRuntime().exec(command, arrayOfNulls(0)) + } + + fun captureOut(vararg command: String): String { + val process = startProcess(*command) + val result = ProcessGroovyMethods.getText(process) + if (process.waitFor() != 0) throw IOException("Command exited with a non-0 status") + return result + } + + fun captureLines(vararg command: String): List { + return captureLines(startProcess(*command)) + } + + fun captureLines(process: Process): List { + val out = BufferedReader(InputStreamReader(process.inputStream)).use { reader -> + reader.lines().filter { it.isNotEmpty() }.toList() + } + ProcessGroovyMethods.closeStreams(process) + if (process.waitFor() != 0) throw IOException("Command exited with a non-0 status") + return out + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 57fe2e8f1..db5953d2c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ jqwik = "1.7.0" junit = "5.9.1" # Build tools +checkstyle = "8.25" # There's a reason we're pinned on an ancient version, but I can't remember what it is. spotless = "6.8.0" [libraries] @@ -26,7 +27,8 @@ junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.re junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit" } junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit" } -# Gradle plugins +# Build tools +checkstyle = { module = "com.puppycrawl.tools:checkstyle", version.ref = "checkstyle" } spotless = { module = "com.diffplug.spotless:spotless-plugin-gradle", version.ref = "spotless" } [bundles] diff --git a/src/main/resources/data/computercraft/lua/rom/help/credits.txt b/src/main/resources/data/computercraft/lua/rom/help/credits.txt index 495def842..e260366c7 100644 --- a/src/main/resources/data/computercraft/lua/rom/help/credits.txt +++ b/src/main/resources/data/computercraft/lua/rom/help/credits.txt @@ -12,4 +12,4 @@ Follow @DanTwoHundred on Twitter! To help contribute to ComputerCraft, browse the source code at https://github.com/dan200/ComputerCraft. GitHub Contributors: -${gitcontributors} +${gitContributors}