| package com.github.shyiko.ktlint.core |
| |
| import org.jetbrains.kotlin.cli.common.CLIConfigurationKeys |
| import org.jetbrains.kotlin.cli.common.messages.MessageCollector |
| import org.jetbrains.kotlin.cli.jvm.compiler.EnvironmentConfigFiles |
| import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment |
| import org.jetbrains.kotlin.com.intellij.lang.ASTNode |
| import org.jetbrains.kotlin.com.intellij.mock.MockProject |
| import org.jetbrains.kotlin.com.intellij.openapi.Disposable |
| import org.jetbrains.kotlin.com.intellij.openapi.diagnostic.DefaultLogger |
| import org.jetbrains.kotlin.com.intellij.openapi.extensions.ExtensionPoint |
| import org.jetbrains.kotlin.com.intellij.openapi.extensions.Extensions.getArea |
| import org.jetbrains.kotlin.com.intellij.openapi.util.Key |
| import org.jetbrains.kotlin.com.intellij.openapi.util.UserDataHolderBase |
| import org.jetbrains.kotlin.com.intellij.pom.PomModel |
| import org.jetbrains.kotlin.com.intellij.pom.PomModelAspect |
| import org.jetbrains.kotlin.com.intellij.pom.PomTransaction |
| import org.jetbrains.kotlin.com.intellij.pom.impl.PomTransactionBase |
| import org.jetbrains.kotlin.com.intellij.pom.tree.TreeAspect |
| import org.jetbrains.kotlin.com.intellij.psi.PsiComment |
| import org.jetbrains.kotlin.com.intellij.psi.PsiElement |
| import org.jetbrains.kotlin.com.intellij.psi.PsiErrorElement |
| import org.jetbrains.kotlin.com.intellij.psi.PsiFileFactory |
| import org.jetbrains.kotlin.com.intellij.psi.PsiWhiteSpace |
| import org.jetbrains.kotlin.com.intellij.psi.impl.source.tree.TreeCopyHandler |
| import org.jetbrains.kotlin.config.CompilerConfiguration |
| import org.jetbrains.kotlin.idea.KotlinLanguage |
| import org.jetbrains.kotlin.psi.KtFile |
| import org.jetbrains.kotlin.psi.psiUtil.prevLeaf |
| import org.jetbrains.kotlin.psi.psiUtil.startOffset |
| import sun.reflect.ReflectionFactory |
| import java.util.ArrayList |
| import java.util.HashSet |
| import org.jetbrains.kotlin.com.intellij.openapi.diagnostic.Logger as DiagnosticLogger |
| |
| object KtLint { |
| |
| val EDITOR_CONFIG_USER_DATA_KEY = Key<EditorConfig>("EDITOR_CONFIG") |
| val ANDROID_USER_DATA_KEY = Key<Boolean>("ANDROID") |
| val FILE_PATH_USER_DATA_KEY = Key<String>("FILE_PATH") |
| |
| private val psiFileFactory: PsiFileFactory |
| private val nullSuppression = { _: Int, _: String -> false } |
| |
| init { |
| // do not print anything to the stderr when lexer is unable to match input |
| class LoggerFactory : DiagnosticLogger.Factory { |
| override fun getLoggerInstance(p: String): DiagnosticLogger = object : DefaultLogger(null) { |
| override fun warn(message: String?, t: Throwable?) {} |
| override fun error(message: String?, vararg details: String?) {} |
| } |
| } |
| DiagnosticLogger.setFactory(LoggerFactory::class.java) |
| val compilerConfiguration = CompilerConfiguration() |
| compilerConfiguration.put(CLIConfigurationKeys.MESSAGE_COLLECTOR_KEY, MessageCollector.NONE) |
| val project = KotlinCoreEnvironment.createForProduction(Disposable {}, |
| compilerConfiguration, EnvironmentConfigFiles.JVM_CONFIG_FILES).project |
| // everything below (up to PsiFileFactory.getInstance(...)) is to get AST mutations (`ktlint -F ...`) working |
| // otherwise it's not needed |
| val pomModel: PomModel = object : UserDataHolderBase(), PomModel { |
| |
| override fun runTransaction(transaction: PomTransaction) { |
| (transaction as PomTransactionBase).run() |
| } |
| |
| @Suppress("UNCHECKED_CAST") |
| override fun <T : PomModelAspect> getModelAspect(aspect: Class<T>): T? { |
| if (aspect == TreeAspect::class.java) { |
| // using approach described in https://git.io/vKQTo due to the magical bytecode of TreeAspect |
| // (check constructor signature and compare it to the source) |
| // (org.jetbrains.kotlin:kotlin-compiler-embeddable:1.0.3) |
| val constructor = ReflectionFactory.getReflectionFactory().newConstructorForSerialization( |
| aspect, Any::class.java.getDeclaredConstructor(*arrayOfNulls<Class<*>>(0))) |
| return constructor.newInstance(*emptyArray()) as T |
| } |
| return null |
| } |
| } |
| val extensionPoint = "org.jetbrains.kotlin.com.intellij.treeCopyHandler" |
| val extensionClassName = TreeCopyHandler::class.java.name!! |
| for (area in arrayOf(getArea(project), getArea(null))) { |
| if (!area.hasExtensionPoint(extensionPoint)) { |
| area.registerExtensionPoint(extensionPoint, extensionClassName, ExtensionPoint.Kind.INTERFACE) |
| } |
| } |
| project as MockProject |
| project.registerService(PomModel::class.java, pomModel) |
| psiFileFactory = PsiFileFactory.getInstance(project) |
| } |
| |
| /** |
| * Check source for lint errors. |
| * |
| * @param text source |
| * @param ruleSets a collection of "RuleSet"s used to validate source |
| * @param cb callback that is going to be executed for every lint error |
| * |
| * @throws ParseException if text is not a valid Kotlin code |
| * @throws RuleExecutionException in case of internal failure caused by a bug in rule implementation |
| */ |
| fun lint(text: String, ruleSets: Iterable<RuleSet>, cb: (e: LintError) -> Unit) { |
| lint(text, ruleSets, emptyMap(), cb, script = false) |
| } |
| |
| fun lint(text: String, ruleSets: Iterable<RuleSet>, userData: Map<String, String>, cb: (e: LintError) -> Unit) { |
| lint(text, ruleSets, userData, cb, script = false) |
| } |
| |
| /** |
| * Check source for lint errors. |
| * |
| * @param text script source |
| * @param ruleSets a collection of "RuleSet"s used to validate source |
| * @param cb callback that is going to be executed for every lint error |
| * |
| * @throws ParseException if text is not a valid Kotlin code |
| * @throws RuleExecutionException in case of internal failure caused by a bug in rule implementation |
| */ |
| fun lintScript(text: String, ruleSets: Iterable<RuleSet>, cb: (e: LintError) -> Unit) { |
| lint(text, ruleSets, emptyMap(), cb, script = true) |
| } |
| |
| fun lintScript(text: String, ruleSets: Iterable<RuleSet>, userData: Map<String, String>, cb: (e: LintError) -> Unit) { |
| lint(text, ruleSets, userData, cb, script = true) |
| } |
| |
| private fun lint( |
| text: String, |
| ruleSets: Iterable<RuleSet>, |
| userData: Map<String, String>, |
| cb: (e: LintError) -> Unit, |
| script: Boolean |
| ) { |
| val positionByOffset = calculateLineColByOffset(text).let { |
| val offsetDueToLineBreakNormalization = calculateLineBreakOffset(text) |
| return@let { offset: Int -> it(offset + offsetDueToLineBreakNormalization(offset)) } |
| } |
| val normalizedText = text.replace("\r\n", "\n").replace("\r", "\n") |
| val fileName = if (script) "file.kts" else "file.kt" |
| val psiFile = psiFileFactory.createFileFromText(fileName, KotlinLanguage.INSTANCE, normalizedText) as KtFile |
| val errorElement = psiFile.findErrorElement() |
| if (errorElement != null) { |
| val (line, col) = positionByOffset(errorElement.textOffset) |
| throw ParseException(line, col, errorElement.errorDescription) |
| } |
| val rootNode = psiFile.node |
| rootNode.putUserData(EDITOR_CONFIG_USER_DATA_KEY, EditorConfig.fromMap(userData - "android" - "file_path")) |
| rootNode.putUserData(ANDROID_USER_DATA_KEY, userData["android"]?.toBoolean() ?: false) |
| rootNode.putUserData(FILE_PATH_USER_DATA_KEY, userData["file_path"]) |
| val isSuppressed = calculateSuppressedRegions(rootNode) |
| visitor(rootNode, ruleSets).invoke { node, rule, fqRuleId -> |
| // fixme: enforcing suppression based on node.startOffset is wrong |
| // (not just because not all nodes are leaves but because rules are free to emit (and fix!) errors at any position) |
| if (!isSuppressed(node.startOffset, fqRuleId) || node === rootNode) { |
| try { |
| rule.visit(node, false) { offset, errorMessage, _ -> |
| val (line, col) = positionByOffset(offset) |
| cb(LintError(line, col, fqRuleId, errorMessage)) |
| } |
| } catch (e: Exception) { |
| val (line, col) = positionByOffset(node.startOffset) |
| throw RuleExecutionException(line, col, fqRuleId, e) |
| } |
| } |
| } |
| } |
| |
| private fun visitor( |
| rootNode: ASTNode, |
| ruleSets: Iterable<RuleSet>, |
| concurrent: Boolean = true, |
| filter: (fqRuleId: String) -> Boolean = { true } |
| ): ((node: ASTNode, rule: Rule, fqRuleId: String) -> Unit) -> Unit { |
| val fqrsRestrictedToRoot = mutableListOf<Pair<String, Rule>>() |
| val fqrs = mutableListOf<Pair<String, Rule>>() |
| val fqrsExpectedToBeExecutedLastOnRoot = mutableListOf<Pair<String, Rule>>() |
| val fqrsExpectedToBeExecutedLast = mutableListOf<Pair<String, Rule>>() |
| for (ruleSet in ruleSets) { |
| val prefix = if (ruleSet.id === "standard") "" else "${ruleSet.id}:" |
| for (rule in ruleSet) { |
| val fqRuleId = "$prefix${rule.id}" |
| if (!filter(fqRuleId)) { |
| continue |
| } |
| val fqr = fqRuleId to rule |
| when { |
| rule is Rule.Modifier.Last -> fqrsExpectedToBeExecutedLast.add(fqr) |
| rule is Rule.Modifier.RestrictToRootLast -> fqrsExpectedToBeExecutedLastOnRoot.add(fqr) |
| rule is Rule.Modifier.RestrictToRoot -> fqrsRestrictedToRoot.add(fqr) |
| else -> fqrs.add(fqr) |
| } |
| } |
| } |
| return { visit -> |
| for ((fqRuleId, rule) in fqrsRestrictedToRoot) { |
| visit(rootNode, rule, fqRuleId) |
| } |
| if (concurrent) { |
| rootNode.visit { node -> |
| for ((fqRuleId, rule) in fqrs) { |
| visit(node, rule, fqRuleId) |
| } |
| } |
| } else { |
| for ((fqRuleId, rule) in fqrs) { |
| rootNode.visit { node -> |
| visit(node, rule, fqRuleId) |
| } |
| } |
| } |
| for ((fqRuleId, rule) in fqrsExpectedToBeExecutedLastOnRoot) { |
| visit(rootNode, rule, fqRuleId) |
| } |
| if (!fqrsExpectedToBeExecutedLast.isEmpty()) { |
| if (concurrent) { |
| rootNode.visit { node -> |
| for ((fqRuleId, rule) in fqrsExpectedToBeExecutedLast) { |
| visit(node, rule, fqRuleId) |
| } |
| } |
| } else { |
| for ((fqRuleId, rule) in fqrsExpectedToBeExecutedLast) { |
| rootNode.visit { node -> |
| visit(node, rule, fqRuleId) |
| } |
| } |
| } |
| } |
| } |
| } |
| |
| private fun calculateLineColByOffset(text: String): (offset: Int) -> Pair<Int, Int> { |
| var i = -1 |
| val e = text.length |
| val arr = ArrayList<Int>() |
| do { |
| arr.add(i + 1) |
| i = text.indexOf('\n', i + 1) |
| } while (i != -1) |
| arr.add(e + if (arr.last() == e) 1 else 0) |
| val segmentTree = SegmentTree(arr.toTypedArray()) |
| return { offset -> |
| val line = segmentTree.indexOf(offset) |
| if (line != -1) { |
| val col = offset - segmentTree.get(line).left |
| line + 1 to col + 1 |
| } else { |
| 1 to 1 |
| } |
| } |
| } |
| |
| private fun calculateSuppressedRegions(rootNode: ASTNode) = |
| SuppressionHint.collect(rootNode).let { listOfHints -> |
| if (listOfHints.isEmpty()) nullSuppression else { offset, ruleId -> |
| listOfHints.any { (range, disabledRules) -> |
| (disabledRules.isEmpty() || disabledRules.contains(ruleId)) && range.contains(offset) } |
| } |
| } |
| |
| /** |
| * Fix style violations. |
| * |
| * @param text source |
| * @param ruleSets a collection of "RuleSet"s used to validate source |
| * @param cb callback that is going to be executed for every lint error |
| * |
| * @throws ParseException if text is not a valid Kotlin code |
| * @throws RuleExecutionException in case of internal failure caused by a bug in rule implementation |
| */ |
| fun format(text: String, ruleSets: Iterable<RuleSet>, cb: (e: LintError, corrected: Boolean) -> Unit): String = |
| format(text, ruleSets, emptyMap<String, String>(), cb, script = false) |
| |
| fun format( |
| text: String, |
| ruleSets: Iterable<RuleSet>, |
| userData: Map<String, String>, |
| cb: (e: LintError, corrected: Boolean) -> Unit |
| ): String = format(text, ruleSets, userData, cb, script = false) |
| |
| /** |
| * Fix style violations. |
| * |
| * @param text script source |
| * @param ruleSets a collection of "RuleSet"s used to validate source |
| * @param cb callback that is going to be executed for every lint error |
| * |
| * @throws ParseException if text is not a valid Kotlin code |
| * @throws RuleExecutionException in case of internal failure caused by a bug in rule implementation |
| */ |
| fun formatScript(text: String, ruleSets: Iterable<RuleSet>, cb: (e: LintError, corrected: Boolean) -> Unit): String = |
| format(text, ruleSets, emptyMap(), cb, script = true) |
| |
| fun formatScript( |
| text: String, |
| ruleSets: Iterable<RuleSet>, |
| userData: Map<String, String>, |
| cb: (e: LintError, corrected: Boolean) -> Unit |
| ): String = format(text, ruleSets, userData, cb, script = true) |
| |
| private fun format( |
| text: String, |
| ruleSets: Iterable<RuleSet>, |
| userData: Map<String, String>, |
| cb: (e: LintError, corrected: Boolean) -> Unit, |
| script: Boolean |
| ): String { |
| val positionByOffset = calculateLineColByOffset(text).let { |
| val offsetDueToLineBreakNormalization = calculateLineBreakOffset(text) |
| return@let { offset: Int -> it(offset + offsetDueToLineBreakNormalization(offset)) } |
| } |
| val normalizedText = text.replace("\r\n", "\n").replace("\r", "\n") |
| val fileName = if (script) "file.kts" else "file.kt" |
| val psiFile = psiFileFactory.createFileFromText(fileName, KotlinLanguage.INSTANCE, normalizedText) as KtFile |
| val errorElement = psiFile.findErrorElement() |
| if (errorElement != null) { |
| val (line, col) = positionByOffset(errorElement.textOffset) |
| throw ParseException(line, col, errorElement.errorDescription) |
| } |
| val rootNode = psiFile.node |
| rootNode.putUserData(EDITOR_CONFIG_USER_DATA_KEY, EditorConfig.fromMap(userData - "android" - "file_path")) |
| rootNode.putUserData(ANDROID_USER_DATA_KEY, userData["android"]?.toBoolean() ?: false) |
| rootNode.putUserData(FILE_PATH_USER_DATA_KEY, userData["file_path"]) |
| var isSuppressed = calculateSuppressedRegions(rootNode) |
| var tripped = false |
| var mutated = false |
| visitor(rootNode, ruleSets, concurrent = false) |
| .invoke { node, rule, fqRuleId -> |
| // fixme: enforcing suppression based on node.startOffset is wrong |
| // (not just because not all nodes are leaves but because rules are free to emit (and fix!) errors at any position) |
| if (!isSuppressed(node.startOffset, fqRuleId) || node === rootNode) { |
| try { |
| rule.visit(node, true) { _, _, canBeAutoCorrected -> |
| tripped = true |
| if (canBeAutoCorrected) { |
| mutated = true |
| if (isSuppressed !== nullSuppression) { |
| isSuppressed = calculateSuppressedRegions(rootNode) |
| } |
| } |
| } |
| } catch (e: Exception) { |
| // line/col cannot be reliably mapped as exception might originate from a node not present |
| // in the original AST |
| throw RuleExecutionException(0, 0, fqRuleId, e) |
| } |
| } |
| } |
| if (tripped) { |
| visitor(rootNode, ruleSets).invoke { node, rule, fqRuleId -> |
| // fixme: enforcing suppression based on node.startOffset is wrong |
| // (not just because not all nodes are leaves but because rules are free to emit (and fix!) errors at any position) |
| if (!isSuppressed(node.startOffset, fqRuleId) || node === rootNode) { |
| try { |
| rule.visit(node, false) { offset, errorMessage, _ -> |
| val (line, col) = positionByOffset(offset) |
| cb(LintError(line, col, fqRuleId, errorMessage), false) |
| } |
| } catch (e: Exception) { |
| val (line, col) = positionByOffset(node.startOffset) |
| throw RuleExecutionException(line, col, fqRuleId, e) |
| } |
| } |
| } |
| } |
| return if (mutated) rootNode.text.replace("\n", determineLineSeparator(text)) else text |
| } |
| |
| private fun calculateLineBreakOffset(fileContent: String): (offset: Int) -> Int { |
| val arr = ArrayList<Int>() |
| var i = 0 |
| do { |
| arr.add(i) |
| i = fileContent.indexOf("\r\n", i + 1) |
| } while (i != -1) |
| arr.add(fileContent.length) |
| return if (arr.size != 2) |
| SegmentTree(arr.toTypedArray()).let { return { offset -> it.indexOf(offset) } } else { _ -> 0 } |
| } |
| |
| private fun determineLineSeparator(fileContent: String): String { |
| val i = fileContent.lastIndexOf('\n') |
| if (i == -1) { |
| return if (fileContent.lastIndexOf('\r') == -1) System.getProperty("line.separator") else "\r" |
| } |
| return if (i != 0 && fileContent[i] == '\r') "\r\n" else "\n" |
| } |
| |
| /** |
| * @param range zero-based range of lines where lint errors should be suppressed |
| * @param disabledRules empty set means "all" |
| */ |
| private data class SuppressionHint(val range: IntRange, val disabledRules: Set<String> = emptySet()) { |
| |
| companion object { |
| |
| fun collect(rootNode: ASTNode): List<SuppressionHint> { |
| val result = ArrayList<SuppressionHint>() |
| val open = ArrayList<SuppressionHint>() |
| rootNode.visit { node -> |
| if (node is PsiComment) { |
| val text = node.getText() |
| if (text.startsWith("//")) { |
| val commentText = text.removePrefix("//").trim() |
| parseHintArgs(commentText, "ktlint-disable")?.let { args -> |
| val lineStart = (node.prevLeaf { it is PsiWhiteSpace && it.textContains('\n') } as |
| PsiWhiteSpace?)?.let { it.startOffset + it.text.lastIndexOf('\n') + 1 } ?: 0 |
| result.add(SuppressionHint(IntRange(lineStart, node.startOffset), HashSet(args))) |
| } |
| } else { |
| val commentText = text.removePrefix("/*").removeSuffix("*/").trim() |
| parseHintArgs(commentText, "ktlint-disable")?.apply { |
| open.add(SuppressionHint(IntRange(node.startOffset, node.startOffset), HashSet(this))) |
| } |
| ?: parseHintArgs(commentText, "ktlint-enable")?.apply { |
| // match open hint |
| val disabledRules = HashSet(this) |
| val openHintIndex = open.indexOfLast { it.disabledRules == disabledRules } |
| if (openHintIndex != -1) { |
| val openingHint = open.removeAt(openHintIndex) |
| result.add(SuppressionHint(IntRange(openingHint.range.start, node.startOffset), |
| disabledRules)) |
| } |
| } |
| } |
| } |
| } |
| result.addAll(open.map { |
| SuppressionHint(IntRange(it.range.first, rootNode.textLength), it.disabledRules) |
| }) |
| return result |
| } |
| |
| private fun parseHintArgs(commentText: String, key: String): List<String>? { |
| if (commentText.startsWith(key)) { |
| val parsedComment = splitCommentBySpace(commentText) |
| // assert exact match |
| if (parsedComment[0] == key) { |
| return parsedComment.tail() |
| } |
| } |
| return null |
| } |
| |
| private fun splitCommentBySpace(comment: String) = |
| comment.replace(Regex("\\s"), " ").replace(" {2,}", " ").split(" ") |
| |
| private fun <T> List<T>.tail() = this.subList(1, this.size) |
| } |
| } |
| |
| private class SegmentTree { |
| |
| private val segments: List<Segment> |
| |
| constructor(sortedArray: Array<Int>) { |
| require(sortedArray.size > 1) { "At least two data points are required" } |
| sortedArray.reduce { r, v -> require(r <= v) { "Data points are not sorted (ASC)" }; v } |
| segments = sortedArray.take(sortedArray.size - 1) |
| .mapIndexed { i: Int, v: Int -> Segment(v, sortedArray[i + 1] - 1) } |
| } |
| |
| fun get(i: Int): Segment = segments[i] |
| fun indexOf(v: Int): Int = binarySearch(v, 0, this.segments.size - 1) |
| |
| private fun binarySearch(v: Int, l: Int, r: Int): Int = when { |
| l > r -> -1 |
| else -> { |
| val i = l + (r - l) / 2 |
| val s = segments[i] |
| if (v < s.left) binarySearch(v, l, i - 1) else (if (s.right < v) binarySearch(v, i + 1, r) else i) |
| } |
| } |
| } |
| |
| private data class Segment(val left: Int, val right: Int) |
| |
| private fun PsiElement.findErrorElement(): PsiErrorElement? { |
| if (this is PsiErrorElement) { |
| return this |
| } |
| this.children.forEach { child -> |
| val errorElement = child.findErrorElement() |
| if (errorElement != null) { |
| return errorElement |
| } |
| } |
| return null |
| } |
| |
| private fun ASTNode.visit(cb: (node: ASTNode) -> Unit) { |
| cb(this) |
| this.getChildren(null).forEach { it.visit(cb) } |
| } |
| } |