Skip to content

Commit

Permalink
Introduce import layout configuration with predefined layouts for Imp…
Browse files Browse the repository at this point in the history
…ortOrderingRule
  • Loading branch information
romtsn committed May 29, 2020
1 parent 257885e commit a1c99c8
Show file tree
Hide file tree
Showing 13 changed files with 969 additions and 104 deletions.
3 changes: 3 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ insert_final_newline = true
[*.{java,kt,kts,scala,rs,xml,kt.spec,kts.spec}]
indent_size = 4

[*.{kt,kts}]
kotlin_imports_layout=ascii

[{Makefile,*.go}]
indent_style = tab
indent_size = 4
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,17 @@ max_line_length=off
# Note that rules in any ruleset other than the standard ruleset will need to be prefixed
# by the ruleset identifier.
disabled_rules=no-wildcard-imports,experimental:annotation,my-custom-ruleset:my-custom-rule

# Defines the imports layout. There are predefined layouts like "ascii" or "idea", as well as a custom layout.
# The custom layout can be composed by the following symbols:
# "*" - wildcard. There must be at least one entry of a single wildcard to match all other imports. Matches anything after a specified symbol/import as well.
# "|" - blank line. Supports only single blank lines between imports. No blank line is allowed in the beginning or end of the layout.
# "^" - alias import, e.g. "^android.*" will match all android alias imports, "^*" will match all other alias imports.
# import paths - these can be full paths, e.g. "java.util.List" as well as wildcard paths, e.g. "kotlin.*"
# Examples:
kotlin_imports_layout=ascii # alphabetical with capital letters before lower case letters (e.g. Z before a), no blank lines
kotlin_imports_layout=idea # default IntelliJ IDEA style, same as "ascii", but with "java", "javax", "kotlin" and alias imports in the end of the imports list
kotlin_imports_layout=android.*,|,^org.junit.*,kotlin.io.Closeable,|,*,^* # custom imports layout
```

## Installation
Expand Down
Original file line number Diff line number Diff line change
@@ -1,53 +1,171 @@
package com.pinterest.ktlint.ruleset.standard

import com.pinterest.ktlint.core.KtLint
import com.pinterest.ktlint.core.Rule
import com.pinterest.ktlint.core.ast.ElementType
import com.pinterest.ktlint.core.ast.isRoot
import com.pinterest.ktlint.ruleset.standard.ImportOrderingRule.Companion.ASCII_PATTERN
import com.pinterest.ktlint.ruleset.standard.ImportOrderingRule.Companion.IDEA_PATTERN
import com.pinterest.ktlint.ruleset.standard.internal.importordering.ImportSorter
import com.pinterest.ktlint.ruleset.standard.internal.importordering.PatternEntry
import org.jetbrains.kotlin.com.intellij.lang.ASTNode
import org.jetbrains.kotlin.com.intellij.psi.PsiWhiteSpace
import org.jetbrains.kotlin.com.intellij.psi.impl.source.tree.PsiWhiteSpaceImpl
import org.jetbrains.kotlin.psi.KtImportDirective

/**
* Alphabetical with capital letters before lower case letters (e.g. Z before a).
* No blank lines between major groups (android, com, junit, net, org, java, javax).
* Single group regardless of import type.
* Import ordering is configured via EditorConfig's custom property `kotlin_imports_layout`. Supported values:
* * "idea" - default IntelliJ IDEA's order, see [IDEA_PATTERN]
* * "ascii" - alphabetical order as recommended in Android's Kotlin style guide, see [ASCII_PATTERN]
* * custom - defined by the following set of tokens. Tokens can be combined together in a group, groups/tokens must be comma separated:
* * "*" - wildcard symbol, can be used as follows:
* 1. Single, meaning matching any import (<all other imports> in IDEA)
* 2. After an import path, e.g. "java.*" or "kotlin.io.*"
* 3. In conjunction with "^" operator, meaning matching any alias import - "^*" (<all other alias imports> in IDEA)
* * "|" - blank line symbol. Only supported single blank lines between imports. Multiple blank lines will be ignored. Blank lines are not allowed outside of import list.
* * "^" - alias symbol, can be used as follows:
* 1. In front of an import path, meaning matching all alias imports from this path, e.g. "^android.*"
* 2. In conjunction with "*" operator, meaning matching any alias import - "^*" (<all other alias imports> in IDEA)
* * import paths - these can be full paths, e.g. "java.util.List" as well as wildcard paths, e.g. "kotlin.*"
*
* https://developer.android.com/kotlin/style-guide#import_statements
* In case the custom property is not provided, the rule defaults to "idea" style.
*/
class ImportOrderingRule : Rule("import-ordering") {

private lateinit var importsLayout: String
private lateinit var importSorter: ImportSorter

companion object {
/**
* Alphabetical with capital letters before lower case letters (e.g. Z before a).
* No blank lines between major groups (android, com, junit, net, org, java, javax).
* Single group regardless of import type.
*
* https://developer.android.com/kotlin/style-guide#import_statements
*/
private const val ASCII_PATTERN = "*"

/**
* Default IntelliJ IDEA style: Alphabetical with capital letters before lower case letters (e.g. Z before a),
* except such groups as "java", "javax" and "kotlin" that are placed in the end. Within the groups the alphabetical order is preserved.
* Alias imports are placed in a separate group in the end of the list with alphabetical order inside.
* No blank lines between groups.
*
* https://github.com/JetBrains/kotlin/blob/ffdab473e28d0d872136b910eb2e0f4beea2e19c/idea/formatter/src/org/jetbrains/kotlin/idea/core/formatter/KotlinCodeStyleSettings.java#L87-L91
*/
private const val IDEA_PATTERN = "*,java.*,javax.*,kotlin.*,^*"

private const val IDEA_ERROR_MESSAGE = "Imports must be ordered in lexicographic order without any empty lines in-between " +
"with \"java\", \"javax\", \"kotlin\" and aliases in the end"
private const val ASCII_ERROR_MESSAGE = "Imports must be ordered in lexicographic order without any empty lines in-between"
private const val CUSTOM_ERROR_MESSAGE = "Imports must be ordered according to the pattern specified in .editorconfig"

private val errorMessages = mapOf(
IDEA_PATTERN to IDEA_ERROR_MESSAGE,
ASCII_PATTERN to ASCII_ERROR_MESSAGE
)
}

override fun visit(
node: ASTNode,
autoCorrect: Boolean,
emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> Unit
) {
if (node.isRoot()) {
val editorConfig = node.getUserData(KtLint.EDITOR_CONFIG_USER_DATA_KEY)!!
importsLayout = editorConfig.get("kotlin_imports_layout")
?: "idea" // default to idea in case there's no such entry in .editorconfig

importsLayout = when (importsLayout) { // transform predefined styles into patterns
"idea" -> IDEA_PATTERN
"ascii" -> ASCII_PATTERN
else -> importsLayout
}
importSorter = ImportSorter(importsLayout)
return
}

if (node.elementType == ElementType.IMPORT_LIST) {
val children = node.getChildren(null)
if (children.isNotEmpty()) {
val imports = children.filter { it.elementType == ElementType.IMPORT_DIRECTIVE }
val imports = children.filter {
it.elementType == ElementType.IMPORT_DIRECTIVE ||
it.psi is PsiWhiteSpace && it.textLength > 1 // also collect empty lines, that are represented as "\n\n"
}
val hasComments = children.find { it.elementType == ElementType.BLOCK_COMMENT || it.elementType == ElementType.EOL_COMMENT } != null
val sortedImports = imports.sortedBy { it.text }.distinctBy { it.text }
val sortedImports = imports
.asSequence()
.filter { it.psi !is PsiWhiteSpace } // sorter expects KtImportDirective, whitespaces are inserted afterwards
.map { it.psi as KtImportDirective }
.sortedWith(importSorter)
.map { it.node } // transform back to ASTNode in order to operate over its method (addChild)
.distinctBy { it.text.substringBefore(" as") } // distinguish by import path w/o aliases

// insert blank lines wherever needed
// traverse the list using fold to have previous and current element and decide if the blank line is needed in between
// based on the ImportSorter imported patterns and indexes from the comparator
val sortedImportsWithSpaces = mutableListOf<ASTNode>()
sortedImports.fold(null as ASTNode?) { prev, current ->
val index1 = if (prev == null) -1 else importSorter.findImportIndex((prev.psi as KtImportDirective).importPath!!)
val index2 = importSorter.findImportIndex((current.psi as KtImportDirective).importPath!!)

var hasBlankLines = false
for (i in (index1 + 1) until index2) {
if (importSorter.patterns[i] == PatternEntry.BLANK_LINE_ENTRY) {
hasBlankLines = true
break
}
}
if (hasBlankLines) {
sortedImportsWithSpaces += PsiWhiteSpaceImpl("\n\n")
}
sortedImportsWithSpaces += current

return@fold current
}

val canAutoCorrect = !hasComments
if (imports != sortedImports || hasTooMuchWhitespace(children)) {
if (!importsAreEqual(imports, sortedImportsWithSpaces) || (hasTooMuchWhitespace(children) && !isCustomLayout())) {
val additionalMessage = if (!canAutoCorrect) {
" -- no autocorrection due to comments in the import list"
} else {
""
}
emit(node.startOffset, "Imports must be ordered in lexicographic order without any empty lines in-between$additionalMessage", canAutoCorrect)
emit(
node.startOffset,
"${errorMessages.getOrDefault(importsLayout, CUSTOM_ERROR_MESSAGE)}$additionalMessage",
canAutoCorrect
)
if (autoCorrect && canAutoCorrect) {
node.removeRange(node.firstChildNode, node.lastChildNode.treeNext)
sortedImports.forEachIndexed { i, astNode ->
if (i > 0) {
sortedImportsWithSpaces.reduce { current, next ->
node.addChild(current, null)
if (current !is PsiWhiteSpace && next !is PsiWhiteSpace) {
node.addChild(PsiWhiteSpaceImpl("\n"), null)
}
node.addChild(astNode, null)
return@reduce next
}
node.addChild(sortedImportsWithSpaces.last(), null)
}
}
}
}
}

private fun importsAreEqual(actual: List<ASTNode>, expected: List<ASTNode>): Boolean {
if (actual.size != expected.size) return false

val combined = actual.zip(expected)
return combined.all { (first, second) ->
if (first is PsiWhiteSpace && second is PsiWhiteSpace) {
return@all (first as PsiWhiteSpace).text == (second as PsiWhiteSpace).text
}
return@all first == second
}
}

private fun isCustomLayout() = importsLayout != IDEA_PATTERN && importsLayout != ASCII_PATTERN

private fun hasTooMuchWhitespace(nodes: Array<ASTNode>): Boolean {
return nodes.any { it is PsiWhiteSpace && (it as PsiWhiteSpace).text != "\n" }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.pinterest.ktlint.ruleset.standard.internal.importordering

internal const val BLANK_LINE_CHAR = "|"
internal const val WILDCARD_CHAR = "*"
internal const val ALIAS_CHAR = "^" // TODO: replace with a proper char, once implemented on IDEA's side

/**
* Adopted from https://github.com/JetBrains/intellij-community/blob/70fd799e94246f2c0fe924763ed892765c0dff9a/java/java-impl/src/com/intellij/psi/codeStyle/JavaPackageEntryTableAccessor.java#L25
*/
internal fun parseImportsLayout(importsLayout: String): List<PatternEntry> {
val imports = mutableListOf<PatternEntry>()
val importsList = importsLayout.split(",").onEach { it.trim() }

if (importsList.first() == BLANK_LINE_CHAR || importsList.last() == BLANK_LINE_CHAR) {
throw IllegalArgumentException("Blank lines are not supported in the beginning or end of import list")
}

if (WILDCARD_CHAR !in importsList) {
throw IllegalArgumentException("<all other imports> symbol (\"*\") must be present in the custom imports layout")
}

importsList.forEach {
var import = it
if (import == BLANK_LINE_CHAR) {
imports += PatternEntry.BLANK_LINE_ENTRY
} else {
var hasAlias = false
var withSubpackages = false
if (import.startsWith(ALIAS_CHAR)) {
import = import.substring(1).trim()
hasAlias = true
}
if (import.endsWith(WILDCARD_CHAR)) {
withSubpackages = true
}
imports += if (import == WILDCARD_CHAR) {
if (hasAlias) PatternEntry.ALL_OTHER_ALIAS_IMPORTS_ENTRY else PatternEntry.ALL_OTHER_IMPORTS_ENTRY
} else {
PatternEntry(import, withSubpackages, hasAlias)
}
}
}
return imports
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.pinterest.ktlint.ruleset.standard.internal.importordering

import org.jetbrains.kotlin.psi.KtImportDirective
import org.jetbrains.kotlin.resolve.ImportPath

/**
* Sorts the imports according to the order specified in [patterns] + alphabetically.
*
* Adopted from https://github.com/JetBrains/kotlin/blob/a270ee094c4d7b9520e0898a242bb6ce4dfcad7b/idea/src/org/jetbrains/kotlin/idea/util/ImportPathComparator.kt#L15
*/
internal class ImportSorter(importsLayout: String) : Comparator<KtImportDirective> {

val patterns: List<PatternEntry> = parseImportsLayout(importsLayout)

override fun compare(import1: KtImportDirective, import2: KtImportDirective): Int {
val importPath1 = import1.importPath!!
val importPath2 = import2.importPath!!

return compareValuesBy(
importPath1,
importPath2,
{ import -> findImportIndex(import) },
{ import -> import.toString() }
)
}

fun findImportIndex(path: ImportPath): Int {
var bestIndex: Int = -1
var bestEntryMatch: PatternEntry? = null
var allOtherAliasIndex = -1
var allOtherIndex = -1

for ((index, entry) in patterns.withIndex()) {
if (entry == PatternEntry.ALL_OTHER_ALIAS_IMPORTS_ENTRY) {
allOtherAliasIndex = index
}
if (entry == PatternEntry.ALL_OTHER_IMPORTS_ENTRY) {
allOtherIndex = index
}
if (entry.isBetterMatchForPackageThan(bestEntryMatch, path)) {
bestEntryMatch = entry
bestIndex = index
}
}

if (bestIndex == -1 && path.hasAlias() && allOtherAliasIndex == -1 && allOtherIndex != -1) {
// if no layout for alias imports specified, put them among all others
bestIndex = allOtherIndex
}
return bestIndex
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.pinterest.ktlint.ruleset.standard.internal.importordering

import org.jetbrains.kotlin.resolve.ImportPath

/**
* Represents an entry in the imports layout pattern. Contains matching logic for imports.
*
* Adopted from https://github.com/JetBrains/kotlin/blob/ffdab473e28d0d872136b910eb2e0f4beea2e19c/idea/formatter/src/org/jetbrains/kotlin/idea/core/formatter/KotlinPackageEntry.kt#L10
*/
internal class PatternEntry(
packageName: String,
val withSubpackages: Boolean,
val hasAlias: Boolean
) {

private val packageName = packageName.removeSuffix(".*")

private fun matchesPackageName(otherPackageName: String): Boolean {
if (this == ALL_OTHER_IMPORTS_ENTRY || this == ALL_OTHER_ALIAS_IMPORTS_ENTRY) return true
if (this == BLANK_LINE_ENTRY) return false

if (otherPackageName.startsWith(packageName)) {
if (otherPackageName.length == packageName.length) return true
if (withSubpackages) {
if (otherPackageName[packageName.length] == '.') return true
}
}
return false
}

fun isBetterMatchForPackageThan(entry: PatternEntry?, import: ImportPath): Boolean {
if (hasAlias != import.hasAlias() || !matchesPackageName(import.pathStr)) return false
if (entry == null) return true

if (entry.hasAlias != hasAlias) return false
// Any matched package is better than ALL_OTHER_IMPORTS_ENTRY
if (this == ALL_OTHER_IMPORTS_ENTRY) return false
if (entry == ALL_OTHER_IMPORTS_ENTRY) return true

if (entry.withSubpackages != withSubpackages) return !withSubpackages

return entry.packageName.count { it == '.' } < packageName.count { it == '.' }
}

override fun toString(): String = packageName

override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false

other as PatternEntry

if (withSubpackages != other.withSubpackages) return false
if (hasAlias != other.hasAlias) return false
if (packageName != other.packageName) return false

return true
}

override fun hashCode(): Int {
var result = withSubpackages.hashCode()
result = 31 * result + hasAlias.hashCode()
result = 31 * result + packageName.hashCode()
return result
}

companion object {
val BLANK_LINE_ENTRY = PatternEntry(BLANK_LINE_CHAR, withSubpackages = true, hasAlias = false)
val ALL_OTHER_IMPORTS_ENTRY = PatternEntry(WILDCARD_CHAR, withSubpackages = true, hasAlias = false)
val ALL_OTHER_ALIAS_IMPORTS_ENTRY = PatternEntry((ALIAS_CHAR + WILDCARD_CHAR), withSubpackages = true, hasAlias = true)
}
}
Loading

0 comments on commit a1c99c8

Please sign in to comment.