diff --git a/CHANGELOG.md b/CHANGELOG.md index fe5e7158df..43a622997f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## Unreleased ### Added +- New `underscore-numeric-literal` rule ### Fixed diff --git a/ktlint-ruleset-standard/src/main/kotlin/com/pinterest/ktlint/ruleset/standard/StandardRuleSetProvider.kt b/ktlint-ruleset-standard/src/main/kotlin/com/pinterest/ktlint/ruleset/standard/StandardRuleSetProvider.kt index a48229fac9..94e0967fe6 100644 --- a/ktlint-ruleset-standard/src/main/kotlin/com/pinterest/ktlint/ruleset/standard/StandardRuleSetProvider.kt +++ b/ktlint-ruleset-standard/src/main/kotlin/com/pinterest/ktlint/ruleset/standard/StandardRuleSetProvider.kt @@ -36,6 +36,7 @@ public class StandardRuleSetProvider : RuleSetProvider { SpacingAroundOperatorsRule(), SpacingAroundParensRule(), SpacingAroundRangeOperatorRule(), - StringTemplateRule() + StringTemplateRule(), + UnderscoreNumericLiteralRule() ) } diff --git a/ktlint-ruleset-standard/src/main/kotlin/com/pinterest/ktlint/ruleset/standard/UnderscoreNumericLiteralRule.kt b/ktlint-ruleset-standard/src/main/kotlin/com/pinterest/ktlint/ruleset/standard/UnderscoreNumericLiteralRule.kt new file mode 100644 index 0000000000..04e874b82c --- /dev/null +++ b/ktlint-ruleset-standard/src/main/kotlin/com/pinterest/ktlint/ruleset/standard/UnderscoreNumericLiteralRule.kt @@ -0,0 +1,100 @@ +package com.pinterest.ktlint.ruleset.standard + +import com.pinterest.ktlint.core.Rule +import com.pinterest.ktlint.core.ast.ElementType +import org.jetbrains.kotlin.KtNodeTypes.FLOAT_CONSTANT +import org.jetbrains.kotlin.KtNodeTypes.INTEGER_CONSTANT +import org.jetbrains.kotlin.com.intellij.lang.ASTNode +import org.jetbrains.kotlin.com.intellij.psi.impl.source.tree.LeafPsiElement +import org.jetbrains.kotlin.psi.KtConstantExpression +import org.jetbrains.kotlin.psi.KtObjectDeclaration +import org.jetbrains.kotlin.psi.KtPrefixExpression +import org.jetbrains.kotlin.psi.KtProperty +import org.jetbrains.kotlin.psi.psiUtil.containingClassOrObject +import java.util.Locale + +class UnderscoreNumericLiteralRule : Rule("underscore-numeric-literal") { + + private val DELIMITER = "_" + private val acceptableLength = 4 + private val chunkLength = 3 + + override fun visit( + node: ASTNode, + autoCorrect: Boolean, + emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> Unit, + ) { + if (node.elementType != INTEGER_CONSTANT && node.elementType != FLOAT_CONSTANT) { + return + } + + val normalizedText = normalizeForMatching(node.text) + if (isNotDecimalNumber(normalizedText) || KtConstantExpression(node).isSerialUidProperty()) { + return + } + + val numberString = normalizedText.split('.').first() + if (numberString.length < acceptableLength) { + return + } + + if (!numberString.matches(UNDERSCORE_NUMBER_REGEX)) { + emit( + node.startOffset, + "Numeric literals should be delimited with '$DELIMITER'", + true + ) + + if (autoCorrect) { + val decimalPoints: String? = normalizedText.split('.').getOrNull(1) + val typeModifier = node.text.filter { it.isLetter() } + val cleanDigits = numberString.replace(DELIMITER, "").reversed() + + val newDigits = cleanDigits.chunked(chunkLength).joinToString(DELIMITER).reversed() + + if (decimalPoints != null) { + ".$decimalPoints" + } else { + "" + } + + val newNode = LeafPsiElement(ElementType.INTEGER_CONSTANT, newDigits + typeModifier) + node.replaceChild(node.firstChildNode, newNode) + } + } + } + + private fun isNotDecimalNumber(rawText: String): Boolean = + rawText.replace(DELIMITER, "").toDoubleOrNull() == null || rawText.startsWith(HEX_PREFIX) || + rawText.startsWith(BIN_PREFIX) + + private fun KtConstantExpression.isSerialUidProperty(): Boolean { + val propertyElement = if (parent is KtPrefixExpression) parent?.parent else parent + val property = propertyElement as? KtProperty + return property != null && property.name == SERIAL_UID_PROPERTY_NAME && isSerializable(property) + } + + private fun isSerializable(property: KtProperty): Boolean { + var containingClassOrObject = property.containingClassOrObject + if (containingClassOrObject is KtObjectDeclaration && containingClassOrObject.isCompanion()) { + containingClassOrObject = containingClassOrObject.containingClassOrObject + } + return containingClassOrObject + ?.superTypeListEntries + ?.any { it.text == SERIALIZABLE } == true + } + + private fun normalizeForMatching(text: String): String = text.trim() + .lowercase(Locale.ROOT) + .removeSuffix("l") + .removeSuffix("d") + .removeSuffix("f") + .removeSuffix("u") + + companion object { + private val UNDERSCORE_NUMBER_REGEX = Regex("[0-9]{1,3}(_[0-9]{3})*") + private const val HEX_PREFIX = "0x" + private const val BIN_PREFIX = "0b" + private const val SERIALIZABLE = "Serializable" + private const val SERIAL_UID_PROPERTY_NAME = "serialVersionUID" + } +} diff --git a/ktlint-ruleset-standard/src/test/kotlin/com/pinterest/ktlint/ruleset/standard/UnderscoreNumericLiteralRuleTest.kt b/ktlint-ruleset-standard/src/test/kotlin/com/pinterest/ktlint/ruleset/standard/UnderscoreNumericLiteralRuleTest.kt new file mode 100644 index 0000000000..49f07689a2 --- /dev/null +++ b/ktlint-ruleset-standard/src/test/kotlin/com/pinterest/ktlint/ruleset/standard/UnderscoreNumericLiteralRuleTest.kt @@ -0,0 +1,94 @@ +package com.pinterest.ktlint.ruleset.standard + +import com.pinterest.ktlint.core.LintError +import com.pinterest.ktlint.test.format +import com.pinterest.ktlint.test.lint +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class UnderscoreNumericLiteralRuleTest { + + @Test + fun `skip bin and hex values`() { + assertThat( + UnderscoreNumericLiteralRule().lint( + """ + val binValue = 0b11011101 + val hexValue: ULong = 0xFFFFFFFFFFFFu + """.trimIndent() + ) + ).isEmpty() + } + + @Test + fun `every 3rd digit should be underscored`() { + + assertThat(UnderscoreNumericLiteralRule().lint(""" + val numericLiteral = 12345_678 + val numericLiteral2 = 12345_678L + val numericLiteral3 = 12345678 + val numericLiteral4 = 12345678u + val numericLiteral5 = 12345678uL + val numericLiteral7: ULong = 0xFFFFFFFFFFFFu + """.trimIndent() + )).containsExactly( + LintError( + line = 1, + col = 22, + ruleId = "underscore-numeric-literal", + detail = "Numeric literals should be delimited with '_'" + ), + LintError( + line = 2, + col = 23, + ruleId = "underscore-numeric-literal", + detail = "Numeric literals should be delimited with '_'" + ), + LintError( + line = 3, + col = 23, + ruleId = "underscore-numeric-literal", + detail = "Numeric literals should be delimited with '_'" + ), + LintError( + line = 4, + col = 23, + ruleId = "underscore-numeric-literal", + detail = "Numeric literals should be delimited with '_'" + ), + LintError( + line = 5, + col = 23, + ruleId = "underscore-numeric-literal", + detail = "Numeric literals should be delimited with '_'" + ) + ) + } + + @Test + fun `non-hex digits should be autocorrected`() { + assertThat( + UnderscoreNumericLiteralRule().format( + """ + val floatVal = 1000000f + val numericLiteral = 12345_678 + val numericLiteral2 = 12345_678L + val numericLiteral3 = 12345678 + val numericLiteral4 = 12345678u + val numericLiteral5 = 12345678uL + val numericLiteral6 = 12345678.1312313 + """.trimIndent() + ) + ).isEqualTo( + """ + val floatVal = 1_000_000f + val numericLiteral = 12_345_678 + val numericLiteral2 = 12_345_678L + val numericLiteral3 = 12_345_678 + val numericLiteral4 = 12_345_678u + val numericLiteral5 = 12_345_678uL + val numericLiteral6 = 12_345_678.1312313 + """.trimIndent() + ) + } +}