diff --git a/ktlint-rule-engine-core/api/ktlint-rule-engine-core.api b/ktlint-rule-engine-core/api/ktlint-rule-engine-core.api index d22c531279..3b36204949 100644 --- a/ktlint-rule-engine-core/api/ktlint-rule-engine-core.api +++ b/ktlint-rule-engine-core/api/ktlint-rule-engine-core.api @@ -52,6 +52,8 @@ public final class com/pinterest/ktlint/rule/engine/core/api/ASTNodeExtensionKt public static synthetic fun prevLeaf$default (Lorg/jetbrains/kotlin/com/intellij/lang/ASTNode;ZILjava/lang/Object;)Lorg/jetbrains/kotlin/com/intellij/lang/ASTNode; public static final fun prevSibling (Lorg/jetbrains/kotlin/com/intellij/lang/ASTNode;Lkotlin/jvm/functions/Function1;)Lorg/jetbrains/kotlin/com/intellij/lang/ASTNode; public static synthetic fun prevSibling$default (Lorg/jetbrains/kotlin/com/intellij/lang/ASTNode;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lorg/jetbrains/kotlin/com/intellij/lang/ASTNode; + public static final fun remove (Lorg/jetbrains/kotlin/com/intellij/lang/ASTNode;)V + public static final fun replaceWith (Lorg/jetbrains/kotlin/com/intellij/lang/ASTNode;Lorg/jetbrains/kotlin/com/intellij/lang/ASTNode;)V public static final fun upsertWhitespaceAfterMe (Lorg/jetbrains/kotlin/com/intellij/lang/ASTNode;Ljava/lang/String;)V public static final fun upsertWhitespaceBeforeMe (Lorg/jetbrains/kotlin/com/intellij/lang/ASTNode;Ljava/lang/String;)V } diff --git a/ktlint-rule-engine-core/src/main/kotlin/com/pinterest/ktlint/rule/engine/core/api/ASTNodeExtension.kt b/ktlint-rule-engine-core/src/main/kotlin/com/pinterest/ktlint/rule/engine/core/api/ASTNodeExtension.kt index b0c6011b68..4e2667d20c 100644 --- a/ktlint-rule-engine-core/src/main/kotlin/com/pinterest/ktlint/rule/engine/core/api/ASTNodeExtension.kt +++ b/ktlint-rule-engine-core/src/main/kotlin/com/pinterest/ktlint/rule/engine/core/api/ASTNodeExtension.kt @@ -481,3 +481,12 @@ public fun ASTNode.betweenCodeSiblings( afterElementType: IElementType, beforeElementType: IElementType, ): Boolean = afterCodeSibling(afterElementType) && beforeCodeSibling(beforeElementType) + +public fun ASTNode.replaceWith(node: ASTNode) { + treeParent.addChild(node, this) + this.remove() +} + +public fun ASTNode.remove() { + treeParent.removeChild(this) +} diff --git a/ktlint-rule-engine/api/ktlint-rule-engine.api b/ktlint-rule-engine/api/ktlint-rule-engine.api index fd09349548..8cb2f0e5b6 100644 --- a/ktlint-rule-engine/api/ktlint-rule-engine.api +++ b/ktlint-rule-engine/api/ktlint-rule-engine.api @@ -95,6 +95,37 @@ public final class com/pinterest/ktlint/rule/engine/api/KtLintRuleException : ja public final fun getRuleId ()Ljava/lang/String; } +public final class com/pinterest/ktlint/rule/engine/api/KtlintRuleEngineSuppressionKt { + public static final fun insertSuppression (Lcom/pinterest/ktlint/rule/engine/api/KtLintRuleEngine;Lcom/pinterest/ktlint/rule/engine/api/Code;Lcom/pinterest/ktlint/rule/engine/api/KtlintSuppression;)Ljava/lang/String; +} + +public abstract class com/pinterest/ktlint/rule/engine/api/KtlintSuppression { + public synthetic fun (Lcom/pinterest/ktlint/rule/engine/core/api/RuleId;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getRuleId ()Lcom/pinterest/ktlint/rule/engine/core/api/RuleId; +} + +public final class com/pinterest/ktlint/rule/engine/api/KtlintSuppressionAtOffset : com/pinterest/ktlint/rule/engine/api/KtlintSuppression { + public fun (IILcom/pinterest/ktlint/rule/engine/core/api/RuleId;)V + public final fun getCol ()I + public final fun getLine ()I +} + +public abstract class com/pinterest/ktlint/rule/engine/api/KtlintSuppressionException : java/lang/RuntimeException { + public synthetic fun (Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V +} + +public final class com/pinterest/ktlint/rule/engine/api/KtlintSuppressionForFile : com/pinterest/ktlint/rule/engine/api/KtlintSuppression { + public fun (Lcom/pinterest/ktlint/rule/engine/core/api/RuleId;)V +} + +public final class com/pinterest/ktlint/rule/engine/api/KtlintSuppressionNoElementFoundException : com/pinterest/ktlint/rule/engine/api/KtlintSuppressionException { + public fun (Lcom/pinterest/ktlint/rule/engine/api/KtlintSuppressionAtOffset;)V +} + +public final class com/pinterest/ktlint/rule/engine/api/KtlintSuppressionOutOfBoundsException : com/pinterest/ktlint/rule/engine/api/KtlintSuppressionException { + public fun (Lcom/pinterest/ktlint/rule/engine/api/KtlintSuppressionAtOffset;)V +} + public final class com/pinterest/ktlint/rule/engine/api/LintError { public fun (IILcom/pinterest/ktlint/rule/engine/core/api/RuleId;Ljava/lang/String;Z)V public fun equals (Ljava/lang/Object;)Z diff --git a/ktlint-rule-engine/src/main/kotlin/com/pinterest/ktlint/rule/engine/api/KtlintRuleEngineSuppression.kt b/ktlint-rule-engine/src/main/kotlin/com/pinterest/ktlint/rule/engine/api/KtlintRuleEngineSuppression.kt new file mode 100644 index 0000000000..8e4aca01b3 --- /dev/null +++ b/ktlint-rule-engine/src/main/kotlin/com/pinterest/ktlint/rule/engine/api/KtlintRuleEngineSuppression.kt @@ -0,0 +1,94 @@ +package com.pinterest.ktlint.rule.engine.api + +import com.pinterest.ktlint.rule.engine.core.api.RuleId +import com.pinterest.ktlint.rule.engine.internal.RuleExecutionContext +import com.pinterest.ktlint.rule.engine.internal.insertKtlintRuleSuppression +import org.jetbrains.kotlin.com.intellij.lang.ASTNode + +/** + * A [Suppress] annotation can only be inserted at specific locations. This function is intended for API Consumers. It updates given [code] + * by inserting a [Suppress] annotation for the given [suppression]. + * + * Throws [KtlintSuppressionOutOfBoundsException] when the position of the [suppression] can not be found in the [code]. Throws + * [KtlintSuppressionNoElementFoundException] when no element can be found at the given offset. + * + * Returns the code with the inserted/modified suppression. Note that the returned code may not (yet) comply with formatting of all rules. + * This is intentional as adding a suppression for the [suppression] does not mean that other lint errors which can be autocorrected should + * be autocorrected. + */ +public fun KtLintRuleEngine.insertSuppression( + code: Code, + suppression: KtlintSuppression, +): String { + val rootNode = + RuleExecutionContext + .createRuleExecutionContext(this, code) + .rootNode + + rootNode + .findLeafElementAt(suppression) + .insertKtlintRuleSuppression(setOf(suppression.ruleId.value)) + + return rootNode.text +} + +private fun ASTNode.findLeafElementAt(suppression: KtlintSuppression): ASTNode = + when (suppression) { + is KtlintSuppressionForFile -> this + + is KtlintSuppressionAtOffset -> + findLeafElementAt(suppression.offsetFromStartOf(text)) + ?: throw KtlintSuppressionNoElementFoundException(suppression) + } + +private fun KtlintSuppressionAtOffset.offsetFromStartOf(code: String): Int { + if (line < 1 || col < 1) { + throw KtlintSuppressionOutOfBoundsException(this) + } + + val lines = code.split("\n") + + if (line > lines.size) { + throw KtlintSuppressionOutOfBoundsException(this) + } + val startOffsetOfLineContainingLintError = + lines + .take((line - 1).coerceAtLeast(0)) + .sumOf { text -> + // Fix length for newlines which were removed while splitting the original code + text.length + 1 + } + + val codeLine = lines[line - 1] + if (col > codeLine.length) { + throw KtlintSuppressionOutOfBoundsException(this) + } + + return startOffsetOfLineContainingLintError + (col - 1) +} + +public sealed class KtlintSuppressionException( + message: String, +) : RuntimeException(message) + +public class KtlintSuppressionOutOfBoundsException( + offsetSuppression: KtlintSuppressionAtOffset, +) : KtlintSuppressionException("Offset (${offsetSuppression.line},${offsetSuppression.col}) is invalid") + +public class KtlintSuppressionNoElementFoundException( + offsetSuppression: KtlintSuppressionAtOffset, +) : KtlintSuppressionException("No ASTNode found at offset (${offsetSuppression.line},${offsetSuppression.col})") + +public sealed class KtlintSuppression( + public val ruleId: RuleId, +) + +public class KtlintSuppressionForFile( + ruleId: RuleId, +) : KtlintSuppression(ruleId) + +public class KtlintSuppressionAtOffset( + public val line: Int, + public val col: Int, + ruleId: RuleId, +) : KtlintSuppression(ruleId) diff --git a/ktlint-rule-engine/src/main/kotlin/com/pinterest/ktlint/rule/engine/internal/KtlintSuppression.kt b/ktlint-rule-engine/src/main/kotlin/com/pinterest/ktlint/rule/engine/internal/KtlintSuppression.kt new file mode 100644 index 0000000000..7b133543ec --- /dev/null +++ b/ktlint-rule-engine/src/main/kotlin/com/pinterest/ktlint/rule/engine/internal/KtlintSuppression.kt @@ -0,0 +1,429 @@ +package com.pinterest.ktlint.rule.engine.internal + +import com.pinterest.ktlint.rule.engine.core.api.ElementType +import com.pinterest.ktlint.rule.engine.core.api.ElementType.COMMA +import com.pinterest.ktlint.rule.engine.core.api.ElementType.FILE +import com.pinterest.ktlint.rule.engine.core.api.ElementType.TYPE_ARGUMENT_LIST +import com.pinterest.ktlint.rule.engine.core.api.ElementType.TYPE_PARAMETER +import com.pinterest.ktlint.rule.engine.core.api.ElementType.TYPE_PARAMETER_LIST +import com.pinterest.ktlint.rule.engine.core.api.ElementType.TYPE_PROJECTION +import com.pinterest.ktlint.rule.engine.core.api.ElementType.VALUE_ARGUMENT +import com.pinterest.ktlint.rule.engine.core.api.ElementType.VALUE_ARGUMENT_LIST +import com.pinterest.ktlint.rule.engine.core.api.ElementType.VALUE_PARAMETER +import com.pinterest.ktlint.rule.engine.core.api.ElementType.VALUE_PARAMETER_LIST +import com.pinterest.ktlint.rule.engine.core.api.firstChildLeafOrSelf +import com.pinterest.ktlint.rule.engine.core.api.indent +import com.pinterest.ktlint.rule.engine.core.api.isPartOfComment +import com.pinterest.ktlint.rule.engine.core.api.isRoot +import com.pinterest.ktlint.rule.engine.core.api.isWhiteSpace +import com.pinterest.ktlint.rule.engine.core.api.nextCodeSibling +import com.pinterest.ktlint.rule.engine.core.api.replaceWith +import org.jetbrains.kotlin.com.intellij.lang.ASTNode +import org.jetbrains.kotlin.com.intellij.psi.PsiElement +import org.jetbrains.kotlin.com.intellij.psi.PsiFileFactory +import org.jetbrains.kotlin.com.intellij.psi.impl.source.tree.PsiWhiteSpaceImpl +import org.jetbrains.kotlin.com.intellij.psi.tree.TokenSet +import org.jetbrains.kotlin.idea.KotlinLanguage +import org.jetbrains.kotlin.psi.KtAnnotatedExpression +import org.jetbrains.kotlin.psi.KtAnnotationEntry +import org.jetbrains.kotlin.psi.KtBlockExpression +import org.jetbrains.kotlin.psi.KtClass +import org.jetbrains.kotlin.psi.KtClassInitializer +import org.jetbrains.kotlin.psi.KtCollectionLiteralExpression +import org.jetbrains.kotlin.psi.KtDeclaration +import org.jetbrains.kotlin.psi.KtDeclarationModifierList +import org.jetbrains.kotlin.psi.KtExpression +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtFileAnnotationList +import org.jetbrains.kotlin.psi.KtFunction +import org.jetbrains.kotlin.psi.KtFunctionLiteral +import org.jetbrains.kotlin.psi.KtLambdaExpression +import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.KtPrimaryConstructor +import org.jetbrains.kotlin.psi.KtProperty +import org.jetbrains.kotlin.psi.KtPropertyAccessor +import org.jetbrains.kotlin.psi.KtScript +import org.jetbrains.kotlin.psi.KtStringTemplateExpression +import org.jetbrains.kotlin.psi.psiUtil.children +import org.jetbrains.kotlin.psi.psiUtil.findDescendantOfType +import org.jetbrains.kotlin.psi.psiUtil.getChildOfType +import org.jetbrains.kotlin.util.prefixIfNot + +private const val KTLINT_PREFIX = "ktlint" +private const val RULE_ID_SEPARATOR = ":" +private const val STANDARD_RULE_SET_PREFIX = "standard" +private const val EXPERIMENTAL_RULE_SET_PREFIX = "experimental" +internal const val KTLINT_SUPPRESSION_ID_ALL_RULES = "\"$KTLINT_PREFIX\"" +private const val KTLINT_SUPPRESSION_ID_PREFIX = "$KTLINT_PREFIX$RULE_ID_SEPARATOR" +private const val DOUBLE_QUOTE = "\"" + +/** + * Inserts or modifies the [Suppress] annotation on the given [ASTNode]. If no [Suppress] annotation may be created on the [ASTNode] then a + * parent node will be targeted. If no parent node is found, the [Suppress] is added as "@file:Suppress" annotation. If the target node + * already is annotated with [Suppress] then it is expanded with [suppressionIds] which are not yet listed in the annotation. + */ +internal fun ASTNode.insertKtlintRuleSuppression( + suppressionIds: Set, + forceFileAnnotation: Boolean = false, +) { + if (suppressionIds.isEmpty()) { + // Do not add or alter the @Suppress / @SuppressWarnings + return + } + val fullyQualifiedSuppressionIds = suppressionIds.map { it.toFullyQualifiedKtlintSuppressionId() }.toSet() + + val targetASTNode = this.findParentDeclarationOrExpression(forceFileAnnotation) + val suppressionAnnotations = targetASTNode.findSuppressionAnnotations() + // Add ktlint rule suppressions: + // - To the @Suppress annotation if found + // - otherwise to the @SuppressWarnings annotation if found + // - otherwise create a new @Suppress annotation + when { + suppressionAnnotations.containsKey(SuppressAnnotationType.SUPPRESS) -> + fullyQualifiedSuppressionIds.mergeInto( + suppressionAnnotations.getValue(SuppressAnnotationType.SUPPRESS), + SuppressAnnotationType.SUPPRESS, + ) + + suppressionAnnotations.containsKey(SuppressAnnotationType.SUPPRESS_WARNINGS) -> + fullyQualifiedSuppressionIds.mergeInto( + suppressionAnnotations.getValue(SuppressAnnotationType.SUPPRESS_WARNINGS), + SuppressAnnotationType.SUPPRESS_WARNINGS, + ) + + else -> targetASTNode.createSuppressAnnotation(SuppressAnnotationType.SUPPRESS, fullyQualifiedSuppressionIds) + } +} + +/** + * Finds the node on which the [Suppress] may be added. This is either a declaration or an exception with some exceptions. + */ +private fun ASTNode.findParentDeclarationOrExpression(forceFileAnnotation: Boolean): ASTNode { + if (!forceFileAnnotation && isTopLevel()) { + return this + } + + var targetNode = psi + while ( + forceFileAnnotation || + targetNode is KtClassInitializer || + targetNode is KtBlockExpression || + targetNode is KtPrimaryConstructor || + targetNode is KtFunctionLiteral || + targetNode is KtLambdaExpression || + targetNode.parent is KtStringTemplateExpression || + ( + targetNode is KtExpression && + targetNode.parent is KtExpression && + targetNode.parent !is KtBlockExpression && + targetNode.parent !is KtDeclaration + ) || + (targetNode !is KtDeclaration && targetNode !is KtExpression) + ) { + targetNode = + when { + targetNode.parent == null -> { + // Prevents null pointer when already at a root node + return targetNode.node + } + + targetNode.node.elementType in listElementTypeTokenSet -> { + // If a suppression is added to an inner element of a list element, then the annotation should be put on that element + return targetNode.node + } + + targetNode.isIgnorableListElement() -> { + // If a suppression is added on a direct child of the list type but not inside in a list element then the annotation is + // moved to the next list element. When no such element is found, it will be moved to the parent of the list type + targetNode + .node + .nextCodeSibling() + ?.firstChildLeafOrSelf() + ?.psi + } + + else -> { + targetNode + } + }?.parent + } + return targetNode.node +} + +private val listTypeTokenSet = TokenSet.create(TYPE_ARGUMENT_LIST, TYPE_PARAMETER_LIST, VALUE_ARGUMENT_LIST, VALUE_PARAMETER_LIST) +private val listElementTypeTokenSet = TokenSet.create(TYPE_PROJECTION, TYPE_PARAMETER, VALUE_ARGUMENT, VALUE_PARAMETER) + +private fun PsiElement.isIgnorableListElement() = + node + .takeIf { it.treeParent.elementType in listTypeTokenSet } + ?.let { it.elementType == COMMA || it.isWhiteSpace() || it.isPartOfComment() } + ?: false + +/** + * Determines whether a node is top level element + */ +internal fun ASTNode.isTopLevel() = this.elementType == FILE || this.treeParent.elementType == FILE + +private fun Set.mergeInto( + annotationNode: ASTNode, + suppressType: SuppressAnnotationType, +) { + annotationNode + .existingSuppressions() + .plus(this) + .let { suppressions -> + if (suppressions.contains(KTLINT_SUPPRESSION_ID_ALL_RULES)) { + // When all ktlint rules are to be suppressed, then ignore all suppressions for specific ktlint rules + suppressions + .filterNot { it.isKtlintSuppressionId() } + .toSet() + } else { + suppressions + } + }.toSet() + .let { suppressions -> annotationNode.createSuppressAnnotation(suppressType, suppressions) } +} + +private fun ASTNode.existingSuppressions() = + existingSuppressionsFromNamedArgumentOrNull() + ?: getValueArguments() + +private fun ASTNode.existingSuppressionsFromNamedArgumentOrNull() = + psi + .findDescendantOfType() + ?.children + ?.map { it.text } + ?.toSet() + +private fun ASTNode.findSuppressionAnnotations(): Map = + if (this.isRoot()) { + findChildByType(ElementType.FILE_ANNOTATION_LIST) + ?.toMapOfSuppressionAnnotations() + .orEmpty() + } else if (this.elementType == ElementType.ANNOTATED_EXPRESSION) { + this.toMapOfSuppressionAnnotations() + } else { + findChildByType(ElementType.MODIFIER_LIST) + ?.toMapOfSuppressionAnnotations() + .orEmpty() + } + +private fun ASTNode.toMapOfSuppressionAnnotations(): Map = + children() + .mapNotNull { modifier -> + when (modifier.suppressionAnnotationTypeOrNull()) { + SuppressAnnotationType.SUPPRESS -> Pair(SuppressAnnotationType.SUPPRESS, modifier) + SuppressAnnotationType.SUPPRESS_WARNINGS -> Pair(SuppressAnnotationType.SUPPRESS_WARNINGS, modifier) + else -> null + } + }.toMap() + +private fun ASTNode.suppressionAnnotationTypeOrNull() = + takeIf { elementType == ElementType.ANNOTATION || elementType == ElementType.ANNOTATION_ENTRY } + ?.findChildByType(ElementType.CONSTRUCTOR_CALLEE) + ?.findChildByType(ElementType.TYPE_REFERENCE) + ?.findChildByType(ElementType.USER_TYPE) + ?.findChildByType(ElementType.REFERENCE_EXPRESSION) + ?.findChildByType(ElementType.IDENTIFIER) + ?.text + ?.let { SuppressAnnotationType.findByIdOrNull(it) } + +private fun ASTNode.getValueArguments() = + findChildByType(ElementType.VALUE_ARGUMENT_LIST) + ?.children() + ?.filter { it.elementType == ElementType.VALUE_ARGUMENT } + ?.map { it.text } + ?.toSet() + .orEmpty() + +private fun ASTNode.createSuppressAnnotation( + suppressType: SuppressAnnotationType, + suppressions: Set, +) { + val targetNode = + if (elementType == ElementType.ANNOTATION_ENTRY) { + treeParent + } else { + this + } + + when (psi) { + is KtFile -> { + val fileAnnotation = targetNode.createFileAnnotation(suppressType, suppressions) + this.createFileAnnotationList(fileAnnotation) + } + + is KtAnnotationEntry -> { + if (psi.parent is KtFileAnnotationList) { + val fileAnnotation = targetNode.createFileAnnotation(suppressType, suppressions) + this.replaceWith(fileAnnotation.firstChildNode) + } else { + val modifierListWithAnnotation = targetNode.createModifierListWithAnnotationEntry(suppressType, suppressions) + this.replaceWith( + modifierListWithAnnotation + .getChildOfType()!! + .node, + ) + } + } + + is KtClass, is KtFunction, is KtProperty, is KtPropertyAccessor -> { + this.addChild(PsiWhiteSpaceImpl(indent()), this.firstChildNode) + val modifierListWithAnnotation = targetNode.createModifierListWithAnnotationEntry(suppressType, suppressions) + this.addChild(modifierListWithAnnotation.node, this.firstChildNode) + } + + else -> { + if (targetNode.psi is KtExpression && + targetNode.psi !is KtAnnotatedExpression && + this.elementType != VALUE_PARAMETER + ) { + val annotatedExpression = targetNode.createAnnotatedExpression(suppressType, suppressions) + treeParent.replaceChild(targetNode, annotatedExpression.node) + } else { + val modifierListWithAnnotation = targetNode.createModifierListWithAnnotationEntry(suppressType, suppressions) + treeParent.addChild( + modifierListWithAnnotation + .getChildOfType()!! + .node, + this, + ) + treeParent.addChild(PsiWhiteSpaceImpl(indent()), this) + } + } + } +} + +private fun ASTNode.createFileAnnotation( + suppressType: SuppressAnnotationType, + suppressions: Set, +): ASTNode = + suppressions + .sorted() + .joinToString() + .let { sortedSuppressions -> "@file:${suppressType.annotationName}($sortedSuppressions)" } + .let { annotation -> + PsiFileFactory + .getInstance(psi.project) + .createFileFromText(KotlinLanguage.INSTANCE, annotation) + ?.firstChild + ?: throw IllegalStateException("Can not create annotation '$annotation'") + }.node + +private fun ASTNode.createFileAnnotationList(annotation: ASTNode) { + require(isRoot()) { "File annotation list can only be created for root node" } + // Should always be inserted into the first (root) code child regardless in which root node the ktlint directive + // was actually found + findChildByType(ElementType.PACKAGE_DIRECTIVE) + ?.let { packageDirective -> + packageDirective + .treeParent + .addChild(annotation, packageDirective) + packageDirective + .treeParent + .addChild(PsiWhiteSpaceImpl("\n" + indent()), packageDirective) + } +} + +private fun ASTNode.createModifierListWithAnnotationEntry( + suppressType: SuppressAnnotationType, + suppressions: Set, +): PsiElement = + suppressions + .sorted() + .joinToString() + .let { sortedSuppressions -> "@${suppressType.annotationName}($sortedSuppressions)" } + .let { annotation -> + PsiFileFactory + .getInstance(psi.project) + .createFileFromText( + KotlinLanguage.INSTANCE, + // Create the annotation for a dummy declaration as the entire code block should be valid Kotlin code + """ + $annotation + fun foo() {} + """.trimIndent(), + ).getChildOfType() + ?.getChildOfType() + ?.getChildOfType() + ?.getChildOfType() + ?: throw IllegalStateException("Can not create annotation '$annotation'") + } + +private fun ASTNode.createAnnotatedExpression( + suppressType: SuppressAnnotationType, + suppressions: Set, +): PsiElement = + suppressions + .sorted() + .joinToString() + .let { sortedSuppressions -> "@${suppressType.annotationName}($sortedSuppressions)" } + .let { annotation -> + PsiFileFactory + .getInstance(psi.project) + .createFileFromText( + KotlinLanguage.INSTANCE, + // Create the annotation for a dummy declaration as the entire code block should be valid Kotlin code + """ + |fun foo() = + |${this.indent(false)}$annotation + |${this.indent(false)}${this.text} + """.trimMargin(), + ).getChildOfType() + ?.getChildOfType() + ?.getChildOfType() + ?.getChildOfType() + ?: throw IllegalStateException("Can not create annotation '$annotation'") + } + +private enum class SuppressAnnotationType( + val annotationName: String, +) { + SUPPRESS("Suppress"), + SUPPRESS_WARNINGS("SuppressWarnings"), + ; + + companion object { + fun findByIdOrNull(id: String): SuppressAnnotationType? = entries.firstOrNull { it.annotationName == id } + } +} + +internal fun String.isKtlintSuppressionId() = removePrefix(DOUBLE_QUOTE).startsWith(KTLINT_SUPPRESSION_ID_PREFIX) + +internal fun String.toFullyQualifiedKtlintSuppressionId(): String = + when (this) { + KTLINT_SUPPRESSION_ID_ALL_RULES -> this + + KTLINT_PREFIX -> this.surroundWith(DOUBLE_QUOTE) + + else -> { + removeSurrounding(DOUBLE_QUOTE) + .qualifiedRuleIdString() + .let { qualifiedRuleId -> "$KTLINT_PREFIX$RULE_ID_SEPARATOR$qualifiedRuleId" } + .surroundWith(DOUBLE_QUOTE) + } + } + +internal fun String.qualifiedRuleIdString() = + removePrefix(KTLINT_SUPPRESSION_ID_PREFIX) + .let { qualifiedSuppressionIdWithoutKtlintPrefix -> + val ruleSetId = + qualifiedSuppressionIdWithoutKtlintPrefix + .substringBefore(RULE_ID_SEPARATOR, STANDARD_RULE_SET_PREFIX) + .let { + if (it == EXPERIMENTAL_RULE_SET_PREFIX) { + // Historically the experimental rules were located in the experimental ruleset. References to that ruleset + // might still exist and can silently be replaced with the reference to the standard ruleset. + STANDARD_RULE_SET_PREFIX + } else { + it + } + } + val ruleId = qualifiedSuppressionIdWithoutKtlintPrefix.substringAfter(RULE_ID_SEPARATOR) + "$ruleSetId$RULE_ID_SEPARATOR$ruleId" + } + +private fun String.surroundWith(string: String) = + removeSurrounding(string) + .prefixIfNot(string) + .plus(string) diff --git a/ktlint-rule-engine/src/main/kotlin/com/pinterest/ktlint/rule/engine/internal/rules/KtlintSuppressionRule.kt b/ktlint-rule-engine/src/main/kotlin/com/pinterest/ktlint/rule/engine/internal/rules/KtlintSuppressionRule.kt index 6f569c1f04..a159a21e70 100644 --- a/ktlint-rule-engine/src/main/kotlin/com/pinterest/ktlint/rule/engine/internal/rules/KtlintSuppressionRule.kt +++ b/ktlint-rule-engine/src/main/kotlin/com/pinterest/ktlint/rule/engine/internal/rules/KtlintSuppressionRule.kt @@ -1,77 +1,49 @@ package com.pinterest.ktlint.rule.engine.internal.rules import com.pinterest.ktlint.rule.engine.core.api.ElementType -import com.pinterest.ktlint.rule.engine.core.api.ElementType.ANNOTATED_EXPRESSION import com.pinterest.ktlint.rule.engine.core.api.ElementType.ANNOTATION import com.pinterest.ktlint.rule.engine.core.api.ElementType.ANNOTATION_ENTRY import com.pinterest.ktlint.rule.engine.core.api.ElementType.BLOCK_COMMENT -import com.pinterest.ktlint.rule.engine.core.api.ElementType.CLASS import com.pinterest.ktlint.rule.engine.core.api.ElementType.EOL_COMMENT -import com.pinterest.ktlint.rule.engine.core.api.ElementType.FILE -import com.pinterest.ktlint.rule.engine.core.api.ElementType.FILE_ANNOTATION_LIST -import com.pinterest.ktlint.rule.engine.core.api.ElementType.FUN -import com.pinterest.ktlint.rule.engine.core.api.ElementType.MODIFIER_LIST -import com.pinterest.ktlint.rule.engine.core.api.ElementType.PACKAGE_DIRECTIVE -import com.pinterest.ktlint.rule.engine.core.api.ElementType.PROPERTY import com.pinterest.ktlint.rule.engine.core.api.ElementType.STRING_TEMPLATE import com.pinterest.ktlint.rule.engine.core.api.ElementType.VALUE_ARGUMENT -import com.pinterest.ktlint.rule.engine.core.api.ElementType.VALUE_ARGUMENT_LIST import com.pinterest.ktlint.rule.engine.core.api.RuleId -import com.pinterest.ktlint.rule.engine.core.api.indent -import com.pinterest.ktlint.rule.engine.core.api.isRoot import com.pinterest.ktlint.rule.engine.core.api.isWhiteSpace import com.pinterest.ktlint.rule.engine.core.api.isWhiteSpaceWithNewline import com.pinterest.ktlint.rule.engine.core.api.nextLeaf import com.pinterest.ktlint.rule.engine.core.api.nextSibling import com.pinterest.ktlint.rule.engine.core.api.parent import com.pinterest.ktlint.rule.engine.core.api.prevLeaf +import com.pinterest.ktlint.rule.engine.core.api.remove +import com.pinterest.ktlint.rule.engine.core.api.replaceWith +import com.pinterest.ktlint.rule.engine.internal.KTLINT_SUPPRESSION_ID_ALL_RULES +import com.pinterest.ktlint.rule.engine.internal.insertKtlintRuleSuppression +import com.pinterest.ktlint.rule.engine.internal.isKtlintSuppressionId +import com.pinterest.ktlint.rule.engine.internal.isTopLevel +import com.pinterest.ktlint.rule.engine.internal.qualifiedRuleIdString import com.pinterest.ktlint.rule.engine.internal.rules.KtLintDirective.KtlintDirectiveType.KTLINT_DISABLE import com.pinterest.ktlint.rule.engine.internal.rules.KtLintDirective.KtlintDirectiveType.KTLINT_ENABLE import com.pinterest.ktlint.rule.engine.internal.rules.KtLintDirective.SuppressionIdChange.InvalidSuppressionId import com.pinterest.ktlint.rule.engine.internal.rules.KtLintDirective.SuppressionIdChange.ValidSuppressionId -import com.pinterest.ktlint.rule.engine.internal.rules.KtlintSuppressionRule.SuppressAnnotationType.SUPPRESS -import com.pinterest.ktlint.rule.engine.internal.rules.KtlintSuppressionRule.SuppressAnnotationType.SUPPRESS_WARNINGS +import com.pinterest.ktlint.rule.engine.internal.rules.KtLintDirective.SuppressionIdChange.ValidSuppressionId.Companion.KTLINT_SUPPRESSION_ALL +import com.pinterest.ktlint.rule.engine.internal.toFullyQualifiedKtlintSuppressionId import org.jetbrains.kotlin.com.intellij.lang.ASTNode -import org.jetbrains.kotlin.com.intellij.psi.PsiElement import org.jetbrains.kotlin.com.intellij.psi.PsiFileFactory -import org.jetbrains.kotlin.com.intellij.psi.impl.source.tree.PsiWhiteSpaceImpl +import org.jetbrains.kotlin.com.intellij.psi.tree.TokenSet import org.jetbrains.kotlin.idea.KotlinLanguage -import org.jetbrains.kotlin.psi.KtAnnotatedExpression -import org.jetbrains.kotlin.psi.KtAnnotationEntry import org.jetbrains.kotlin.psi.KtBlockExpression import org.jetbrains.kotlin.psi.KtCallExpression -import org.jetbrains.kotlin.psi.KtClass -import org.jetbrains.kotlin.psi.KtClassInitializer -import org.jetbrains.kotlin.psi.KtCollectionLiteralExpression -import org.jetbrains.kotlin.psi.KtDeclaration -import org.jetbrains.kotlin.psi.KtDeclarationModifierList -import org.jetbrains.kotlin.psi.KtExpression -import org.jetbrains.kotlin.psi.KtFile -import org.jetbrains.kotlin.psi.KtFileAnnotationList -import org.jetbrains.kotlin.psi.KtFunction -import org.jetbrains.kotlin.psi.KtFunctionLiteral -import org.jetbrains.kotlin.psi.KtLambdaExpression import org.jetbrains.kotlin.psi.KtLiteralStringTemplateEntry -import org.jetbrains.kotlin.psi.KtNamedFunction -import org.jetbrains.kotlin.psi.KtPrimaryConstructor -import org.jetbrains.kotlin.psi.KtProperty -import org.jetbrains.kotlin.psi.KtPropertyAccessor import org.jetbrains.kotlin.psi.KtScript import org.jetbrains.kotlin.psi.KtScriptInitializer import org.jetbrains.kotlin.psi.KtStringTemplateExpression import org.jetbrains.kotlin.psi.KtValueArgument import org.jetbrains.kotlin.psi.KtValueArgumentList -import org.jetbrains.kotlin.psi.psiUtil.children import org.jetbrains.kotlin.psi.psiUtil.findDescendantOfType import org.jetbrains.kotlin.psi.psiUtil.getChildOfType import org.jetbrains.kotlin.psi.psiUtil.siblings -import org.jetbrains.kotlin.util.prefixIfNot import org.jetbrains.kotlin.utils.addToStdlib.applyIf -private const val KTLINT_SUPPRESSION_ID_PREFIX = "ktlint:" -private const val KTLINT_SUPPRESSION_ID_ALL_RULES = "\"ktlint\"" -private const val DOUBLE_QUOTE = "\"" - /** * Disallow usage of the old "ktlint-disable" and "ktlint-enable" directives. * @@ -121,8 +93,6 @@ public class KtlintSuppressionRule( } ?: false - private fun String.isKtlintSuppressionId() = removePrefix(DOUBLE_QUOTE).startsWith(KTLINT_SUPPRESSION_ID_PREFIX) - private fun ASTNode.isPartOfAnnotation() = parent { it.elementType == ANNOTATION || it.elementType == ANNOTATION_ENTRY } != null private fun visitKtlintSuppressionInAnnotation( @@ -138,8 +108,9 @@ public class KtlintSuppressionRule( val prefixedSuppression = literalStringTemplateEntry .text - .prefixKtlintSuppressionWithRuleSetIdOrNull() - val offset = literalStringTemplateEntry.startOffset + KTLINT_SUPPRESSION_ID_PREFIX.length + .toFullyQualifiedKtlintSuppressionId() + .removeSurrounding("\"") + val offset = literalStringTemplateEntry.startOffset if (prefixedSuppression.isUnknownKtlintSuppression()) { emit(offset, "Ktlint rule with id '$prefixedSuppression' is unknown or not loaded", false) } else if (prefixedSuppression != literalStringTemplateEntry.text) { @@ -153,6 +124,12 @@ public class KtlintSuppressionRule( } } + private fun ASTNode.removePrecedingWhitespace() { + prevLeaf() + .takeIf { it.isWhiteSpace() } + ?.remove() + } + private fun ASTNode.createLiteralStringTemplateEntry(prefixedSuppression: String) = PsiFileFactory .getInstance(psi.project) @@ -167,27 +144,6 @@ public class KtlintSuppressionRule( ?.getChildOfType() ?.node - private fun String.prefixKtlintSuppressionWithRuleSetIdOrNull(): String { - val isPrefixedWithDoubleQuote = startsWith(DOUBLE_QUOTE) - return removePrefix(DOUBLE_QUOTE) - .takeIf { startsWith(KTLINT_SUPPRESSION_ID_PREFIX) } - ?.substringAfter(KTLINT_SUPPRESSION_ID_PREFIX) - ?.let { prefixWithRuleSetIdWhenMissing(it) } - ?.prefixIfNot(KTLINT_SUPPRESSION_ID_PREFIX) - ?.applyIf(isPrefixedWithDoubleQuote) { prefixIfNot(DOUBLE_QUOTE) } - ?: this - } - - private fun String.isUnknownKtlintSuppression(): Boolean = - removePrefix(DOUBLE_QUOTE) - .takeIf { startsWith(KTLINT_SUPPRESSION_ID_PREFIX) } - ?.substringAfter(KTLINT_SUPPRESSION_ID_PREFIX) - ?.let { prefixWithRuleSetIdWhenMissing(it) } - ?.let { ruleId -> - allowedRuleIds.none { it.value == ruleId } - } - ?: false - private fun KtLintDirective.visitKtlintDirective( autoCorrect: Boolean, emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> Unit, @@ -244,8 +200,15 @@ public class KtlintSuppressionRule( ) } if (autoCorrect) { - findParentDeclarationOrExpression() - .addKtlintRuleSuppression(suppressionIdChanges) + val suppressionIds = + suppressionIdChanges + .filterIsInstance() + .map { it.suppressionId } + .toSet() + node + .applyIf(node.elementType == BLOCK_COMMENT && shouldBePromotedToParentDeclaration(ruleIdValidator)) { + treeParent.treeParent + }.insertKtlintRuleSuppression(suppressionIds, forceFileAnnotation = node.shouldBeConvertedToFileAnnotation()) if (node.elementType == EOL_COMMENT) { node.removePrecedingWhitespace() } else { @@ -261,280 +224,6 @@ public class KtlintSuppressionRule( } } - private fun KtLintDirective.findParentDeclarationOrExpression(): ASTNode { - val shouldBeConvertedToFileAnnotation = shouldBeConvertedToFileAnnotation() - var targetNode = - if (node.elementType == BLOCK_COMMENT && - shouldBePromotedToParentDeclaration(ruleIdValidator) - ) { - node.treeParent.treeParent.psi - } else { - node.psi - } - while ( - shouldBeConvertedToFileAnnotation || - targetNode is KtClassInitializer || - targetNode is KtBlockExpression || - targetNode is KtPrimaryConstructor || - targetNode is KtFunctionLiteral || - targetNode is KtLambdaExpression || - (targetNode is KtExpression && targetNode.parent is KtExpression && targetNode.parent !is KtDeclaration) || - (targetNode !is KtDeclaration && targetNode !is KtExpression) - ) { - if (targetNode.parent == null) { - return targetNode.node - } - targetNode = targetNode.parent - } - return targetNode.node - } - - private fun ASTNode.remove() { - treeParent.removeChild(this) - } - - private fun ASTNode.removePrecedingWhitespace() { - prevLeaf() - .takeIf { it.isWhiteSpace() } - ?.remove() - } - - private fun ASTNode.addKtlintRuleSuppression(suppressionIdChanges: Set) { - val ktlintRuleSuppressions = - suppressionIdChanges - .filterIsInstance() - .map { it.suppressionId } - .toSet() - if (ktlintRuleSuppressions.isEmpty()) { - // Do not add or alter the @Suppress / @SuppressWarnings - return - } - val suppressionAnnotations = findSuppressionAnnotations() - // Add ktlint rule suppressions: - // - To the @Suppress annotation if found - // - otherwise to the @SuppressWarnings annotation if found - // - otherwise create a new @Suppress annotation - when { - suppressionAnnotations.containsKey(SUPPRESS) -> - ktlintRuleSuppressions.mergeInto(suppressionAnnotations.getValue(SUPPRESS), SUPPRESS) - - suppressionAnnotations.containsKey(SUPPRESS_WARNINGS) -> - ktlintRuleSuppressions.mergeInto(suppressionAnnotations.getValue(SUPPRESS_WARNINGS), SUPPRESS_WARNINGS) - - else -> createSuppressAnnotation(SUPPRESS, ktlintRuleSuppressions) - } - } - - private fun Set.mergeInto( - annotationNode: ASTNode, - suppressType: SuppressAnnotationType, - ) { - annotationNode - .existingSuppressions() - .plus(this) - .let { suppressions -> - if (suppressions.contains(KTLINT_SUPPRESSION_ID_ALL_RULES)) { - // When all ktlint rules are to be suppressed, then ignore all suppressions for specific ktlint rules - suppressions - .filterNot { it.isKtlintSuppressionId() } - .toSet() - } else { - suppressions - } - }.map { it.prefixKtlintSuppressionWithRuleSetIdOrNull() } - .toSet() - .let { suppressions -> annotationNode.createSuppressAnnotation(suppressType, suppressions) } - } - - private fun ASTNode.existingSuppressions() = - existingSuppressionsFromNamedArgumentOrNull() - ?: getValueArguments() - - private fun ASTNode.existingSuppressionsFromNamedArgumentOrNull() = - psi - .findDescendantOfType() - ?.children - ?.map { it.text } - ?.toSet() - - private fun ASTNode.findSuppressionAnnotations(): Map = - if (this.isRoot()) { - findChildByType(FILE_ANNOTATION_LIST) - ?.toMapOfSuppressionAnnotations() - .orEmpty() - } else if (this.elementType == ANNOTATED_EXPRESSION) { - this.toMapOfSuppressionAnnotations() - } else { - findChildByType(MODIFIER_LIST) - ?.toMapOfSuppressionAnnotations() - .orEmpty() - } - - private fun ASTNode.toMapOfSuppressionAnnotations(): Map = - children() - .mapNotNull { modifier -> - when (modifier.suppressionAnnotationTypeOrNull()) { - SUPPRESS -> Pair(SUPPRESS, modifier) - SUPPRESS_WARNINGS -> Pair(SUPPRESS_WARNINGS, modifier) - else -> null - } - }.toMap() - - private fun ASTNode.suppressionAnnotationTypeOrNull() = - takeIf { elementType == ANNOTATION || elementType == ANNOTATION_ENTRY } - ?.findChildByType(ElementType.CONSTRUCTOR_CALLEE) - ?.findChildByType(ElementType.TYPE_REFERENCE) - ?.findChildByType(ElementType.USER_TYPE) - ?.findChildByType(ElementType.REFERENCE_EXPRESSION) - ?.findChildByType(ElementType.IDENTIFIER) - ?.text - ?.let { SuppressAnnotationType.findByIdOrNull(it) } - - private fun ASTNode.getValueArguments() = - findChildByType(VALUE_ARGUMENT_LIST) - ?.children() - ?.filter { it.elementType == VALUE_ARGUMENT } - ?.map { it.text } - ?.toSet() - .orEmpty() - - private fun ASTNode.createSuppressAnnotation( - suppressType: SuppressAnnotationType, - suppressions: Set, - ) { - val targetNode = - if (elementType == ANNOTATION_ENTRY) { - treeParent - } else { - this - } - - when (psi) { - is KtFile -> { - val fileAnnotation = targetNode.createFileAnnotation(suppressType, suppressions) - this.createFileAnnotationList(fileAnnotation) - } - - is KtAnnotationEntry -> { - if (psi.parent is KtFileAnnotationList) { - val fileAnnotation = targetNode.createFileAnnotation(suppressType, suppressions) - this.replaceWith(fileAnnotation.firstChildNode) - } else { - val modifierListWithAnnotation = targetNode.createModifierListWithAnnotationEntry(suppressType, suppressions) - this.replaceWith( - modifierListWithAnnotation - .getChildOfType()!! - .node, - ) - } - } - - is KtClass, is KtFunction, is KtProperty, is KtPropertyAccessor -> { - this.addChild(PsiWhiteSpaceImpl(indent()), this.firstChildNode) - val modifierListWithAnnotation = targetNode.createModifierListWithAnnotationEntry(suppressType, suppressions) - this.addChild(modifierListWithAnnotation.node, this.firstChildNode) - } - - else -> { - if (targetNode.psi is KtExpression && targetNode.psi !is KtAnnotatedExpression) { - val annotatedExpression = targetNode.createAnnotatedExpression(suppressType, suppressions) - treeParent.replaceChild(targetNode, annotatedExpression.node) - } else { - val modifierListWithAnnotation = targetNode.createModifierListWithAnnotationEntry(suppressType, suppressions) - treeParent.addChild( - modifierListWithAnnotation - .getChildOfType()!! - .node, - this, - ) - treeParent.addChild(PsiWhiteSpaceImpl(indent()), this) - } - } - } - } - - private fun ASTNode.createFileAnnotation( - suppressType: SuppressAnnotationType, - suppressions: Set, - ): ASTNode = - suppressions - .sorted() - .joinToString() - .let { sortedSuppressions -> "@file:${suppressType.annotationName}($sortedSuppressions)" } - .let { annotation -> - PsiFileFactory - .getInstance(psi.project) - .createFileFromText(KotlinLanguage.INSTANCE, annotation) - ?.firstChild - ?: throw IllegalStateException("Can not create annotation '$annotation'") - }.node - - private fun ASTNode.createFileAnnotationList(annotation: ASTNode) { - require(isRoot()) { "File annotation list can only be created for root node" } - // Should always be inserted into the first (root) code child regardless in which root node the ktlint directive - // was actually found - findChildByType(PACKAGE_DIRECTIVE) - ?.let { packageDirective -> - packageDirective - .treeParent - .addChild(annotation, packageDirective) - packageDirective - .treeParent - .addChild(PsiWhiteSpaceImpl("\n" + indent()), packageDirective) - } - } - - private fun ASTNode.createModifierListWithAnnotationEntry( - suppressType: SuppressAnnotationType, - suppressions: Set, - ): PsiElement = - suppressions - .sorted() - .joinToString() - .let { sortedSuppressions -> "@${suppressType.annotationName}($sortedSuppressions)" } - .let { annotation -> - PsiFileFactory - .getInstance(psi.project) - .createFileFromText( - KotlinLanguage.INSTANCE, - // Create the annotation for a dummy declaration as the entire code block should be valid Kotlin code - """ - $annotation - fun foo() {} - """.trimIndent(), - ).getChildOfType() - ?.getChildOfType() - ?.getChildOfType() - ?.getChildOfType() - ?: throw IllegalStateException("Can not create annotation '$annotation'") - } - - private fun ASTNode.createAnnotatedExpression( - suppressType: SuppressAnnotationType, - suppressions: Set, - ): PsiElement = - suppressions - .sorted() - .joinToString() - .let { sortedSuppressions -> "@${suppressType.annotationName}($sortedSuppressions)" } - .let { annotation -> - PsiFileFactory - .getInstance(psi.project) - .createFileFromText( - KotlinLanguage.INSTANCE, - // Create the annotation for a dummy declaration as the entire code block should be valid Kotlin code - """ - |fun foo() = - |${this.indent(false)}$annotation - |${this.indent(false)}${this.text} - """.trimMargin(), - ).getChildOfType() - ?.getChildOfType() - ?.getChildOfType() - ?.getChildOfType() - ?: throw IllegalStateException("Can not create annotation '$annotation'") - } - private fun KtLintDirective.removeKtlintEnableDirective( autoCorrect: Boolean, emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> Unit, @@ -546,29 +235,16 @@ public class KtlintSuppressionRule( } } - private fun ASTNode.replaceWith(node: ASTNode) { - treeParent.addChild(node, this) - this.remove() - } - - private enum class SuppressAnnotationType( - val annotationName: String, - ) { - SUPPRESS("Suppress"), - SUPPRESS_WARNINGS("SuppressWarnings"), - ; - - companion object { - fun findByIdOrNull(id: String): SuppressAnnotationType? = - SuppressAnnotationType - .values() - .firstOrNull { it.annotationName == id } - } - } + private fun String.isUnknownKtlintSuppression(): Boolean = + qualifiedRuleIdString() + .let { ruleId -> + allowedRuleIds.none { it.value == ruleId } + } } -private data class KtLintDirective( +private class KtLintDirective( val node: ASTNode, + val ruleIdValidator: (String) -> Boolean, val ktlintDirectiveType: KtlintDirectiveType, val ktlintDirectives: String, val suppressionIdChanges: Set, @@ -578,7 +254,7 @@ private data class KtLintDirective( fun hasNoMatchingKtlintEnableDirective(ruleIdValidator: (String) -> Boolean): Boolean { require(ktlintDirectiveType == KTLINT_DISABLE && node.elementType == BLOCK_COMMENT) - return if (shouldBeConvertedToFileAnnotation()) { + return if (node.shouldBeConvertedToFileAnnotation()) { false } else { findMatchingKtlintEnableDirective(ruleIdValidator) == null @@ -589,38 +265,22 @@ private data class KtLintDirective( node .applyIf(node.isSuppressibleDeclaration()) { node.treeParent } .siblings() - .firstOrNull { - it + .firstOrNull { node -> + node .ktlintDirectiveOrNull(ruleIdValidator) ?.takeIf { it.ktlintDirectiveType == KTLINT_ENABLE } ?.ktlintDirectives == ktlintDirectives } - fun shouldBeConvertedToFileAnnotation() = - node.isTopLevel() || - (node.elementType == BLOCK_COMMENT && node.isSuppressibleDeclaration() && node.treeParent.isTopLevel()) - - private fun ASTNode.isSuppressibleDeclaration() = - when (treeParent.elementType) { - CLASS, FUN, PROPERTY -> true - else -> false - } - - private fun ASTNode.isTopLevel() = - FILE == - this - .treeParent - .elementType - fun shouldBePromotedToParentDeclaration(ruleIdValidator: (String) -> Boolean): Boolean { require(ktlintDirectiveType == KTLINT_DISABLE && node.elementType == BLOCK_COMMENT) - return if (shouldBeConvertedToFileAnnotation()) { - false - } else { - node - .takeIf { it.isSuppressibleDeclaration() } - ?.let { findMatchingKtlintEnableDirective(ruleIdValidator) } + if (node.shouldBeConvertedToFileAnnotation()) { + return false + } + + if (node.isSuppressibleDeclaration()) { + return findMatchingKtlintEnableDirective(ruleIdValidator) ?.let { matchingKtlintEnabledDirective -> // In case the node is part of a suppressible declaration and the next sibling matches the enable directive then the // block directive should be match with this declaration only and not be moved to the parent. @@ -631,8 +291,41 @@ private data class KtLintDirective( } ?: false } + + return node.surroundsMultipleListElements() + } + + private fun ASTNode.surroundsMultipleListElements(): Boolean { + require(ktlintDirectiveType == KTLINT_DISABLE && elementType == BLOCK_COMMENT) + return if (treeParent.elementType in listTypeTokenSet) { + val firstElementAfterEnableDirective = nextSibling { it.elementType in listElementTypeTokenSet } + findMatchingKtlintEnableDirective(ruleIdValidator) + ?.siblings(false) + ?.takeWhile { it != this } + ?.count { it.elementType in listElementTypeTokenSet } + ?.let { it > 1 } + ?: false + } else { + false + } } + private val listTypeTokenSet = + TokenSet.create( + ElementType.TYPE_ARGUMENT_LIST, + ElementType.TYPE_PARAMETER_LIST, + ElementType.VALUE_ARGUMENT_LIST, + ElementType.VALUE_PARAMETER_LIST, + ) + + private val listElementTypeTokenSet = + TokenSet.create( + ElementType.TYPE_PROJECTION, + ElementType.TYPE_PARAMETER, + VALUE_ARGUMENT, + ElementType.VALUE_PARAMETER, + ) + enum class KtlintDirectiveType( val id: String, ) { @@ -643,7 +336,11 @@ private data class KtLintDirective( sealed class SuppressionIdChange { class ValidSuppressionId( val suppressionId: String, - ) : SuppressionIdChange() + ) : SuppressionIdChange() { + companion object { + val KTLINT_SUPPRESSION_ALL = ValidSuppressionId(KTLINT_SUPPRESSION_ID_ALL_RULES) + } + } class InvalidSuppressionId( val originalRuleId: String, @@ -675,7 +372,7 @@ private fun ASTNode.ktlintDirectiveOrNull(ruleIdValidator: (String) -> Boolean): val ruleIds = ktlintDirectiveString.removePrefix(ktlintDirectiveType.id) val suppressionIdChanges = ruleIds.toSuppressionIdChanges(ruleIdValidator) - return KtLintDirective(this, ktlintDirectiveType, ruleIds, suppressionIdChanges) + return KtLintDirective(this, ruleIdValidator, ktlintDirectiveType, ruleIds, suppressionIdChanges) } private fun String.toKtlintDirectiveTypeOrNull() = @@ -694,14 +391,10 @@ private fun String.toSuppressionIdChanges(ruleIdValidator: (String) -> Boolean) .split(" ") .map { it.trim() } .filter { it.isNotBlank() } + .map { it.qualifiedRuleIdString() } .map { originalRuleId -> - val prefixedRuleId = prefixWithRuleSetIdWhenMissing(originalRuleId) - if (ruleIdValidator(prefixedRuleId)) { - ValidSuppressionId( - prefixedRuleId - .prefixIfNot(KTLINT_SUPPRESSION_ID_PREFIX) - .surroundWith(DOUBLE_QUOTE), - ) + if (ruleIdValidator(originalRuleId)) { + ValidSuppressionId(originalRuleId) } else { InvalidSuppressionId( originalRuleId, @@ -709,19 +402,16 @@ private fun String.toSuppressionIdChanges(ruleIdValidator: (String) -> Boolean) ) } }.toSet() - .ifEmpty { setOf(ValidSuppressionId("\"ktlint\"")) } - -private fun prefixWithRuleSetIdWhenMissing(ruleIdString: String) = - RuleId - .prefixWithStandardRuleSetIdWhenMissing( - // The experimental ruleset was removed in Ktlint 0.49. References to that ruleset however may still exist. Also, not all - // user seem to understand that it is no longer a separate ruleset. - ruleIdString.removePrefix("experimental:"), - ) + .ifEmpty { setOf(KTLINT_SUPPRESSION_ALL) } + +private fun ASTNode.shouldBeConvertedToFileAnnotation() = + isTopLevel() || + (elementType == BLOCK_COMMENT && isSuppressibleDeclaration() && treeParent.isTopLevel()) -private fun String.surroundWith(string: String) = - removeSurrounding(string) - .prefixIfNot(string) - .plus(string) +private fun ASTNode.isSuppressibleDeclaration() = + when (treeParent.elementType) { + ElementType.CLASS, ElementType.FUN, ElementType.PROPERTY -> true + else -> false + } public val KTLINT_SUPPRESSION_RULE_ID: RuleId = KtlintSuppressionRule(emptyList()).ruleId diff --git a/ktlint-rule-engine/src/test/kotlin/com/pinterest/ktlint/rule/engine/api/KtlintRuleEngineSuppressionKtTest.kt b/ktlint-rule-engine/src/test/kotlin/com/pinterest/ktlint/rule/engine/api/KtlintRuleEngineSuppressionKtTest.kt new file mode 100644 index 0000000000..781619ef20 --- /dev/null +++ b/ktlint-rule-engine/src/test/kotlin/com/pinterest/ktlint/rule/engine/api/KtlintRuleEngineSuppressionKtTest.kt @@ -0,0 +1,193 @@ +package com.pinterest.ktlint.rule.engine.api + +import com.pinterest.ktlint.rule.engine.core.api.Rule +import com.pinterest.ktlint.rule.engine.core.api.RuleId +import com.pinterest.ktlint.rule.engine.core.api.RuleProvider +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +class KtlintRuleEngineSuppressionKtTest { + private val ktLintRuleEngine = + KtLintRuleEngine( + ruleProviders = setOf(RuleProvider { SomeRule() }), + ) + + @Test + fun `Given a FileSuppression then add the suppression at file level`() { + val code = + """ + import foo.Foo + """.trimIndent() + val formattedCode = + """ + @file:Suppress("ktlint:standard:some-rule-id") + + import foo.Foo + """.trimIndent() + val actual = + ktLintRuleEngine + .insertSuppression( + Code.fromSnippet(code, false), + KtlintSuppressionForFile(SOME_RULE_ID), + ) + assertThat(actual).isEqualTo(formattedCode) + } + + @Test + fun `Given an OffsetSuppression at an import statement then add the suppression at file level`() { + val code = + """ + import foo.Foo + """.trimIndent() + val formattedCode = + """ + @file:Suppress("ktlint:standard:some-rule-id") + + import foo.Foo + """.trimIndent() + val actual = + ktLintRuleEngine + .insertSuppression( + Code.fromSnippet(code, false), + KtlintSuppressionAtOffset(1, 1, SOME_RULE_ID), + ) + assertThat(actual).isEqualTo(formattedCode) + } + + @Test + fun `Given an OffsetSuppression in a top level declaration before the assignment then add the suppression on the declaration`() { + val code = + """ + val foo = "Foo" + """.trimIndent() + val formattedCode = + """ + @Suppress("ktlint:standard:some-rule-id") + val foo = "Foo" + """.trimIndent() + val actual = + ktLintRuleEngine + .insertSuppression( + Code.fromSnippet(code, false), + KtlintSuppressionAtOffset(1, 1, SOME_RULE_ID), + ) + assertThat(actual).isEqualTo(formattedCode) + } + + @ParameterizedTest(name = "Index: {0}") + @ValueSource( + strings = [ + "11", // Opening quotes + "12", // Character F + "13", // Character o (first occurrence) + "14", // Character o (second occurrence) + "15", // Closing quotes + ], + ) + fun `Given an OffsetSuppression in a string template then add the suppression on top of the string template`(index: Int) { + val code = + """ + val foo = "Foo" + """.trimIndent() + val formattedCode = + """ + val foo = @Suppress("ktlint:standard:some-rule-id") + "Foo" + """.trimIndent() + val actual = + ktLintRuleEngine + .insertSuppression( + Code.fromSnippet(code, false), + KtlintSuppressionAtOffset(1, index, SOME_RULE_ID), + ) + + assertThat(actual).isEqualTo(formattedCode) + } + + @Test + fun `Given an OffsetSuppression inside a string template value argument then add the suppression to the value argument`() { + val code = + """ + fun foo() { + bar("Foo") + } + """.trimIndent() + val formattedCode = + """ + fun foo() { + bar(@Suppress("ktlint:standard:some-rule-id") + "Foo") + } + """.trimIndent() + val actual = + ktLintRuleEngine + .insertSuppression( + Code.fromSnippet(code, false), + KtlintSuppressionAtOffset(2, 10, SOME_RULE_ID), + ) + + assertThat(actual).isEqualTo(formattedCode) + } + + @Test + fun `Given an OffsetSuppression on a value argument then add the suppression to the parent of the value argument list`() { + val code = + """ + fun foo(): String { + bar( + "Foo", + ) + } + """.trimIndent() + val formattedCode = + """ + fun foo(): String { + @Suppress("ktlint:standard:some-rule-id") + bar( + "Foo", + ) + } + """.trimIndent() + val actual = + ktLintRuleEngine + .insertSuppression( + Code.fromSnippet(code, false), + KtlintSuppressionAtOffset(3, 14, SOME_RULE_ID), + ) + + assertThat(actual).isEqualTo(formattedCode) + } + + @Test + fun `Given an OffsetSuppression in a string template which is part of a return statement then add the suppression on top of the return statement`() { + val code = + """ + fun foo(): String { + return "Foo" + } + """.trimIndent() + val formattedCode = + """ + fun foo(): String { + @Suppress("ktlint:standard:some-rule-id") + return "Foo" + } + """.trimIndent() + val actual = + ktLintRuleEngine + .insertSuppression( + Code.fromSnippet(code, false), + KtlintSuppressionAtOffset(2, 13, SOME_RULE_ID), + ) + + assertThat(actual).isEqualTo(formattedCode) + } + + private companion object { + val SOME_RULE_ID = RuleId("standard:some-rule-id") + } + + private class SomeRule : Rule(ruleId = SOME_RULE_ID, about = About()) +} diff --git a/ktlint-rule-engine/src/test/kotlin/com/pinterest/ktlint/rule/engine/internal/KtlintSuppressionKtTest.kt b/ktlint-rule-engine/src/test/kotlin/com/pinterest/ktlint/rule/engine/internal/KtlintSuppressionKtTest.kt new file mode 100644 index 0000000000..85630d72f4 --- /dev/null +++ b/ktlint-rule-engine/src/test/kotlin/com/pinterest/ktlint/rule/engine/internal/KtlintSuppressionKtTest.kt @@ -0,0 +1,719 @@ +package com.pinterest.ktlint.rule.engine.internal + +import com.pinterest.ktlint.rule.engine.api.Code +import com.pinterest.ktlint.rule.engine.api.KtLintRuleEngine +import com.pinterest.ktlint.rule.engine.core.api.Rule +import com.pinterest.ktlint.rule.engine.core.api.RuleId +import com.pinterest.ktlint.rule.engine.core.api.RuleProvider +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +class KtlintSuppressionKtTest { + @Nested + inner class `Given a file suppression to be inserted` { + @Test + fun `Given a suppression to be inserted on a package statement`() { + val code = + """ + package foo.foo_bar + """.trimIndent() + val formattedCode = + """ + @file:Suppress("ktlint:standard:package-name") + + package foo.foo_bar + """.trimIndent() + val actual = + code + .atOffset(1, 1) + .insertKtlintRuleSuppression(setOf("ktlint:standard:package-name")) + + assertThat(actual).isEqualTo(formattedCode) + } + + @Test + fun `Given a suppression to be inserted on an import statement`() { + val code = + """ + import foo.* + """.trimIndent() + val formattedCode = + """ + @file:Suppress("ktlint:standard:no-wildcard-imports") + + import foo.* + """.trimIndent() + val actual = + code + .atOffset(1, 1) + .insertKtlintRuleSuppression(setOf("ktlint:standard:no-wildcard-imports")) + + assertThat(actual).isEqualTo(formattedCode) + } + + @Test + fun `Given code with a file suppression, but no suppression ids, and an import statement then insert a file suppression`() { + val code = + """ + @file:Suppress + + import foo.* + """.trimIndent() + val formattedCode = + """ + @file:Suppress("ktlint:standard:no-wildcard-imports") + + import foo.* + """.trimIndent() + val actual = + code + .atOffset(3, 1) + .insertKtlintRuleSuppression(setOf("ktlint:standard:no-wildcard-imports")) + + assertThat(actual).isEqualTo(formattedCode) + } + + @Test + fun `Given code with a file suppression already containing the suppression id then not add that same suppression id again`() { + val code = + """ + @file:Suppress("ktlint:standard:no-wildcard-imports") + + import foo.* + """.trimIndent() + val actual = + code + .atOffset(3, 1) + .insertKtlintRuleSuppression(setOf("ktlint:standard:no-wildcard-imports")) + + assertThat(actual).isEqualTo(code) + } + + @Test + fun `Given code with a file suppression not containing any suppression id lexicograhically bigger than the new id then add it as last element`() { + val code = + """ + @file:Suppress("aaa") + + import foo.* + """.trimIndent() + val formattedCode = + """ + @file:Suppress("aaa", "ktlint:standard:no-wildcard-imports") + + import foo.* + """.trimIndent() + val actual = + code + .atOffset(3, 1) + .insertKtlintRuleSuppression(setOf("ktlint:standard:no-wildcard-imports")) + + assertThat(actual).isEqualTo(formattedCode) + } + + @Test + fun `Given code with a file suppression not containing any suppression id lexicograhically smaller than the new id then add it as first element`() { + val code = + """ + @file:Suppress("zzz") + + import foo.* + """.trimIndent() + val formattedCode = + """ + @file:Suppress("ktlint:standard:no-wildcard-imports", "zzz") + + import foo.* + """.trimIndent() + val actual = + code + .atOffset(3, 1) + .insertKtlintRuleSuppression(setOf("ktlint:standard:no-wildcard-imports")) + + assertThat(actual).isEqualTo(formattedCode) + } + + @Test + fun `Given code with a file suppression having unsorted ids, and the new suppression id is lexicograhically in between other elements`() { + val code = + """ + @file:Suppress("zzz", "aaa") + + import foo.* + """.trimIndent() + val formattedCode = + """ + @file:Suppress("aaa", "ktlint:standard:no-wildcard-imports", "zzz") + + import foo.* + """.trimIndent() + val actual = + code + .atOffset(3, 1) + .insertKtlintRuleSuppression(setOf("ktlint:standard:no-wildcard-imports")) + + assertThat(actual).isEqualTo(formattedCode) + } + + @Test + fun `Given code with a copyright comment before the package statement, then insert the suppression below the copyright comment`() { + val code = + """ + /* Some copyright notice before package statement */ + package foobar + + import foo.* + """.trimIndent() + val formattedCode = + """ + /* Some copyright notice before package statement */ + @file:Suppress("ktlint:standard:no-wildcard-imports") + + package foobar + + import foo.* + """.trimIndent() + val actual = + code + .atOffset(4, 1) + .insertKtlintRuleSuppression(setOf("ktlint:standard:no-wildcard-imports")) + + assertThat(actual).isEqualTo(formattedCode) + } + + @Nested + inner class `Given that all rules for entire file have to be disabled` { + @Test + fun `Given that no file annotation is defined`() { + val code = + """ + import foo.* + """.trimIndent() + val formattedCode = + """ + @file:Suppress("ktlint") + + import foo.* + """.trimIndent() + val actual = + code + .atOffset(1, 1) + .insertKtlintRuleSuppression(setOf("ktlint"), true) + + assertThat(actual).isEqualTo(formattedCode) + } + + @Test + fun `Given that a file annotation is defined then remove ktlint suppression ids only as they become redundant`() { + val code = + """ + @file:Suppress("ktlint:standard:no-wildcard-imports", "unused") + + import foo.* + """.trimIndent() + val formattedCode = + """ + @file:Suppress("ktlint", "unused") + + import foo.* + """.trimIndent() + val actual = + code + .atOffset(1, 1) + .insertKtlintRuleSuppression(setOf("ktlint"), true) + + assertThat(actual).isEqualTo(formattedCode) + } + } + } + + @Test + fun `Given a top level declaration at which a suppression is to be added`() { + val code = + """ + val foo = "Foo" + """.trimIndent() + val formattedCode = + """ + @Suppress("ktlint:standard:some-rule-id") + val foo = "Foo" + """.trimIndent() + val actual = + code + .atOffset(1, 1) + .insertKtlintRuleSuppression(setOf("ktlint:standard:some-rule-id")) + + assertThat(actual).isEqualTo(formattedCode) + } + + @Nested + inner class `Given an element annotated with @SuppressWarnings` { + @Test + fun `Given a ktlint suppression then add it to the existing SuppressWarnings and sort all suppressions alphabetically`() { + val code = + """ + @SuppressWarnings("zzz", "aaa") + val foo = "foo" + """.trimIndent() + val formattedCode = + """ + @SuppressWarnings("aaa", "ktlint:standard:foo", "zzz") + val foo = "foo" + """.trimIndent() + val actual = + code + .atOffset(2, 1) + .insertKtlintRuleSuppression(setOf("ktlint:standard:foo")) + + assertThat(actual).isEqualTo(formattedCode) + } + + @Test + fun `Given the target element is already annotated with both @Suppress and @SuppressWarnings then add the ktlint suppression to the @Suppress`() { + val code = + """ + @Suppress("aaa", "zzz") + @SuppressWarnings("bbb", "yyy") + val foo = "foo" + """.trimIndent() + val formattedCode = + """ + @Suppress("aaa", "ktlint:standard:foo", "zzz") + @SuppressWarnings("bbb", "yyy") + val foo = "foo" + """.trimIndent() + val actual = + code + .atOffset(3, 1) + .insertKtlintRuleSuppression(setOf("ktlint:standard:foo")) + + assertThat(actual).isEqualTo(formattedCode) + } + } + + @Test + fun `Given an init block comment to which an suppression is being added`() { + val code = + """ + class Foo() { + var foo: String + var bar: String + + init { + foo = "foo" + } + } + """.trimIndent() + val formattedCode = + """ + @Suppress("ktlint:standard:foo") + class Foo() { + var foo: String + var bar: String + + init { + foo = "foo" + } + } + """.trimIndent() + val actual = + code + .atOffset(6, 1) + .insertKtlintRuleSuppression(setOf("ktlint:standard:foo")) + + assertThat(actual).isEqualTo(formattedCode) + } + + @Test + fun `Given a setter on which a suppression is added`() { + val code = + """ + class Foo { + var foo: Int = 1 + set(value) { + field = value + } + } + """.trimIndent() + val formattedCode = + """ + class Foo { + var foo: Int = 1 + @Suppress("ktlint:standard:foo") + set(value) { + field = value + } + } + """.trimIndent() + val actual = + code + .atOffset(4, 1) + .insertKtlintRuleSuppression(setOf("ktlint:standard:foo")) + + assertThat(actual).isEqualTo(formattedCode) + } + + @Test + fun `Given a primary constructor on which a suppression is added`() { + val code = + """ + class Foo constructor(bar: Bar) { + // foo + } + """.trimIndent() + val formattedCode = + """ + @Suppress("ktlint:standard:foo") + class Foo constructor(bar: Bar) { + // foo + } + """.trimIndent() + val actual = + code + .atOffset(2, 8) + .insertKtlintRuleSuppression(setOf("ktlint:standard:foo")) + + assertThat(actual).isEqualTo(formattedCode) + } + + @Nested + inner class `Given a type argument list` { + @Test + fun `Given a type argument on which a suppression is added`() { + val code = + """ + fun FooBar< + in FOO, + in BAR + >.foo(foo: FOO, bar: BAR) {} + """.trimIndent() + val formattedCode = + """ + fun FooBar< + @Suppress("ktlint:standard:foo") + in FOO, + in BAR + >.foo(foo: FOO, bar: BAR) {} + """.trimIndent() + val actual = + code + .atOffset(2, 5) + .insertKtlintRuleSuppression(setOf("ktlint:standard:foo")) + + assertThat(actual).isEqualTo(formattedCode) + } + + @Test + fun `Given a type argument on which a suppression is added on the comma or non-code leaf in the type argument list`() { + val code = + """ + fun FooBar< + in FOO, + in BAR + >.foo(foo: FOO, bar: BAR) {} + """.trimIndent() + val formattedCode = + """ + fun FooBar< + in FOO, + @Suppress("ktlint:standard:foo") + in BAR + >.foo(foo: FOO, bar: BAR) {} + """.trimIndent() + val actual = + code + .atOffset(2, 11) + .also { require(it.charAtOffset() == ',') } + .insertKtlintRuleSuppression(setOf("ktlint:standard:foo")) + + assertThat(actual).isEqualTo(formattedCode) + } + } + + @Nested + inner class `Given a type parameter list` { + @Test + fun `Given a type parameter on which a suppression is added`() { + val code = + """ + fun < + FOO, + BAR, + > foobar(foo: FOO, bar: BAR) = "foo" + """.trimIndent() + val formattedCode = + """ + fun < + @Suppress("ktlint:standard:foo") + FOO, + BAR, + > foobar(foo: FOO, bar: BAR) = "foo" + """.trimIndent() + val actual = + code + .atOffset(2, 5) + .insertKtlintRuleSuppression(setOf("ktlint:standard:foo")) + + assertThat(actual).isEqualTo(formattedCode) + } + + @Test + fun `Given a type parameter on which a suppression is added on the comma or non-code leaf in the type parameter list`() { + val code = + """ + fun < + FOO, + BAR, + > foobar(foo: FOO, bar: BAR) = "foo" + """.trimIndent() + val formattedCode = + """ + fun < + FOO, + @Suppress("ktlint:standard:foo") + BAR, + > foobar(foo: FOO, bar: BAR) = "foo" + """.trimIndent() + val actual = + code + .atOffset(2, 8) + .also { require(it.charAtOffset() == ',') } + .insertKtlintRuleSuppression(setOf("ktlint:standard:foo")) + + assertThat(actual).isEqualTo(formattedCode) + } + } + + @Nested + inner class `Given a value argument list` { + @Test + fun `Given a value argument on which a suppression is added`() { + val code = + """ + val foobar = foobar( + foo = "foo", + bar = "bar", + ) + """.trimIndent() + val formattedCode = + """ + val foobar = foobar( + @Suppress("ktlint:standard:foo") + foo = "foo", + bar = "bar", + ) + """.trimIndent() + val actual = + code + .atOffset(2, 5) + .insertKtlintRuleSuppression(setOf("ktlint:standard:foo")) + + assertThat(actual).isEqualTo(formattedCode) + } + + @Test + fun `Given a value argument on which a suppression is added on the comma or non-code leaf in the value argument list`() { + val code = + """ + val foobar = foobar( + foo = "foo", + bar = "bar", + ) + """.trimIndent() + val formattedCode = + """ + val foobar = foobar( + foo = "foo", + @Suppress("ktlint:standard:foo") + bar = "bar", + ) + """.trimIndent() + val actual = + code + .atOffset(2, 16) + .also { require(it.charAtOffset() == ',') } + .insertKtlintRuleSuppression(setOf("ktlint:standard:foo")) + + assertThat(actual).isEqualTo(formattedCode) + } + } + + @Nested + inner class `Given a value parameter list` { + @Test + fun `Given a class parameter on which a suppression is added`() { + val code = + """ + class Foobar( + val foo: Foo, + ) + """.trimIndent() + val formattedCode = + """ + class Foobar( + @Suppress("ktlint:standard:foo") + val foo: Foo, + ) + """.trimIndent() + val actual = + code + .atOffset(2, 9) + .insertKtlintRuleSuppression(setOf("ktlint:standard:foo")) + + assertThat(actual).isEqualTo(formattedCode) + } + + @Test + fun `Given a value parameter on which a suppression is added on the comma or non-code leaf in the value parameter list`() { + val code = + """ + class Foobar( + val foo: Foo, + val bar: Bar + ) + """.trimIndent() + val formattedCode = + """ + class Foobar( + val foo: Foo, + @Suppress("ktlint:standard:foo") + val bar: Bar + ) + """.trimIndent() + val actual = + code + .atOffset(2, 17) + .also { require(it.charAtOffset() == ',') } + .insertKtlintRuleSuppression(setOf("ktlint:standard:foo")) + + assertThat(actual).isEqualTo(formattedCode) + } + } + + @Test + fun `Given a declaration with a @Suppress annotation using a named argument and a suppression`() { + val code = + """ + @Suppress(names = ["unused"]) + val foo = "foo" + """.trimIndent() + val formattedCode = + """ + @Suppress("ktlint:standard:foo", "unused") + val foo = "foo" + """.trimIndent() + val actual = + code + .atOffset(2, 5) + .insertKtlintRuleSuppression(setOf("ktlint:standard:foo")) + + assertThat(actual).isEqualTo(formattedCode) + } + + @Test + fun `Given a suppression which is added on a property delegate`() { + val code = + """ + val foo by lazy(LazyThreadSafetyMode.PUBLICATION) { + // do something + } + """.trimIndent() + val formattedCode = + """ + val foo by @Suppress("ktlint:standard:foo") + lazy(LazyThreadSafetyMode.PUBLICATION) { + // do something + } + """.trimIndent() + val actual = + code + .atOffset(1, 12) + .insertKtlintRuleSuppression(setOf("ktlint:standard:foo")) + + assertThat(actual).isEqualTo(formattedCode) + } + + @Test + fun `Given a nested expression on which a suppression is added`() { + val code = + """ + val foo = + setOf("a") + .map { + bar(it) + } + """.trimIndent() + val formattedCode = + """ + val foo = + setOf("a") + .map { + @Suppress("ktlint:standard:foo") + bar(it) + } + """.trimIndent() + val actual = + code + .atOffset(4, 13) + .insertKtlintRuleSuppression(setOf("ktlint:standard:foo")) + + assertThat(actual).isEqualTo(formattedCode) + } + + private fun String.atOffset( + line: Int, + col: Int, + ): CodeWithOffset { + require(line >= 1) + require(col >= 1) + + val lines = split("\n") + require(line <= lines.size) + + val startOffsetOfLineContainingLintError = + lines + .take((line - 1).coerceAtLeast(0)) + .sumOf { text -> + // Fix length for newlines which were removed while splitting the original code + text.length + 1 + } + + val codeLine = lines[line - 1] + require(col <= codeLine.length) + + return CodeWithOffset(this, startOffsetOfLineContainingLintError + (col - 1)) + } + + private data class CodeWithOffset( + val code: String, + val offset: Int, + ) { + fun insertKtlintRuleSuppression( + suppressionIds: Set, + forceFileAnnotation: Boolean = false, + ): String = + ktLintRuleEngine + .transformToAst(Code.fromSnippet(code)) + .also { + it + .findLeafElementAt(offset) + ?.insertKtlintRuleSuppression(suppressionIds, forceFileAnnotation) + }.text + + fun charAtOffset(): Char = code[offset] + + private companion object { + class SomeRule : Rule(ruleId = SOME_RULE_ID, about = About()) + + val ktLintRuleEngine = + KtLintRuleEngine( + ruleProviders = setOf(RuleProvider { SomeRule() }), + ) + } + } + + private companion object { + val SOME_RULE_ID = RuleId("standard:some-rule-id") + } +} diff --git a/ktlint-rule-engine/src/test/kotlin/com/pinterest/ktlint/rule/engine/internal/SuppressionLocatorBuilderTest.kt b/ktlint-rule-engine/src/test/kotlin/com/pinterest/ktlint/rule/engine/internal/SuppressionLocatorBuilderTest.kt index 1c1317a40f..e55951e3bf 100644 --- a/ktlint-rule-engine/src/test/kotlin/com/pinterest/ktlint/rule/engine/internal/SuppressionLocatorBuilderTest.kt +++ b/ktlint-rule-engine/src/test/kotlin/com/pinterest/ktlint/rule/engine/internal/SuppressionLocatorBuilderTest.kt @@ -290,7 +290,7 @@ class SuppressionLocatorBuilderTest { val actual = lint(code = code, ignoreKtlintSuppressionRule = false) @Suppress("ktlint:standard:argument-list-wrapping", "ktlint:standard:max-line-length") assertThat(actual).containsExactly( - LintError(1, 24, KTLINT_SUPPRESSION_RULE_ID, "Ktlint rule with id 'ktlint:internal:ktlint-suppression' is unknown or not loaded", false), + LintError(1, 17, KTLINT_SUPPRESSION_RULE_ID, "Ktlint rule with id 'ktlint:internal:ktlint-suppression' is unknown or not loaded", false), ) } diff --git a/ktlint-rule-engine/src/test/kotlin/com/pinterest/ktlint/rule/engine/internal/rules/KtlintSuppressionRuleTest.kt b/ktlint-rule-engine/src/test/kotlin/com/pinterest/ktlint/rule/engine/internal/rules/KtlintSuppressionRuleTest.kt index 1a89fa44ef..1d9b0dd66f 100644 --- a/ktlint-rule-engine/src/test/kotlin/com/pinterest/ktlint/rule/engine/internal/rules/KtlintSuppressionRuleTest.kt +++ b/ktlint-rule-engine/src/test/kotlin/com/pinterest/ktlint/rule/engine/internal/rules/KtlintSuppressionRuleTest.kt @@ -46,7 +46,7 @@ class KtlintSuppressionRuleTest { @file:Suppress("ktlint:standard:bar", "ktlint:standard:foo", "ktlint:custom:foo") """.trimIndent() ktlintSuppressionRuleAssertThat(code) - .hasLintViolation(1, 24, "Identifier to suppress ktlint rule must be fully qualified with the rule set id") + .hasLintViolation(1, 17, "Identifier to suppress ktlint rule must be fully qualified with the rule set id") .isFormattedAs(formattedCode) } @@ -61,7 +61,7 @@ class KtlintSuppressionRuleTest { @file:SuppressWarnings("ktlint:standard:bar", "ktlint:standard:foo", "ktlint:custom:foo") """.trimIndent() ktlintSuppressionRuleAssertThat(code) - .hasLintViolation(1, 32, "Identifier to suppress ktlint rule must be fully qualified with the rule set id") + .hasLintViolation(1, 25, "Identifier to suppress ktlint rule must be fully qualified with the rule set id") .isFormattedAs(formattedCode) } @@ -77,8 +77,8 @@ class KtlintSuppressionRuleTest { """.trimIndent() ktlintSuppressionRuleAssertThat(code) .hasLintViolations( - LintViolation(1, 25, "Identifier to suppress ktlint rule must be fully qualified with the rule set id"), - LintViolation(1, 77, "Identifier to suppress ktlint rule must be fully qualified with the rule set id"), + LintViolation(1, 18, "Identifier to suppress ktlint rule must be fully qualified with the rule set id"), + LintViolation(1, 70, "Identifier to suppress ktlint rule must be fully qualified with the rule set id"), ).isFormattedAs(formattedCode) } @@ -96,8 +96,8 @@ class KtlintSuppressionRuleTest { """.trimIndent() ktlintSuppressionRuleAssertThat(code) .hasLintViolations( - LintViolation(1, 20, "Identifier to suppress ktlint rule must be fully qualified with the rule set id"), - LintViolation(1, 72, "Identifier to suppress ktlint rule must be fully qualified with the rule set id"), + LintViolation(1, 13, "Identifier to suppress ktlint rule must be fully qualified with the rule set id"), + LintViolation(1, 65, "Identifier to suppress ktlint rule must be fully qualified with the rule set id"), ).isFormattedAs(formattedCode) } @@ -114,7 +114,7 @@ class KtlintSuppressionRuleTest { val foo = "foo" """.trimIndent() ktlintSuppressionRuleAssertThat(code) - .hasLintViolation(1, 35, "Identifier to suppress ktlint rule must be fully qualified with the rule set id") + .hasLintViolation(1, 28, "Identifier to suppress ktlint rule must be fully qualified with the rule set id") .isFormattedAs(formattedCode) } @@ -131,7 +131,7 @@ class KtlintSuppressionRuleTest { val foo = "foo" """.trimIndent() ktlintSuppressionRuleAssertThat(code) - .hasLintViolation(1, 28, "Identifier to suppress ktlint rule must be fully qualified with the rule set id") + .hasLintViolation(1, 21, "Identifier to suppress ktlint rule must be fully qualified with the rule set id") .isFormattedAs(formattedCode) } @@ -148,7 +148,7 @@ class KtlintSuppressionRuleTest { val foo = "foo" """.trimIndent() ktlintSuppressionRuleAssertThat(code) - .hasLintViolation(1, 19, "Identifier to suppress ktlint rule must be fully qualified with the rule set id") + .hasLintViolation(1, 12, "Identifier to suppress ktlint rule must be fully qualified with the rule set id") .isFormattedAs(formattedCode) } @@ -165,7 +165,7 @@ class KtlintSuppressionRuleTest { val foo = "foo" """.trimIndent() ktlintSuppressionRuleAssertThat(code) - .hasLintViolation(1, 27, "Identifier to suppress ktlint rule must be fully qualified with the rule set id") + .hasLintViolation(1, 20, "Identifier to suppress ktlint rule must be fully qualified with the rule set id") .isFormattedAs(formattedCode) } } @@ -987,7 +987,7 @@ class KtlintSuppressionRuleTest { @Nested inner class `Given ktlint-disable directive in block comment not having a ktlint-enable directive in a sibling in the same parent node` { @Test - fun `G1iven a ktlint-disable directive root level not related to an declaration or expression then move to @file annotation`() { + fun `Given a ktlint-disable directive root level not related to an declaration or expression then move to @file annotation`() { val code = """ /* ktlint-disable standard:foo */ @@ -1103,8 +1103,8 @@ class KtlintSuppressionRuleTest { """.trimIndent() ktlintSuppressionRuleAssertThat(code) .hasLintViolations( - LintViolation(1, 24, "Ktlint rule with id 'ktlint:standard:SOME-INVALID-RULE-ID-1' is unknown or not loaded", false), - LintViolation(3, 19, "Ktlint rule with id 'ktlint:standard:SOME-INVALID-RULE-ID-2' is unknown or not loaded", false), + LintViolation(1, 17, "Ktlint rule with id 'ktlint:standard:SOME-INVALID-RULE-ID-1' is unknown or not loaded", false), + LintViolation(3, 12, "Ktlint rule with id 'ktlint:standard:SOME-INVALID-RULE-ID-2' is unknown or not loaded", false), LintViolation(5, 8, "Directive 'ktlint-disable' is deprecated. Replace with @Suppress annotation"), LintViolation(5, 23, "Ktlint rule with id 'standard:SOME-INVALID-RULE-ID-3' is unknown or not loaded", false), LintViolation(7, 28, "Directive 'ktlint-disable' is deprecated. Replace with @Suppress annotation"), @@ -1184,7 +1184,7 @@ class KtlintSuppressionRuleTest { } @Test - fun `Given a class parameter with multiple ktlint directives`() { + fun `Given a class with a single parameter wrapped between ktlint disable and ktlint enable directives`() { val code = """ class Foo( @@ -1195,8 +1195,8 @@ class KtlintSuppressionRuleTest { """.trimIndent() val formattedCode = """ - @Suppress("ktlint:standard:bar", "ktlint:standard:foo") class Foo( + @Suppress("ktlint:standard:bar", "ktlint:standard:foo") val bar: Bar ) """.trimIndent() @@ -1207,6 +1207,32 @@ class KtlintSuppressionRuleTest { ).isFormattedAs(formattedCode) } + @Test + fun `Given a class with multiple parameters wrapped between ktlint disable and ktlint enable directives`() { + val code = + """ + class Foo( + /* ktlint-disable standard:bar standard:foo */ + val bar1: Bar, + val bar2: Bar, + /* ktlint-enable standard:bar standard:foo */ + ) + """.trimIndent() + val formattedCode = + """ + @Suppress("ktlint:standard:bar", "ktlint:standard:foo") + class Foo( + val bar1: Bar, + val bar2: Bar, + ) + """.trimIndent() + ktlintSuppressionRuleAssertThat(code) + .hasLintViolations( + LintViolation(2, 8, "Directive 'ktlint-disable' is deprecated. Replace with @Suppress annotation"), + LintViolation(5, 8, "Directive 'ktlint-enable' is obsolete after migrating to suppress annotations"), + ).isFormattedAs(formattedCode) + } + @Test fun `Given a ktlint-disable block directive around a single declaration then place the @Suppress on the declaration`() { val code = @@ -1344,12 +1370,12 @@ class KtlintSuppressionRuleTest { """ // $MAX_LINE_LENGTH_MARKER $EOL_CHAR fun optionalInputTestArguments(): Stream = - @Suppress("ktlint") Stream.of( Arguments.of( "foo", "bar" ), + @Suppress("ktlint") Arguments.of("fooooooooooooooooooooooo","bar"), ) """.trimIndent()