diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fa0dd7e7c..34ba091f62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ### Fixed * An enumeration class having a primary constructor and in which the list of enum entries is followed by a semicolon then do not remove the semicolon in case it is followed by code element `no-semi` ([#1733](https://github.com/pinterest/ktlint/issues/1733)) +* Add API so that KtLint API consumer is able to process a Kotlin script snippet without having to specify a file path ([#1738](https://github.com/pinterest/ktlint/issues/1738)) * Disable the `standard:filename` rule whenever Ktlint CLI is run with option `--stdin` ([#1742](https://github.com/pinterest/ktlint/issues/1742)) ### Changed diff --git a/ktlint-api-consumer/build.gradle.kts b/ktlint-api-consumer/build.gradle.kts new file mode 100644 index 0000000000..b44b1527ac --- /dev/null +++ b/ktlint-api-consumer/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + id("ktlint-kotlin-common") + id("ktlint-publication") +} + +dependencies { + implementation(projects.ktlintCore) + implementation(projects.ktlintRulesetStandard) + implementation(libs.logback) + + testImplementation(libs.junit5) + testImplementation(libs.assertj) +} diff --git a/ktlint-api-consumer/gradle.properties b/ktlint-api-consumer/gradle.properties new file mode 100644 index 0000000000..4a0c7942cd --- /dev/null +++ b/ktlint-api-consumer/gradle.properties @@ -0,0 +1,2 @@ +POM_NAME=ktlint-api-consumer +POM_ARTIFACT_ID=ktlint-api-consumer diff --git a/ktlint-api-consumer/src/main/kotlin/com/example/ktlint/api/consumer/KtlintApiConsumer.kt b/ktlint-api-consumer/src/main/kotlin/com/example/ktlint/api/consumer/KtlintApiConsumer.kt new file mode 100644 index 0000000000..ad98b64898 --- /dev/null +++ b/ktlint-api-consumer/src/main/kotlin/com/example/ktlint/api/consumer/KtlintApiConsumer.kt @@ -0,0 +1,40 @@ +package com.example.ktlint.api.consumer + +import com.example.ktlint.api.consumer.rules.KTLINT_API_CONSUMER_RULE_PROVIDERS +import com.pinterest.ktlint.core.Code +import com.pinterest.ktlint.core.KtLintRuleEngine +import com.pinterest.ktlint.core.initKtLintKLogger +import java.io.File +import mu.KotlinLogging + +private val LOGGER = KotlinLogging.logger {}.initKtLintKLogger() + +public class KtlintApiConsumer { + // The KtLint RuleEngine only needs to be instantiated once and can be reused in multiple invocations + private val ktLintRuleEngine = KtLintRuleEngine( + ruleProviders = KTLINT_API_CONSUMER_RULE_PROVIDERS, + ) + + public fun run(command: String, fileName: String) { + val codeFile = Code.CodeFile( + File(fileName), + ) + + when (command) { + "lint" -> { + ktLintRuleEngine + .lint(codeFile) { + LOGGER.info { "LintViolation reported by KtLint: $it" } + } + } + "format" -> { + ktLintRuleEngine + .format(codeFile) + .also { + LOGGER.info { "Code formatted by KtLint:\n$it" } + } + } + else -> LOGGER.error { "Unexpected argument '$command'" } + } + } +} diff --git a/ktlint-api-consumer/src/main/kotlin/com/example/ktlint/api/consumer/Main.kt b/ktlint-api-consumer/src/main/kotlin/com/example/ktlint/api/consumer/Main.kt new file mode 100644 index 0000000000..001743bd94 --- /dev/null +++ b/ktlint-api-consumer/src/main/kotlin/com/example/ktlint/api/consumer/Main.kt @@ -0,0 +1,15 @@ +import com.example.ktlint.api.consumer.KtlintApiConsumer +import com.pinterest.ktlint.core.initKtLintKLogger +import kotlin.system.exitProcess +import mu.KotlinLogging + +private val LOGGER = KotlinLogging.logger {}.initKtLintKLogger() + +public fun main(args: Array) { + if (args.size != 2) { + LOGGER.error { "Expected two arguments" } + exitProcess(1) + } + + KtlintApiConsumer().run(args[0], args[1]) +} diff --git a/ktlint-api-consumer/src/main/kotlin/com/example/ktlint/api/consumer/rules/CustomRuleSetProvider.kt b/ktlint-api-consumer/src/main/kotlin/com/example/ktlint/api/consumer/rules/CustomRuleSetProvider.kt new file mode 100644 index 0000000000..1cb2e95048 --- /dev/null +++ b/ktlint-api-consumer/src/main/kotlin/com/example/ktlint/api/consumer/rules/CustomRuleSetProvider.kt @@ -0,0 +1,11 @@ +package com.example.ktlint.api.consumer.rules + +import com.pinterest.ktlint.core.RuleProvider +import com.pinterest.ktlint.ruleset.standard.IndentationRule + +internal val KTLINT_API_CONSUMER_RULE_PROVIDERS = setOf( + // Can provide custom rules + RuleProvider { NoVarRule() }, + // but also reuse rules from KtLint rulesets + RuleProvider { IndentationRule() }, +) diff --git a/ktlint-api-consumer/src/main/kotlin/com/example/ktlint/api/consumer/rules/NoVarRule.kt b/ktlint-api-consumer/src/main/kotlin/com/example/ktlint/api/consumer/rules/NoVarRule.kt new file mode 100644 index 0000000000..1dacc7d6c5 --- /dev/null +++ b/ktlint-api-consumer/src/main/kotlin/com/example/ktlint/api/consumer/rules/NoVarRule.kt @@ -0,0 +1,17 @@ +package com.example.ktlint.api.consumer.rules + +import com.pinterest.ktlint.core.Rule +import com.pinterest.ktlint.core.ast.ElementType.VAR_KEYWORD +import org.jetbrains.kotlin.com.intellij.lang.ASTNode + +public class NoVarRule : Rule("no-var") { + override fun beforeVisitChildNodes( + node: ASTNode, + autoCorrect: Boolean, + emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> Unit, + ) { + if (node.elementType == VAR_KEYWORD) { + emit(node.startOffset, "Unexpected var, use val instead", false) + } + } +} diff --git a/ktlint-api-consumer/src/main/readme.md b/ktlint-api-consumer/src/main/readme.md new file mode 100644 index 0000000000..ec93793864 --- /dev/null +++ b/ktlint-api-consumer/src/main/readme.md @@ -0,0 +1,3 @@ +# Ktlint API Consumer + +A very rudimentary example of how the Ktlint API can be used. For a more complete implementation see the `ktlint CLI` module. diff --git a/ktlint-api-consumer/src/test/kotlin/com/pinterest/ktlint/api/consumer/ApiTestRunner.kt b/ktlint-api-consumer/src/test/kotlin/com/pinterest/ktlint/api/consumer/ApiTestRunner.kt new file mode 100644 index 0000000000..5654347722 --- /dev/null +++ b/ktlint-api-consumer/src/test/kotlin/com/pinterest/ktlint/api/consumer/ApiTestRunner.kt @@ -0,0 +1,57 @@ +package com.pinterest.ktlint.api.consumer + +import com.pinterest.ktlint.core.initKtLintKLogger +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.attribute.BasicFileAttributes +import kotlin.io.path.Path +import kotlin.io.path.copyTo +import kotlin.io.path.createDirectories +import kotlin.io.path.relativeToOrSelf +import mu.KotlinLogging + +private val LOGGER = KotlinLogging.logger {}.initKtLintKLogger() + +class ApiTestRunner(private val tempDir: Path) { + fun prepareTestProject(testProjectName: String): Path { + val testProjectPath = TEST_PROJECTS_PATHS.resolve(testProjectName) + assert(Files.exists(testProjectPath)) { + "Test project $testProjectName does not exist!" + } + + return tempDir.resolve(testProjectName).also { testProjectPath.copyRecursively(it) } + } + + private fun Path.copyRecursively(dest: Path) { + Files.walkFileTree( + this, + object : SimpleFileVisitor() { + override fun preVisitDirectory( + dir: Path, + attrs: BasicFileAttributes, + ): FileVisitResult { + val relativeDir = dir.relativeToOrSelf(this@copyRecursively) + dest.resolve(relativeDir).createDirectories() + return FileVisitResult.CONTINUE + } + + override fun visitFile( + file: Path, + attrs: BasicFileAttributes, + ): FileVisitResult { + val relativeFile = file.relativeToOrSelf(this@copyRecursively) + val destinationFile = dest.resolve(relativeFile) + LOGGER.trace { "Copy '$relativeFile' to '$destinationFile'" } + file.copyTo(destinationFile) + return FileVisitResult.CONTINUE + } + }, + ) + } + + companion object { + private val TEST_PROJECTS_PATHS: Path = Path("src", "test", "resources", "api") + } +} diff --git a/ktlint-api-consumer/src/test/kotlin/com/pinterest/ktlint/api/consumer/KtLintRuleEngineTest.kt b/ktlint-api-consumer/src/test/kotlin/com/pinterest/ktlint/api/consumer/KtLintRuleEngineTest.kt new file mode 100644 index 0000000000..d4f6ed1504 --- /dev/null +++ b/ktlint-api-consumer/src/test/kotlin/com/pinterest/ktlint/api/consumer/KtLintRuleEngineTest.kt @@ -0,0 +1,175 @@ +package com.pinterest.ktlint.api.consumer + +import com.pinterest.ktlint.core.Code +import com.pinterest.ktlint.core.KtLintRuleEngine +import com.pinterest.ktlint.core.LintError +import com.pinterest.ktlint.core.RuleProvider +import com.pinterest.ktlint.ruleset.standard.IndentationRule +import java.io.File +import java.nio.file.Path +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir + +/** + * The KtLintRuleEngine is use by the Ktlint CLI and external API Consumers. Although most functionalities of the RuleEngine are already + * tested via the Ktlint CLI Tests and normal unit tests in KtLint Core, some functionalities need additional testing from the perspective + * of an API Consumer to ensure that the API is usable and stable across releases. + */ +class KtLintRuleEngineTest { + @Nested + inner class `Lint with KtLintRuleEngine` { + @Test + fun `Given a file that does not contain an error`( + @TempDir + tempDir: Path, + ) { + val dir = ApiTestRunner(tempDir).prepareTestProject("no-code-style-error") + + val ktLintRuleEngine = KtLintRuleEngine( + ruleProviders = setOf( + RuleProvider { IndentationRule() }, + ), + ) + + val lintErrors = mutableListOf() + ktLintRuleEngine.lint( + code = Code.CodeFile(File("$dir/Main.kt")), + callback = { lintErrors.add(it) }, + ) + + assertThat(lintErrors).isEmpty() + } + + @Test + fun `Given a kotlin code snippet that does not contain an error`() { + val ktLintRuleEngine = KtLintRuleEngine( + ruleProviders = setOf( + RuleProvider { IndentationRule() }, + ), + ) + + val lintErrors = mutableListOf() + ktLintRuleEngine.lint( + code = Code.CodeSnippet( + """ + fun main() { + println("Hello world!") + } + """.trimIndent(), + ), + callback = { lintErrors.add(it) }, + ) + + assertThat(lintErrors).isEmpty() + } + + @Test + fun `Givens a kotlin script code snippet that does not contain an error`() { + val ktLintRuleEngine = KtLintRuleEngine( + ruleProviders = setOf( + RuleProvider { IndentationRule() }, + ), + ) + + val lintErrors = mutableListOf() + ktLintRuleEngine.lint( + code = Code.CodeSnippet( + """ + plugins { + id("foo") + id("bar") + } + """.trimIndent(), + script = true, + ), + callback = { lintErrors.add(it) }, + ) + + assertThat(lintErrors).isEmpty() + } + } + + @Nested + inner class `Format with KtLintRuleEngine` { + @Test + fun `Given a file that does not contain an error`( + @TempDir + tempDir: Path, + ) { + val dir = ApiTestRunner(tempDir).prepareTestProject("no-code-style-error") + + val ktLintRuleEngine = KtLintRuleEngine( + ruleProviders = setOf( + RuleProvider { IndentationRule() }, + ), + ) + + val original = File("$dir/Main.kt").readText() + + val actual = ktLintRuleEngine.format( + code = Code.CodeFile(File("$dir/Main.kt")), + ) + + assertThat(actual).isEqualTo(original) + } + + @Test + fun `Given a kotlin code snippet that does contain an indentation error`() { + val ktLintRuleEngine = KtLintRuleEngine( + ruleProviders = setOf( + RuleProvider { IndentationRule() }, + ), + ) + + val actual = ktLintRuleEngine.format( + code = Code.CodeSnippet( + """ + fun main() { + println("Hello world!") + } + """.trimIndent(), + ), + ) + + assertThat(actual).isEqualTo( + """ + fun main() { + println("Hello world!") + } + """.trimIndent(), + ) + } + + @Test + fun `Given a kotlin script code snippet that does contain an indentation error`() { + val ktLintRuleEngine = KtLintRuleEngine( + ruleProviders = setOf( + RuleProvider { IndentationRule() }, + ), + ) + + val actual = ktLintRuleEngine.format( + code = Code.CodeSnippet( + """ + plugins { + id("foo") + id("bar") + } + """.trimIndent(), + script = true, + ), + ) + + assertThat(actual).isEqualTo( + """ + plugins { + id("foo") + id("bar") + } + """.trimIndent(), + ) + } + } +} diff --git a/ktlint-api-consumer/src/test/readme.md b/ktlint-api-consumer/src/test/readme.md new file mode 100644 index 0000000000..9a32af5096 --- /dev/null +++ b/ktlint-api-consumer/src/test/readme.md @@ -0,0 +1,3 @@ +# Ktlint API Consumer API tests + +The tests in this module do *not* test the Ktlint API Consumer itself. The tests verify the usage of the Ktlint API from the perspective of the API Consumer. This to ensure that the API is usable from an external perspective. diff --git a/ktlint-api-consumer/src/test/resources/api/no-code-style-error/Main.kt b/ktlint-api-consumer/src/test/resources/api/no-code-style-error/Main.kt new file mode 100644 index 0000000000..acf03f8cf9 --- /dev/null +++ b/ktlint-api-consumer/src/test/resources/api/no-code-style-error/Main.kt @@ -0,0 +1,3 @@ +fun main() { + println("Hello world!") +} diff --git a/ktlint-core/src/main/kotlin/com/pinterest/ktlint/core/KtLint.kt b/ktlint-core/src/main/kotlin/com/pinterest/ktlint/core/KtLint.kt index 4b521c7a4a..0fcf2676f4 100644 --- a/ktlint-core/src/main/kotlin/com/pinterest/ktlint/core/KtLint.kt +++ b/ktlint-core/src/main/kotlin/com/pinterest/ktlint/core/KtLint.kt @@ -179,8 +179,8 @@ public object KtLint { params.editorConfigOverride, params.isInvokedFromCli, ).lint( - Code( - text = params.text, + Code.CodeSnippetLegacy( + content = params.text, fileName = params.fileName, script = params.script, isStdIn = params.isStdIn, @@ -207,8 +207,8 @@ public object KtLint { params.editorConfigOverride, params.isInvokedFromCli, ).format( - Code( - text = params.text, + Code.CodeSnippetLegacy( + content = params.text, fileName = params.fileName, script = params.script, isStdIn = params.isStdIn, @@ -291,17 +291,19 @@ public object KtLint { } } -/** - * To be removed in KtLint 0.49 when class ExperimentalParams is removed. - */ -internal class Code private constructor( - val content: String, - val fileName: String?, - val script: Boolean, - val filePath: Path?, - val isStdIn: Boolean, +public sealed class Code( + internal open val content: String, + internal open val fileName: String?, + internal open val filePath: Path?, + internal open val script: Boolean, + internal open val isStdIn: Boolean, ) { - constructor(file: File) : this( + /** + * A [file] containing valid Kotlin code or script. The '.editorconfig' files on the path to [file] are taken into account. + */ + public class CodeFile( + private val file: File, + ) : Code( content = file.readText(), fileName = file.name, filePath = file.toPath(), @@ -309,16 +311,30 @@ internal class Code private constructor( isStdIn = false, ) - constructor(text: String, fileName: String? = null) : this( - content = text, - fileName = fileName, - filePath = fileName?.let { Paths.get(fileName) }, - script = fileName?.endsWith(".kts", ignoreCase = true) == true, - isStdIn = fileName == KtLintRuleEngine.STDIN_FILE, + /** + * The [content] represent a valid piece of Kotlin code or Kotlin script. The '.editorconfig' files on the filesystem are ignored as the + * snippet is not associated with a file path. Use [CodeFile] for scanning a file while at the same time respecting the '.editorconfig' + * files on the path to the file. + */ + public class CodeSnippet( + override val content: String, + override val script: Boolean = false, + ) : Code( + content = content, + filePath = null, + fileName = null, + script = script, + isStdIn = true, ) - constructor(text: String, fileName: String? = null, script: Boolean, isStdIn: Boolean) : this( - content = text, + @Deprecated(message = "Remove in KtLint 0.49 when class ExperimentalParams is removed") + internal class CodeSnippetLegacy( + override val content: String, + override val fileName: String? = null, + override val script: Boolean = fileName?.endsWith(".kts", ignoreCase = true) == true, + override val isStdIn: Boolean = fileName == KtLintRuleEngine.STDIN_FILE, + ) : Code( + content = content, fileName = fileName, filePath = fileName?.let { Paths.get(fileName) }, script = script, @@ -367,14 +383,15 @@ public class KtLintRuleEngine( * @throws KtLintParseException if text is not a valid Kotlin code * @throws KtLintRuleException in case of internal failure caused by a bug in rule implementation */ + @Deprecated(message = "Marked for removal in Ktlint 0.49") public fun lint( code: String, filePath: Path? = null, callback: (LintError) -> Unit = { }, ) { lint( - Code( - text = code, + Code.CodeSnippetLegacy( + content = code, fileName = filePath?.absolutePathString(), ), callback, @@ -388,18 +405,25 @@ public class KtLintRuleEngine( * @throws KtLintParseException if text is not a valid Kotlin code * @throws KtLintRuleException in case of internal failure caused by a bug in rule implementation */ + @Deprecated(message = "Marked for removal in Ktlint 0.49") public fun lint( filePath: Path, callback: (LintError) -> Unit = { }, ) { lint( - Code(filePath.toFile()), + Code.CodeFile(filePath.toFile()), callback, ) } - @Deprecated(message = "To be removed in KtLint 0.49 when ExperimentalParams is removed") - internal fun lint( + /** + * Check the [code] for lint errors. If [code] is path as file reference then the '.editorconfig' files on the path to file are taken + * into account. For each lint violation found, the [callback] is invoked. + * + * @throws KtLintParseException if text is not a valid Kotlin code + * @throws KtLintRuleException in case of internal failure caused by a bug in rule implementation + */ + public fun lint( code: Code, callback: (LintError) -> Unit = { }, ) { @@ -433,14 +457,15 @@ public class KtLintRuleEngine( * @throws KtLintParseException if text is not a valid Kotlin code * @throws KtLintRuleException in case of internal failure caused by a bug in rule implementation */ + @Deprecated(message = "Marked for removal in Ktlint 0.49") public fun format( code: String, filePath: Path? = null, callback: (LintError, Boolean) -> Unit = { _, _ -> }, ): String = format( - Code( - text = code, + Code.CodeSnippetLegacy( + content = code, fileName = filePath?.absolutePathString(), ), callback, @@ -458,12 +483,18 @@ public class KtLintRuleEngine( callback: (LintError, Boolean) -> Unit = { _, _ -> }, ): String = format( - Code(filePath.toFile()), + Code.CodeFile(filePath.toFile()), callback, ) - @Deprecated(message = "To be removed or refactored in KtLint when class ExperimentalParams is removed") - internal fun format( + /** + * Fix style violations in [code] for lint errors when possible. If [code] is passed as file reference then the '.editorconfig' files on + * the path are taken into account. For each lint violation found, the [callback] is invoked. + * + * @throws KtLintParseException if text is not a valid Kotlin code + * @throws KtLintRuleException in case of internal failure caused by a bug in rule implementation + */ + public fun format( code: Code, callback: (LintError, Boolean) -> Unit = { _, _ -> }, ): String { diff --git a/ktlint-core/src/main/kotlin/com/pinterest/ktlint/core/internal/RuleExecutionContext.kt b/ktlint-core/src/main/kotlin/com/pinterest/ktlint/core/internal/RuleExecutionContext.kt index 4bf5a61747..500941df54 100644 --- a/ktlint-core/src/main/kotlin/com/pinterest/ktlint/core/internal/RuleExecutionContext.kt +++ b/ktlint-core/src/main/kotlin/com/pinterest/ktlint/core/internal/RuleExecutionContext.kt @@ -117,11 +117,13 @@ internal class RuleExecutionContext private constructor( val normalizedText = normalizeText(code.content) val positionInTextLocator = buildPositionInTextLocator(normalizedText) - val psiFileName = when { - code.fileName != null -> code.fileName - code.script -> "file.kts" - else -> "file.kt" - } + val psiFileName = + code.fileName + ?: if (code.script) { + "file.kts" + } else { + "file.kt" + } val psiFile = psiFileFactory.createFileFromText( psiFileName, KotlinLanguage.INSTANCE, diff --git a/ktlint-core/src/test/kotlin/com/pinterest/ktlint/core/ast/PackageKtTest.kt b/ktlint-core/src/test/kotlin/com/pinterest/ktlint/core/ast/PackageKtTest.kt index 089a02851a..4b8e9b7562 100644 --- a/ktlint-core/src/test/kotlin/com/pinterest/ktlint/core/ast/PackageKtTest.kt +++ b/ktlint-core/src/test/kotlin/com/pinterest/ktlint/core/ast/PackageKtTest.kt @@ -619,7 +619,7 @@ class PackageKtTest { RuleProvider { DummyRule() }, ), ), - code = Code(code), + code = Code.CodeSnippetLegacy(code), ).rootNode private fun FileASTNode.toEnumClassBodySequence() = diff --git a/settings.gradle.kts b/settings.gradle.kts index ed9b108e0b..091ef85f07 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -32,6 +32,7 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") include( ":ktlint", ":ktlint-core", + ":ktlint-api-consumer", ":ktlint-reporter-baseline", ":ktlint-reporter-checkstyle", ":ktlint-reporter-format",