Skip to content

Commit

Permalink
Add module templating to streamline new module creation (#3799)
Browse files Browse the repository at this point in the history
<!--
Note: This checklist is a reminder of our shared engineering
expectations.
The items in Bold are required
If your PR involves UI changes:
1. Upload screenshots or screencasts that illustrate the changes before
/ after
2. Add them under the UI changes section (feel free to add more columns
if needed)
If your PR does not involve UI changes, you can remove the **UI
changes** section

At a minimum, make sure your changes are tested in API 23 and one of the
more recent API levels available.
-->

Task/Issue URL: https://app.asana.com/0/0/1205678561572065/f 

### Description
Adds tooling to allow for creation of new modules which come with the
recommended structure and gradle dependencies. Supported module types:
- Pure Kotlin API modules
- Android-aware API modules
- impl modules

Note: `store` modules have recently became redundant and is therefore
not supported through this tooling.


### Steps to test this PR

#### Sense-check the usage instructions
- [x] Run `./gradlew newModule`, which will only output usage
instructions
- [x] Verify the instructions make sense

#### Add new kotlin API module
- [x] Run `./gradlew newModule -Pfeature=brand-new-feature/api`
- [x] Verify a new directory is created in the project root called
`brand-new-feature`, and inside is `brand-new-feature-api`
- [x] Open the new `build.gradle` and verify it's a pure Kotlin module
type
- [x] Open the app's `build.gradle` and verify it's added the dependency
`implementation project(":brand-new-feature-api")`

#### Add new kotlin impl module
- [x] Run `./gradlew newModule -Pfeature=brand-new-feature/impl`
- [x] Verify `brand-new-feature/brand-new-feature-impl` now exists
- [x] Open the new `build.gradle` and verify it looks as expected, and
has a sensible namespace set
- [x] Open the app's `build.gradle` and verify it's added the dependency
`implementation project(":brand-new-feature-impl")`

#### Switch to Android-API module type
- [x] Delete `brand-new-feature-api` module directory, and remove its
dependency from:
    - [x] app's `build.gradle`
    - [x] `brand-new-feature-impl`'s `build.gradle`
- [x] Run `./gradlew newModule -Pfeature=brand-new-feature/apiandroid`
- [x] Verify `brand-new-feature/brand-new-feature-api` now exists
- [x] Open the new `build.gradle` and verify it's an Android-aware API
module

#### Will not update an existing module
For safety, the tooling will fail if you run it against an existing
module
- [x] Run `./gradlew newModule -Pfeature=settings/api` and verify it
fails due to `settings/settings-api` already existing
  • Loading branch information
CDRussell authored Nov 21, 2023
1 parent 34d0681 commit 61c354e
Show file tree
Hide file tree
Showing 17 changed files with 809 additions and 1 deletion.
3 changes: 2 additions & 1 deletion .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# This is the module where the Android Design System resides
# Any changes to the source files of this module require approval from @malmstein or @nalcalag.
/common/common-ui/ @malmstein @nalcalag
/saved-sites/ @malmstein
/saved-sites/ @malmstein
/example-feature/ @cdrussell
4 changes: 4 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -486,3 +486,7 @@ tasks.register('releaseTestCloud', Exec) {
commandLine 'maestro', 'cloud', '--include-tags', 'releaseTest', "build/outputs/apk/play/release/duckduckgo-${buildVersionName()}-play-release.apk", '../.maestro'
dependsOn 'assemblePlayRelease'
}

task newModule(type: com.duckduckgo.gradle.ModuleCreator) {
feature = project.hasProperty('feature') ? project.getProperty('feature') : null
}
16 changes: 16 additions & 0 deletions buildSrc/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
plugins {
id 'org.jetbrains.kotlin.jvm' version "1.9.20"
}

repositories {
mavenCentral()
}

dependencies {
testImplementation platform('org.junit:junit-bom:5.9.1')
testImplementation 'org.junit.jupiter:junit-jupiter'
}

test {
useJUnitPlatform()
}
110 changes: 110 additions & 0 deletions buildSrc/src/main/java/com/duckduckgo/gradle/BuildGradleModifier.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/*
* Copyright (c) 2023 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.duckduckgo.gradle

import org.gradle.api.GradleException
import java.io.File

class BuildGradleModifier(private val gradleFile: File) {

fun removeDependency(placeholder: String) {
val lines = gradleFile.readText().lines()
val updatedLines = mutableListOf<String>()

for (line in lines) {
if (!line.contains(placeholder)) {
updatedLines.add(line)
}
}

val newFileOutput = updatedLines.joinToString(System.lineSeparator())
gradleFile.writeText(newFileOutput)
}

fun insertDependencies(
featureName: String,
dependencies: List<String>
) {
if(dependencies.isEmpty()) return

val searchString = "dependencies {"
val linesToAdd = dependencies.map { """implementation project(":${featureName}-${it}")""" }

val lines = gradleFile.readText().lines()
val updatedLines = mutableListOf<String>()
var found = false

for (line in lines) {
updatedLines.add(line)

if (line.contains(searchString)) {
linesToAdd.forEach {
if (!lines.contains("\t$it")) {
println("Inserting [$it] into [${gradleFile.parentFile.name}/${gradleFile.name}]")
updatedLines.add("\t$it")
}
}
found = true
}
}

if (!found) {
throw GradleException("Could not insert dependencies into build.gradle because could not locate correct place to insert them.")
}

val newFileOutput = updatedLines.joinToString(System.lineSeparator())
gradleFile.writeText(newFileOutput)
}

fun renameModuleNamespace(
featureName: String,
moduleType: String,
) {
val lines = gradleFile.readText().lines()
val updatedLines = mutableListOf<String>()
var found = false
val placeholder = moduleNamespacePlaceholder(moduleType)
val newNamespace = buildNewNamespace(featureName, moduleType)

for (line in lines) {
if (line.contains(placeholder)) {
updatedLines.add(newNamespace)
found = true
} else {
updatedLines.add(line)
}
}

if (!found) {
throw GradleException("Could not update namespace for [$featureName/$moduleType] because namespace placeholder not found in new module's build.gradle")
}

val newFileOutput = updatedLines.joinToString(System.lineSeparator())
gradleFile.writeText(newFileOutput)
}

private fun buildNewNamespace(
featureName: String,
moduleType: String
): String {
return """ namespace "com.duckduckgo.$featureName.${moduleType}""""
}

private fun moduleNamespacePlaceholder(moduleType: String): String {
return """namespace "com.duckduckgo.examplefeature.${moduleType}""""
}
}
55 changes: 55 additions & 0 deletions buildSrc/src/main/java/com/duckduckgo/gradle/InputExtractor.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* Copyright (c) 2023 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.duckduckgo.gradle

import org.gradle.api.GradleException

class InputExtractor {

fun extractFeatureNameAndTypes(input: String): Pair<String, ModuleType> {
validateFeatureName(input)
val (featureName, moduleType) = input.split("/")
validateModuleType(moduleType)
return Pair(featureName, ModuleType.moduleTypeFromInput(moduleType)).also {
println("""Feature is [$featureName] and module type is [${moduleType}]""")
}
}

private fun validateFeatureName(featureName: String) {
if (featureName.isEmpty()) throw GradleException(ERROR_MESSAGE_EMPTY_NAME)
if (!featureName.matches(ACCEPTABLE_CHARACTERS_REGEX)) throw GradleException(ERROR_MESSAGE_INVALID_CHARS)
if (featureName.count { it == '/' } != 1) throw GradleException(ERROR_MESSAGE_UNEXPECTED_NUMBER_OF_FORWARD_SLASHES)
if (featureName.startsWith("/")) throw GradleException(ERROR_MESSAGE_STARTS_WITH_FORWARD_SLASH)
}

private fun validateModuleType(moduleType: String) {
if (!moduleType.matches(VALID_MODULE_TYPES_REGEX)) {
throw GradleException("Invalid module type [$moduleType]. Must be one of [ ${ModuleType.validInputTypes().joinToString(" | ")} ]")
}
}

companion object {
private val VALID_MODULE_TYPES_REGEX = "^(${ModuleType.validInputTypes().joinToString("|")})$".toRegex()
private val ACCEPTABLE_CHARACTERS_REGEX = "^[a-z0-9-/]*$".toRegex()

private const val ERROR_MESSAGE_EMPTY_NAME = "Feature name cannot be empty"
private const val ERROR_MESSAGE_UNEXPECTED_NUMBER_OF_FORWARD_SLASHES = "Feature name must contain exactly one forward slash"
private const val ERROR_MESSAGE_STARTS_WITH_FORWARD_SLASH = "Feature name cannot start with a forward slash"
private const val ERROR_MESSAGE_INVALID_CHARS =
"Feature name can only contain lowercase letters, numbers, dashes and one forward slash"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* Copyright (c) 2023 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.duckduckgo.gradle

import com.duckduckgo.gradle.ModuleType.ApiPureKotlin
import com.duckduckgo.gradle.ModuleType.Companion.destinationDirectorySuffix
import com.duckduckgo.gradle.ModuleType.Impl
import java.io.File

class IntermoduleDependencyManager {

fun wireUpIntermoduleDependencies(newFeatureDestination: File) {
val apiModule = File(newFeatureDestination, "${newFeatureDestination.name}-${ApiPureKotlin.destinationDirectorySuffix()}")
val implModule = File(newFeatureDestination, "${newFeatureDestination.name}-${Impl.destinationDirectorySuffix()}")

wireUpImplModule(apiModule, implModule, newFeatureDestination)
}

private fun wireUpImplModule(
apiModule: File,
implModule: File,
newFeatureDestination: File
) {
if (implModule.exists()) {
println("Wiring up module: ${implModule.name}")
val gradleModifier = BuildGradleModifier(File(implModule, BUILD_GRADLE))

// delete placeholder
gradleModifier.removeDependency(PLACEHOLDER_API_DEPENDENCY)

// conditionally insert dependencies
val modules = mutableListOf<String>()
if (apiModule.exists()) {
modules.add(ApiPureKotlin.destinationDirectorySuffix())
}
gradleModifier.insertDependencies(newFeatureDestination.name, modules)
}
}

fun wireUpAppModule(
featureName: String,
moduleType: ModuleType,
buildGradleFile: File,
) {
println("Wiring up app module to include feature: name=[$featureName], type=[${moduleType.javaClass.simpleName}]")
val gradleModifier = BuildGradleModifier(buildGradleFile)
gradleModifier.insertDependencies(featureName, listOf(moduleType.destinationDirectorySuffix()))
}

companion object {
private const val PLACEHOLDER_API_DEPENDENCY = "implementation project(':example-feature-api')"
private const val BUILD_GRADLE = "build.gradle"
}
}
Loading

0 comments on commit 61c354e

Please sign in to comment.