Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save KovshefulCoder/34cba5e880641f0a334a059e158f654e to your computer and use it in GitHub Desktop.
Save KovshefulCoder/34cba5e880641f0a334a059e158f654e to your computer and use it in GitHub Desktop.
Custom Spotless step that allow to split Modifier's functions chain calls into lines (like ij_kotlin_method_call_chain_wrap = split_into_lines in .editorconfig)
Features:
- Split single line Modifier functions chain calls into lines (like ij_kotlin_method_call_chain_wrap = split_into_lines in .editorconfig)
- Do not affect single call of function, like 'Modifier.padding(4.dp),'
- Do not affect Modifier extension function declarations
- Add IDE formatting disabling comments around formatted line to disable further collisions
- Move only the functions of Modifier to new lines, keeping the instance on which they were called in place:
'modifier = Modifier.fillMaxWidth().padding(4.dp)' formatted to
'modifier = Modifier
.fillMaxWidth()
.padding(4.dp)'
NOT
'modifier =
Modifier.fillMaxWidth()
.padding(4.dp)'
Known issues:
- Unproperly handles lines, that end with opening parenthesis of Modifier's function call like this -
'modifier = Modifier.fillMaxWidth().padding('. Logs warning about this issue with affected line
spotless_verision = "6.25.0" # Pump to 7+ version after fix https://github.com/diffplug/spotless/issues/2387
[libraries]
gradleplugin-spotless = { module = "com.diffplug.spotless:com.diffplug.spotless.gradle.plugin", version.ref = "spotless_verision" }
[plugins]
spotless = { id = "com.diffplug.spotless", version.ref = "spotless_verision" }
import org.slf4j.LoggerFactory
plugins.applyIfNeeded(libs.plugins.spotless.get().pluginId)
// slf4j - logging facade used by Spotless Gradle plugin
private val log = LoggerFactory.getLogger("spotless")
// Warning
// This plugin depends on version 6.25.0 of the 'com.diffplug.spotless' plugin.
// It is incompatible with version 7.0.0 and above due to configuration cache issue -
// https://github.com/diffplug/spotless/issues/2387
// Known issues - unproperly handles lines, that end with opening parenthesis of Modifier's function call like this -
// modifier = Modifier.fillMaxWidth().padding(
// Logs warning about this issue with affected line
spotlessConfig {
kotlin {
target("**/*.kt")
// Exclude all Kotlin files located anywhere inside a build directory or its subdirectories
targetExclude("**/build/**/*.kt")
custom("ComposeModifierChainSplitIntoLines") { text ->
// Skip the file if it contains no Compose functions or no Modifier usages.
// A Modifier is considered used when one of its functions is called for it,
// for example Modifier.padding()
if (!(text.contains("@Composable") && modifierPatterns.any { text.contains(it) })) {
return@custom text
}
text.lines().joinToString("\n") { line ->
// Find modifier chains in a line that need formatting, then split them into separate lines
val modifierPattern = modifierPatterns.firstOrNull { pattern ->
line.contains(pattern) && isLineContainsModifierChainForFormatting(
line = line,
modifierPattern = pattern.removeSuffix("."),
)
} ?: run {
// Skip the line if it has no modifiers or no modifier chains that need formatting
return@joinToString line
}
// Compute the indentation level for new lines when splitting modifier chains.
val lineLeadingWhitespacesLength = line.takeWhile { it.isWhitespace() }.length
val chainLeadingWhitespacesLength = lineLeadingWhitespacesLength + indentSize
// Use the pattern without a leading dot, since it is only used to detect chain calls
val formatterLine = line
.addIDEFormattingDisable(lineLeadingWhitespacesLength)
.formatModifier(
modifierPattern = modifierPattern.removeSuffix("."),
indent = chainLeadingWhitespacesLength,
isLineHasModifierAssignment = modifierAssignmentRegexMap.values.any { regex ->
regex.containsMatchIn(line)
}
)
if (line.endsWith('(')) {
log.warn(
"Spotless WARNING: Line below may be incorrect:\n$formatterLine\n" +
"Please, verify it manually."
)
}
formatterLine
}
}
}
}
// Indentation size (4 spaces) as specified in .editorconfig
private val indentSize: Int = 4
// Formatter enable/disable tags as specified in .editorconfig
private val editorConfigFormatterOffTagComment = "// @formatter:off"
private val editorConfigFormatterOnTagComment = "// @formatter:on"
private val modifierPatterns = setOf("Modifier.", "modifier.")
private fun indent(count: Int) = " ".repeat(count)
/**
* Adds formatting-disabling comments to the beginning and end of the given ;ine.
*
* This prevents the IDE formatter, based on .editorconfig, from reformatting
* code that has already been modified by this Spotless custom step.
*/
private fun String.addIDEFormattingDisable(indentCount: Int): String {
return indent(count = indentCount) + editorConfigFormatterOffTagComment + "\n" +
this +
"\n" + indent(count = indentCount) + editorConfigFormatterOnTagComment
}
/**
* Formats the given line to split [androidx.compose.ui.Modifier]'s functions chain calls
* into separate lines to improve readability.
*
* @param modifierPattern The pattern to match (e.g. "Modifier" or "modifier").
* @param indent The number of spaces to indent the formatted Modifier methods calls.
* @param isLineHasModifierAssignment Indicates if the line contains an assignment related to Modifier,
* [modifierAssignmentRegexMap]
*
* @return The formatted string.
*/
private fun String.formatModifier(
modifierPattern: String,
indent: Int,
isLineHasModifierAssignment: Boolean = true,
): String {
// If Modifier's assignment is present in line, move chain calls after it to the next line,
// otherwise chain calls starts right after [modifierPattern], so move it to the next line
val line = if (isLineHasModifierAssignment) {
modifierAssignmentRegexMap[modifierPattern]?.replace(this) { match ->
"${match.value}\n${indent(count = indent)}"
} ?: this
} else {
replace(
oldValue = modifierPattern,
newValue = "$modifierPattern\n${indent(count = indent)}",
)
}
// Put each call in a Modifier chain on its own line
return line.replace(
oldValue = ").",
newValue = ")\n${indent(count = indent)}.",
)
}
/**
* Checks if the given [line] contains a valid [androidx.compose.ui.Modifier] chain for formatting.
*
* Line should have at least two chained calls of Modifier' functions in single line (not split by \n)
* and should not contain an extension function declaration.
*
* Using [modifierExtensionRegexMap], check for extension function declaration, may be redundant,
* but left it just for sure.
*
* @param line The line to check.
* @param modifierPattern The pattern to match (e.g. "Modifier" or "modifier").
* */
private fun isLineContainsModifierChainForFormatting(
line: String,
modifierPattern: String,
): Boolean {
return modifierChainRegexMap[modifierPattern]?.containsMatchIn(line) == true &&
modifierExtensionRegexMap[modifierPattern]?.containsMatchIn(line) == false
}
/**
* Map of modifier patterns to their corresponding assignment regexes.
* For example:
* - "Modifier" maps to regex for "modifier = Modifier"
* - "modifier" maps to regex for "modifier = modifier"
*/
private val modifierAssignmentRegexMap = mapOf(
"Modifier" to Regex("""\bmodifier\s*=\s*Modifier\b"""),
"modifier" to Regex("""\bmodifier\s*=\s*modifier\b""")
)
/**
* A map of [modifierPatterns] without dots with regex patterns matching chains of
* [androidx.compose.ui.Modifier] calls, where at least two calls are chained together in single
* line (not split by \n).
*
* Used to identify lines with Modifier' chain calls that should be formatted.
* */
private val modifierChainRegexMap: Map<String, Regex> = modifierPatterns
.map { it.removeSuffix(".") }
.associateWith { pattern ->
Regex("""\b${Regex.escape(pattern)}(?:\.\w+\([^)]*\)){2,}""")
}
/**
* A map of [modifierPatterns] without dots with regex patterns match Modifier extension function
* declarations.
*
* Used to identify lines that should be excluded from modifier formatting.
* */
private val modifierExtensionRegexMap: Map<String, Regex> = modifierPatterns
.map { it.removeSuffix(".") }
.associateWith { pattern ->
Regex("""^\s*fun\s+${Regex.escape(pattern)}\.\w+\(""")
}
import com.diffplug.gradle.spotless.SpotlessExtension
import org.gradle.api.Project
import org.gradle.kotlin.dsl.findByType
internal val Project.spotlessExtension: SpotlessExtension
get() = checkNotNull(extensions.findByType(SpotlessExtension::class))
internal fun Project.spotlessConfig(block: SpotlessExtension.() -> Unit) = block(spotlessExtension)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment