Skip to content

Commit

Permalink
Add API for retrieving the list of files that will be accessed (#1659)
Browse files Browse the repository at this point in the history
* Add API for retrieving the list of files that will be accessed when linting or formatting a given path

Closes #1446
  • Loading branch information
paul-dingemans authored Sep 26, 2022
1 parent 09ea546 commit 8e78581
Show file tree
Hide file tree
Showing 6 changed files with 295 additions and 10 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ This project adheres to [Semantic Versioning](https://semver.org/).

### API Changes & RuleSet providers

#### Retrieve `.editorconfig`s

The list of `.editorconfig` files which will be accessed by KtLint when linting or formatting a given path can now be retrieved with the new API `KtLint.editorConfigFilePaths(path: Path): List<Path>`.

This API can be called with either a file or a directory. It's intended usage is that it is called once with the root directory of a project before actually linting or formatting files of that project. When called with a directory path, all `.editorconfig` files in the directory or any of its subdirectories (except hidden directories) are returned. In case the given directory does not contain an `.editorconfig` file or if it does not contain the `root=true` setting, the parent directories are scanned as well until a root `.editorconfig` file is found.

Calling this API with a file path results in the `.editorconfig` files that will be accessed when processing that specific file. In case the directory in which the file resides does not contain an `.editorconfig` file or if it does not contain the `root=true` setting, the parent directories are scanned until a root `.editorconfig` file is found.

### Added
* Wrap blocks in case the max line length is exceeded or in case the block contains a new line `wrapping` ([#1643](https://github.com/pinterest/ktlint/issue/1643))

Expand Down
15 changes: 14 additions & 1 deletion ktlint-core/src/main/kotlin/com/pinterest/ktlint/core/KtLint.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.pinterest.ktlint.core.api.EditorConfigOverride
import com.pinterest.ktlint.core.api.EditorConfigOverride.Companion.emptyEditorConfigOverride
import com.pinterest.ktlint.core.api.EditorConfigProperties
import com.pinterest.ktlint.core.api.UsesEditorConfigProperties
import com.pinterest.ktlint.core.internal.EditorConfigFinder
import com.pinterest.ktlint.core.internal.EditorConfigGenerator
import com.pinterest.ktlint.core.internal.EditorConfigLoader
import com.pinterest.ktlint.core.internal.RuleExecutionContext
Expand All @@ -27,6 +28,7 @@ import org.jetbrains.kotlin.com.intellij.lang.ASTNode
import org.jetbrains.kotlin.com.intellij.openapi.util.Key
import org.jetbrains.kotlin.utils.addToStdlib.safeAs

@Suppress("MemberVisibilityCanBePrivate")
public object KtLint {
public val FILE_PATH_USER_DATA_KEY: Key<String> = Key<String>("FILE_PATH")

Expand Down Expand Up @@ -329,10 +331,21 @@ public object KtLint {
threadSafeEditorConfigCache.clear()
}

/**
* Get the list of files which will be accessed by KtLint when linting or formatting the given file or directory.
* The API consumer can use this list to observe changes in '.editorconfig` files. Whenever such a change is
* observed, the API consumer should call [reloadEditorConfigFile].
* To avoid unnecessary access to the file system, it is best to call this method only once for the root of the
* project which is to be [lint] or [format].
*/
public fun editorConfigFilePaths(path: Path): List<Path> =
EditorConfigFinder().findEditorConfigs(path)

/**
* Reloads an '.editorconfig' file given that it is currently loaded into the KtLint cache. This method is intended
* to be called by the API consumer when it is aware of changes in the '.editorconfig' file that should be taken
* into account with next calls to [lint] and/or [format].
* into account with next calls to [lint] and/or [format]. See [editorConfigFilePaths] to get the list of
* '.editorconfig' files which need to be observed.
*/
public fun reloadEditorConfigFile(path: Path) {
threadSafeEditorConfigCache.reloadIfExists(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package com.pinterest.ktlint.core.internal

import com.pinterest.ktlint.core.initKtLintKLogger
import java.nio.charset.StandardCharsets
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.isDirectory
import mu.KotlinLogging
import org.ec4j.core.Resource
import org.ec4j.core.ResourcePropertiesService
import org.ec4j.core.model.Version
import org.jetbrains.kotlin.konan.file.File

private val logger = KotlinLogging.logger {}.initKtLintKLogger()

internal class EditorConfigFinder {
// Do not reuse the generic threadSafeEditorConfigCache to prevent that results are incorrect due to other calls to
// KtLint that result in changing the cache
private val editorConfigCache = ThreadSafeEditorConfigCache()

/**
* Finds all relevant ".editorconfig" files for the given path.
*/
fun findEditorConfigs(path: Path): List<Path> {
val result = mutableListOf<Path>()
val normalizedPath = path.normalize().toAbsolutePath()
if (path.isDirectory()) {
result += findEditorConfigsInSubDirectories(normalizedPath)
}
result += findEditorConfigsInParentDirectories(normalizedPath)
return result
.map {
// Resolve against original path as the drive letter seems to get lost on WindowsOs
path.resolve(it)
}.toList()
}

private fun findEditorConfigsInSubDirectories(path: Path): List<Path> {
val result = mutableListOf<Path>()

Files.walkFileTree(
path,
object : SimpleFileVisitor<Path>() {
override fun visitFile(
filePath: Path,
fileAttrs: BasicFileAttributes,
): FileVisitResult {
if (filePath.File().name == ".editorconfig") {
logger.trace { "- File: $filePath: add to list of accessed files" }
result.add(filePath)
}
return FileVisitResult.CONTINUE
}

override fun preVisitDirectory(
dirPath: Path,
dirAttr: BasicFileAttributes,
): FileVisitResult {
return if (Files.isHidden(dirPath)) {
logger.trace { "- Dir: $dirPath: Ignore" }
FileVisitResult.SKIP_SUBTREE
} else {
logger.trace { "- Dir: $dirPath: Traverse" }
FileVisitResult.CONTINUE
}
}
},
)

return result.toList()
}

private fun findEditorConfigsInParentDirectories(path: Path): List<Path> {
// The logic to load parental ".editorconfig" files resides in the ec4j library. This library however uses a
// cache provided by KtLint. As of this the list of parental ".editorconfig" files can be extracted from the
// cache.
createLoaderService().queryProperties(path.resource())
return editorConfigCache.getPaths()
}

private fun Path?.resource() =
Resource.Resources.ofPath(this, StandardCharsets.UTF_8)

private fun createLoaderService() =
ResourcePropertiesService.builder()
.cache(editorConfigCache)
.loader(org.ec4j.core.EditorConfigLoader.of(Version.CURRENT))
.build()
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.pinterest.ktlint.core.internal

import com.pinterest.ktlint.core.initKtLintKLogger
import java.nio.file.Paths
import java.util.concurrent.locks.ReentrantReadWriteLock
import kotlin.concurrent.read
import kotlin.concurrent.write
Expand Down Expand Up @@ -72,6 +73,14 @@ internal class ThreadSafeEditorConfigCache : Cache {
}.clear()
}

/**
* Get the paths of files stored in the cache.
*/
fun getPaths() =
inMemoryMap
.keys
.map { Paths.get(it.path.toString()) }

private data class CacheValue(
val editorConfigLoader: EditorConfigLoader,
val editConfig: EditorConfig,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package com.pinterest.ktlint.core.internal

import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import kotlin.io.path.absolutePathString
import kotlin.io.path.writeText
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

class EditorConfigFinderTest {
@Nested
inner class FindByFile {
@Test
fun `Given a kotlin file in a subdirectory and a root-editorconfig file in the same directory then get the path of that editorconfig file`(
@TempDir
tempDir: Path,
) {
val someSubDir = "some-project/src/main/kotlin"
tempDir.createFile("$someSubDir/.editorconfig", "root=true")
val kotlinFilePath = tempDir.createFile("$someSubDir/test.kt", "val foo = 42")

val actual = EditorConfigFinder().findEditorConfigs(kotlinFilePath)

assertThat(actual).contains(
tempDir.plus("$someSubDir/.editorconfig"),
)
}

@Test
fun `Given a kotlin file in a subdirectory and a root-editorconfig file in a parent directory then get the path of that parent editorconfig file`(
@TempDir
tempDir: Path,
) {
val someProjectDirectory = "some-project"
tempDir.createFile("$someProjectDirectory/.editorconfig", "root=true")
val kotlinFilePath = tempDir.createFile("$someProjectDirectory/src/main/kotlin/test.kt", "val foo = 42")

val actual = EditorConfigFinder().findEditorConfigs(kotlinFilePath)

assertThat(actual).contains(
tempDir.plus("$someProjectDirectory/.editorconfig"),
)
}

@Test
fun `Given a kotlin file in a subdirectory and a non-root-editorconfig file in that same directory and a root-editorconfig file in a parent directory then get the paths of both editorconfig files`(
@TempDir
tempDir: Path,
) {
val someProjectDirectory = "some-project"
tempDir.createFile("$someProjectDirectory/.editorconfig", "root=true")
tempDir.createFile("$someProjectDirectory/src/main/.editorconfig", "root=false")
val kotlinFilePath = tempDir.createFile("$someProjectDirectory/src/main/kotlin/test.kt", "val foo = 42")

val actual = EditorConfigFinder().findEditorConfigs(kotlinFilePath)

assertThat(actual).contains(
tempDir.plus("$someProjectDirectory/.editorconfig"),
tempDir.plus("$someProjectDirectory/src/main/.editorconfig"),
)
}

@Test
fun `Given a kotlin file in a subdirectory and a root-editorconfig file in the parent directory and another root-editorconfig file in a great-parent directory then get the paths of editorconfig files excluding root-editorconfig once the first one is found`(
@TempDir
tempDir: Path,
) {
val someProjectDirectory = "some-project"
tempDir.createFile("$someProjectDirectory/src/main/.editorconfig", "root=false")
tempDir.createFile("$someProjectDirectory/src/.editorconfig", "root=true")
tempDir.createFile("$someProjectDirectory/.editorconfig", "root=true")
val kotlinFilePath = tempDir.createFile("$someProjectDirectory/src/main/kotlin/test.kt", "val foo = 42")

val actual = EditorConfigFinder().findEditorConfigs(kotlinFilePath)

assertThat(actual)
.contains(
tempDir.plus("$someProjectDirectory/src/main/.editorconfig"),
tempDir.plus("$someProjectDirectory/src/.editorconfig"),
).doesNotContain(
tempDir.plus("$someProjectDirectory/.editorconfig"),
)
}
}

@Nested
inner class FindByDirectory {
@Test
fun `Given a directory containing a root-editorconfig file and a subdirectory containing a editorconfig file then get the paths of both editorconfig files`(
@TempDir
tempDir: Path,
) {
val someDirectory = "some-project"
tempDir.createFile("$someDirectory/.editorconfig", "root=true")
tempDir.createFile("$someDirectory/src/main/kotlin/.editorconfig", "some-property=some-value")

val actual = EditorConfigFinder().findEditorConfigs(tempDir.plus(someDirectory))

assertThat(actual).contains(
tempDir.plus("$someDirectory/.editorconfig"),
tempDir.plus("$someDirectory/src/main/kotlin/.editorconfig"),
)
}

@Test
fun `Given a subdirectory containing an editorconfig file and a sibling subdirectory contain a editorconfig file in a parent directory then get the path of all editorconfig file except of the sibling subdirectory`(
@TempDir
tempDir: Path,
) {
val someProjectDirectory = "some-project"
tempDir.createFile("$someProjectDirectory/.editorconfig", "root=true")
tempDir.createFile("$someProjectDirectory/src/main/kotlin/.editorconfig", "some-property=some-value")
tempDir.createFile("$someProjectDirectory/src/test/kotlin/.editorconfig", "some-property=some-value")

val actual = EditorConfigFinder().findEditorConfigs(tempDir.plus("$someProjectDirectory/src/main/kotlin"))

assertThat(actual)
.contains(
tempDir.plus("$someProjectDirectory/.editorconfig"),
tempDir.plus("$someProjectDirectory/src/main/kotlin/.editorconfig"),
).doesNotContain(
tempDir.plus("$someProjectDirectory/src/test/kotlin/.editorconfig"),
)
}

@Test
fun `Given a directory containing an editorconfig file and multiple subdirectores containing a editorconfig file then get the path of all editorconfig files`(
@TempDir
tempDir: Path,
) {
val someProjectDirectory = "some-project"
tempDir.createFile("$someProjectDirectory/.editorconfig", "root=true")
tempDir.createFile("$someProjectDirectory/src/main/kotlin/.editorconfig", "some-property=some-value")
tempDir.createFile("$someProjectDirectory/src/test/kotlin/.editorconfig", "some-property=some-value")

val actual = EditorConfigFinder().findEditorConfigs(tempDir.plus(someProjectDirectory))

assertThat(actual).contains(
tempDir.plus("$someProjectDirectory/.editorconfig"),
tempDir.plus("$someProjectDirectory/src/main/kotlin/.editorconfig"),
tempDir.plus("$someProjectDirectory/src/test/kotlin/.editorconfig"),
)
}
}

private fun Path.createFile(fileName: String, content: String): Path {
val dirPath = fileName.substringBeforeLast("/", "")
Files.createDirectories(this.plus(dirPath))
return Files
.createFile(this.plus(fileName))
.also { it.writeText(content) }
}

private fun Path.plus(subPath: String): Path =
this
.absolutePathString()
.plus(this.fileSystem.separator)
.plus(subPath)
.let { Paths.get(it) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,28 +71,28 @@ class ThreadSafeEditorConfigCacheTest {
}

@Test
fun `Given that a file is stored in the cache and then the cache is cleared and the file is requested again then the file is to be reloaded`() {
fun `Given that a file is stored in the cache and then file is explicitly reloaded`() {
val threadSafeEditorConfigCache = ThreadSafeEditorConfigCache()

val editorConfigLoaderFile1 = EditorConfigLoaderMock(EDIT_CONFIG_1)
threadSafeEditorConfigCache.get(FILE_1, editorConfigLoaderFile1)
threadSafeEditorConfigCache.clear()
threadSafeEditorConfigCache.get(FILE_1, editorConfigLoaderFile1)
threadSafeEditorConfigCache.get(FILE_1, editorConfigLoaderFile1)
threadSafeEditorConfigCache.reloadIfExists(FILE_1)
threadSafeEditorConfigCache.reloadIfExists(FILE_1)

assertThat(editorConfigLoaderFile1.loadCount).isEqualTo(2)
assertThat(editorConfigLoaderFile1.loadCount).isEqualTo(3)
}

@Test
fun `Given that a file is stored in the cache and then file is explicitly reloaded`() {
fun `Given that a file is stored in the cache and then the cache is cleared and the file is requested again then the file is to be reloaded`() {
val threadSafeEditorConfigCache = ThreadSafeEditorConfigCache()

val editorConfigLoaderFile1 = EditorConfigLoaderMock(EDIT_CONFIG_1)
threadSafeEditorConfigCache.get(FILE_1, editorConfigLoaderFile1)
threadSafeEditorConfigCache.reloadIfExists(FILE_1)
threadSafeEditorConfigCache.reloadIfExists(FILE_1)
threadSafeEditorConfigCache.clear()
threadSafeEditorConfigCache.get(FILE_1, editorConfigLoaderFile1)
threadSafeEditorConfigCache.get(FILE_1, editorConfigLoaderFile1)

assertThat(editorConfigLoaderFile1.loadCount).isEqualTo(3)
assertThat(editorConfigLoaderFile1.loadCount).isEqualTo(2)
}

private companion object {
Expand Down

0 comments on commit 8e78581

Please sign in to comment.