Skip to content

Commit

Permalink
Add API to process a Kotlin script snippet (#1746)
Browse files Browse the repository at this point in the history
* Add API so that KtLint API consumer is able to process a Kotlin script snippet without having to specify a file path

Closes #1738
  • Loading branch information
paul-dingemans committed Dec 27, 2022
1 parent 7836a6c commit 0ce631d
Show file tree
Hide file tree
Showing 16 changed files with 412 additions and 38 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions 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)
}
2 changes: 2 additions & 0 deletions ktlint-api-consumer/gradle.properties
@@ -0,0 +1,2 @@
POM_NAME=ktlint-api-consumer
POM_ARTIFACT_ID=ktlint-api-consumer
@@ -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'" }
}
}
}
@@ -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<String>) {
if (args.size != 2) {
LOGGER.error { "Expected two arguments" }
exitProcess(1)
}

KtlintApiConsumer().run(args[0], args[1])
}
@@ -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() },
)
@@ -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)
}
}
}
3 changes: 3 additions & 0 deletions 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.
@@ -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<Path>() {
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")
}
}
@@ -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<LintError>()
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<LintError>()
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<LintError>()
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(),
)
}
}
}
3 changes: 3 additions & 0 deletions 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.
@@ -0,0 +1,3 @@
fun main() {
println("Hello world!")
}

0 comments on commit 0ce631d

Please sign in to comment.