diff --git a/CHANGELOG.md b/CHANGELOG.md index f48c25d863..57a782c980 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. + +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 ### Fixed 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 f1c349e1c5..7633b19e54 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 @@ -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 @@ -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 = Key("FILE_PATH") @@ -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 = + 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( diff --git a/ktlint-core/src/main/kotlin/com/pinterest/ktlint/core/internal/EditorConfigFinder.kt b/ktlint-core/src/main/kotlin/com/pinterest/ktlint/core/internal/EditorConfigFinder.kt new file mode 100644 index 0000000000..41fda5909f --- /dev/null +++ b/ktlint-core/src/main/kotlin/com/pinterest/ktlint/core/internal/EditorConfigFinder.kt @@ -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 { + val result = mutableListOf() + 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 { + val result = mutableListOf() + + Files.walkFileTree( + path, + object : SimpleFileVisitor() { + 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 { + // 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() +} diff --git a/ktlint-core/src/main/kotlin/com/pinterest/ktlint/core/internal/ThreadSafeEditorConfigCache.kt b/ktlint-core/src/main/kotlin/com/pinterest/ktlint/core/internal/ThreadSafeEditorConfigCache.kt index 1eeb2c9a37..67ca34672c 100644 --- a/ktlint-core/src/main/kotlin/com/pinterest/ktlint/core/internal/ThreadSafeEditorConfigCache.kt +++ b/ktlint-core/src/main/kotlin/com/pinterest/ktlint/core/internal/ThreadSafeEditorConfigCache.kt @@ -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 @@ -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, diff --git a/ktlint-core/src/test/kotlin/com/pinterest/ktlint/core/internal/EditorConfigFinderTest.kt b/ktlint-core/src/test/kotlin/com/pinterest/ktlint/core/internal/EditorConfigFinderTest.kt new file mode 100644 index 0000000000..67cb51287d --- /dev/null +++ b/ktlint-core/src/test/kotlin/com/pinterest/ktlint/core/internal/EditorConfigFinderTest.kt @@ -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) } +} diff --git a/ktlint-core/src/test/kotlin/com/pinterest/ktlint/core/internal/ThreadSafeEditorConfigCacheTest.kt b/ktlint-core/src/test/kotlin/com/pinterest/ktlint/core/internal/ThreadSafeEditorConfigCacheTest.kt index 02db3aae07..47a150e6e6 100644 --- a/ktlint-core/src/test/kotlin/com/pinterest/ktlint/core/internal/ThreadSafeEditorConfigCacheTest.kt +++ b/ktlint-core/src/test/kotlin/com/pinterest/ktlint/core/internal/ThreadSafeEditorConfigCacheTest.kt @@ -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 {