All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.pinterest.ktlint.ruleset.standard.rules.FilenameRule.kt Maven / Gradle / Ivy

There is a newer version: 1.5.0
Show newest version
package com.pinterest.ktlint.ruleset.standard.rules

import com.pinterest.ktlint.rule.engine.core.api.AutocorrectDecision
import com.pinterest.ktlint.rule.engine.core.api.ElementType.CLASS
import com.pinterest.ktlint.rule.engine.core.api.ElementType.FUN
import com.pinterest.ktlint.rule.engine.core.api.ElementType.IDENTIFIER
import com.pinterest.ktlint.rule.engine.core.api.ElementType.MODIFIER_LIST
import com.pinterest.ktlint.rule.engine.core.api.ElementType.OBJECT_DECLARATION
import com.pinterest.ktlint.rule.engine.core.api.ElementType.PROPERTY
import com.pinterest.ktlint.rule.engine.core.api.ElementType.TYPEALIAS
import com.pinterest.ktlint.rule.engine.core.api.ElementType.TYPE_REFERENCE
import com.pinterest.ktlint.rule.engine.core.api.RuleId
import com.pinterest.ktlint.rule.engine.core.api.SinceKtlint
import com.pinterest.ktlint.rule.engine.core.api.children
import com.pinterest.ktlint.rule.engine.core.api.isRoot
import com.pinterest.ktlint.ruleset.standard.StandardRule
import com.pinterest.ktlint.ruleset.standard.rules.internal.regExIgnoringDiacriticsAndStrokesOnLetters
import org.jetbrains.kotlin.com.intellij.lang.ASTNode
import org.jetbrains.kotlin.com.intellij.lang.FileASTNode
import org.jetbrains.kotlin.com.intellij.psi.tree.IElementType
import org.jetbrains.kotlin.psi.KtFile

/**
 * [Kotlin lang documentation](https://kotlinlang.org/docs/coding-conventions.html#source-file-names):
 * If a Kotlin file contains a single class (potentially with related top-level declarations), its name should be
 * the same as the name of the class, with the `.kt` extension appended. If a file contains multiple classes,
 * or only top-level declarations, choose a name describing what the file contains, and name the file accordingly.
 * Use upper camel case with an uppercase first letter (also known as Pascal case),
 * for example, `ProcessDeclarations.kt`.
 *
 * According to issue https://youtrack.jetbrains.com/issue/KTIJ-21897/Kotlin-coding-convention-file-naming-for-class,
 * "class" above should be read as any type of class (data class, enum class, sealed class) and interfaces.
 *
 * A strict implementation of guideline above had unwanted consequences:
 *   - If the file contains a single top level private class, it does not make sense to force the name of file to be
 *     identical to that class.
 *   - If the file contains a coherent set of functions and one of those function returns an instance of a public class
 *     which happens to be the only top level class in that file, it might not always be best to force the file to be
 *     named after that class.
 *   - Existing functionality regarding files containing a single top level object/typealias was lost.
 *
 * Exceptions to this rule:
 * - file without `.kt` extension
 * - file with name `package.kt`
 */
@SinceKtlint("0.23", SinceKtlint.Status.STABLE)
public class FilenameRule : StandardRule("filename") {
    override fun beforeVisitChildNodes(
        node: ASTNode,
        emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> AutocorrectDecision,
    ) {
        if (node.isRoot()) {
            node as FileASTNode? ?: error("node is not ${FileASTNode::class} but ${node::class}")

            val filePath = (node.psi as? KtFile)?.virtualFilePath
            if (filePath?.endsWith(".kt") != true || filePath.endsWith("package.kt")) {
                // ignore all non ".kt" files (including ".kts")
                stopTraversalOfAST()
                return
            }

            val fileName =
                filePath
                    .replace('\\', '/') // Ensure compatibility with Windows OS
                    .substringAfterLast("/")
                    .substringBefore(".")

            val topLevelClassDeclarations = node.topLevelDeclarations(CLASS)
            if (topLevelClassDeclarations.size == 1) {
                val topLevelClassDeclaration = topLevelClassDeclarations.first()
                if (node.hasTopLevelDeclarationNotExtending(topLevelClassDeclaration.identifier)) {
                    fileName.shouldMatchPascalCase(emit)
                } else {
                    // If the file only contains one (non-private) top level class and possibly some extension functions of
                    // that class, then its filename should be identical to the class name.
                    fileName.shouldMatchClassName(topLevelClassDeclaration.identifier, emit)
                }
            } else {
                val topLevelDeclarations = node.topLevelDeclarations()
                if (topLevelDeclarations.size == 1) {
                    val topLevelDeclaration = topLevelDeclarations.first()
                    if (topLevelDeclaration.elementType == OBJECT_DECLARATION ||
                        topLevelDeclaration.elementType == TYPEALIAS
                    ) {
                        val pascalCaseIdentifier =
                            topLevelDeclaration
                                .identifier
                                .toPascalCase()
                        fileName.shouldMatchFileName(pascalCaseIdentifier, emit)
                    } else {
                        fileName.shouldMatchPascalCase(emit)
                    }
                } else {
                    fileName.shouldMatchPascalCase(emit)
                }
            }
            stopTraversalOfAST()
        }
    }

    private fun ASTNode.topLevelDeclarations(elementType: IElementType? = null): List =
        children()
            .filter { elementType == null || it.elementType == elementType }
            .filter { it.doesNotHavePrivateModifier() }
            .mapNotNull { it.toTopLevelDeclaration() }
            .distinct()
            .toList()

    private fun ASTNode.doesNotHavePrivateModifier(): Boolean =
        findChildByType(MODIFIER_LIST)
            ?.children()
            ?.none { it.text == "private" }
            ?: true

    private fun ASTNode.hasTopLevelDeclarationNotExtending(className: String) =
        children()
            .filter { it.doesNotHavePrivateModifier() }
            .any { it.isNotClassRelatedTopLevelDeclaration() || it.isFunctionNotExtending(className) }

    private fun ASTNode.isNotClassRelatedTopLevelDeclaration() = elementType in NON_CLASS_RELATED_TOP_LEVEL_DECLARATION_TYPES

    private fun ASTNode.isFunctionNotExtending(className: String) =
        elementType == FUN &&
            findChildByType(TYPE_REFERENCE)?.text?.let { !it.contains(className) } ?: true

    private fun String.shouldMatchClassName(
        className: String,
        emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> AutocorrectDecision,
    ) {
        if (this != className) {
            emit(
                0,
                "File '$this.kt' contains a single class and possibly also extension functions for that class and should be named same " +
                    "after that class '$className.kt'",
                false,
            )
        }
    }

    private fun String.toPascalCase() = replaceFirstChar { it.uppercaseChar() }

    private fun String.shouldMatchFileName(
        filename: String,
        emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> AutocorrectDecision,
    ) {
        if (this != filename) {
            emit(
                0,
                "File '$this.kt' contains a single top level declaration and should be named '$filename.kt'",
                false,
            )
        }
    }

    private fun String.shouldMatchPascalCase(
        emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> AutocorrectDecision,
    ) {
        if (!this.matches(PASCAL_CASE_REGEX)) {
            emit(0, "File name '$this.kt' should conform PascalCase", false)
        }
    }

    private data class TopLevelDeclaration(
        val elementType: IElementType,
        val identifier: String,
    )

    private fun ASTNode.toTopLevelDeclaration(): TopLevelDeclaration? =
        findChildByType(IDENTIFIER)
            ?.text
            ?.removeSurrounding("`")
            ?.let { TopLevelDeclaration(elementType, it) }

    private companion object {
        val PASCAL_CASE_REGEX = "^[A-Z][A-Za-z\\d]*$".regExIgnoringDiacriticsAndStrokesOnLetters()
        val NON_CLASS_RELATED_TOP_LEVEL_DECLARATION_TYPES = listOf(OBJECT_DECLARATION, TYPEALIAS, PROPERTY)
    }
}

public val FILENAME_RULE_ID: RuleId = FilenameRule().ruleId




© 2015 - 2024 Weber Informatics LLC | Privacy Policy