Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reload ".editorconfig" #1594

Merged
merged 1 commit into from Aug 19, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Expand Up @@ -135,6 +135,17 @@ Parameter "ExperimentalParams.editorConfigPath" is deprecated in favor of the ne
API consumers can easily create the EditConfigDefaults by calling
"EditConfigDefaults.load(path)" or creating it programmatically.

#### Reload of `.editorconfig` file

Some API Consumers keep a long-running instance of the KtLint engine alive. In case an `.editorconfig` file is changed, which was already loaded into the internal cache of the KtLint engine this change would not be taken into account by KtLint. One way to deal with this, was to clear the entire KtLint cache after each change in an `.editorconfig` file.

Now, the API consumer can reload an `.editorconfig`. If the `.editorconfig` with given path is actually found in the cached, it will be replaced with the new value directly. If the file is not yet loaded in the cache, loading will be deferred until the file is actually requested again.

Example:
```kotlin
KtLint.reloadEditorConfigFile("/some/path/to/.editorconfig")
```

#### Miscellaneous

Several methods for which it is unlikely that they are used by API consumers have been marked for removal from the public API in KtLint 0.48.0. Please create an issue in case you have a valid business case to keep such methods in the public API.
Expand Down
13 changes: 13 additions & 0 deletions ktlint-core/src/main/kotlin/com/pinterest/ktlint/core/KtLint.kt
Expand Up @@ -16,10 +16,12 @@ import com.pinterest.ktlint.core.internal.ThreadSafeEditorConfigCache.Companion.
import com.pinterest.ktlint.core.internal.VisitorProvider
import com.pinterest.ktlint.core.internal.prepareCodeForLinting
import com.pinterest.ktlint.core.internal.toQualifiedRuleId
import java.nio.charset.StandardCharsets
import java.nio.file.FileSystems
import java.nio.file.Path
import java.nio.file.Paths
import java.util.Locale
import org.ec4j.core.Resource
import org.ec4j.core.model.PropertyType
import org.jetbrains.kotlin.com.intellij.lang.ASTNode
import org.jetbrains.kotlin.com.intellij.openapi.util.Key
Expand Down Expand Up @@ -318,6 +320,17 @@ public object KtLint {
threadSafeEditorConfigCache.clear()
}

/**
* 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].
*/
public fun reloadEditorConfigFile(path: Path) {
threadSafeEditorConfigCache.reloadIfExists(
Resource.Resources.ofPath(path, StandardCharsets.UTF_8),
)
}

/**
* Generates Kotlin `.editorconfig` file section content based on [ExperimentalParams].
*
Expand Down
@@ -1,40 +1,88 @@
package com.pinterest.ktlint.core.internal

import com.pinterest.ktlint.core.initKtLintKLogger
import java.util.concurrent.locks.ReentrantReadWriteLock
import kotlin.concurrent.read
import kotlin.concurrent.write
import mu.KotlinLogging
import org.ec4j.core.Cache
import org.ec4j.core.EditorConfigLoader
import org.ec4j.core.Resource
import org.ec4j.core.model.EditorConfig

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

/**
* In-memory [Cache] implementation that could be safely accessed from any [Thread].
*/
internal class ThreadSafeEditorConfigCache : Cache {
private val readWriteLock = ReentrantReadWriteLock()
private val inMemoryMap = HashMap<Resource, EditorConfig>()
private val inMemoryMap = HashMap<Resource, CacheValue>()

/**
* Gets the [editorConfigFile] from the cache. If not found, then the [editorConfigLoader] is used to retrieve the
* Gets the [resource] from the cache. If not found, then the [editorConfigLoader] is used to retrieve the
* '.editorconfig' properties. The result is stored in the cache.
*/
override fun get(
editorConfigFile: Resource,
resource: Resource,
editorConfigLoader: EditorConfigLoader,
): EditorConfig {
readWriteLock.read {
return inMemoryMap[editorConfigFile]
val cachedEditConfig = inMemoryMap[resource]
?.also {
logger.trace { "Retrieving EditorConfig cache entry for path ${resource.path}" }
}?.editConfig
return cachedEditConfig
?: readWriteLock.write {
val result = editorConfigLoader.load(editorConfigFile)
inMemoryMap[editorConfigFile] = result
result
CacheValue(resource, editorConfigLoader)
.also { cacheValue ->
inMemoryMap[resource] = cacheValue
logger.trace { "Creating new EditorConfig cache entry for path ${resource.path}" }
}.editConfig
}
}
}

/**
* Reloads an '.editorconfig' file given that it is currently loaded into the cache.
*/
fun reloadIfExists(resource: Resource) {
readWriteLock.read {
inMemoryMap[resource]
?.let { cacheValue ->
readWriteLock.write {
cacheValue
.copy(editConfig = cacheValue.editorConfigLoader.load(resource))
.let { cacheValue -> inMemoryMap[resource] = cacheValue }
.also {
logger.trace { "Reload EditorConfig cache entry for path ${resource.path}" }
}
}
}
}
}

/**
* Clears entire cache to free-up memory.
*/
fun clear() = readWriteLock.write {
inMemoryMap.clear()
inMemoryMap
.also {
logger.trace { "Removing ${it.size} entries from the EditorConfig cache" }
}.clear()
}

private data class CacheValue(
val editorConfigLoader: EditorConfigLoader,
val editConfig: EditorConfig,
) {
constructor(
resource: Resource,
editorConfigLoader: EditorConfigLoader,
) : this(
editorConfigLoader = editorConfigLoader,
editConfig = editorConfigLoader.load(resource),
)
}

internal companion object {
Expand Down
@@ -0,0 +1,135 @@
package com.pinterest.ktlint.core.internal

import ch.qos.logback.classic.Level
import ch.qos.logback.classic.Logger
import com.pinterest.ktlint.core.initKtLintKLogger
import com.pinterest.ktlint.core.setDefaultLoggerModifier
import java.nio.charset.StandardCharsets
import java.nio.file.Paths
import mu.KotlinLogging
import org.assertj.core.api.Assertions.assertThat
import org.ec4j.core.EditorConfigLoader
import org.ec4j.core.Resource
import org.ec4j.core.model.EditorConfig
import org.ec4j.core.model.Glob
import org.ec4j.core.model.Property
import org.ec4j.core.model.Section
import org.junit.jupiter.api.Test

class ThreadSafeEditorConfigCacheTest {
init {
// Overwrite default logging with TRACE logging by initializing *and* printing first log statement before
// loading any other classes.
KotlinLogging
.logger {}
.setDefaultLoggerModifier { logger -> (logger.underlyingLogger as Logger).level = Level.TRACE }
.initKtLintKLogger()
.trace { "Enable trace logging for unit test" }
}

@Test
fun `Given a file which is requested multiple times then it is read only once and then stored into and retrieved from the cache`() {
val threadSafeEditorConfigCache = ThreadSafeEditorConfigCache()

val editorConfigLoader = EditorConfigLoaderMock(EDIT_CONFIG_1)
val actual = listOf(
threadSafeEditorConfigCache.get(FILE_1, editorConfigLoader),
threadSafeEditorConfigCache.get(FILE_1, editorConfigLoader),
threadSafeEditorConfigCache.get(FILE_1, editorConfigLoader),
)

// In logs, it can also be seen that the EditConfig entry is created only once and retrieved multiple times
assertThat(editorConfigLoader.loadCount).isEqualTo(1)
assertThat(actual).containsExactly(
EDIT_CONFIG_1,
EDIT_CONFIG_1,
EDIT_CONFIG_1,
)
}

@Test
fun `Given that multiple files are stored into the cache and one of those files is requested another time then this file is still being retrived from the cache`() {
val threadSafeEditorConfigCache = ThreadSafeEditorConfigCache()
val editorConfigLoaderFile1 = EditorConfigLoaderMock(EDIT_CONFIG_1)
val editorConfigLoaderFile2 = EditorConfigLoaderMock(EDIT_CONFIG_2)

val actual = listOf(
threadSafeEditorConfigCache.get(FILE_1, editorConfigLoaderFile1),
threadSafeEditorConfigCache.get(FILE_2, editorConfigLoaderFile2),
threadSafeEditorConfigCache.get(FILE_1, editorConfigLoaderFile1),
)

// In logs, it can also be seen that the EditConfig entry for FILE_1 and FILE_2 are created only once and
// retrieved once more for FILE_1
assertThat(editorConfigLoaderFile1.loadCount).isEqualTo(1)
assertThat(editorConfigLoaderFile2.loadCount).isEqualTo(1)
assertThat(actual).containsExactly(
EDIT_CONFIG_1,
EDIT_CONFIG_2,
EDIT_CONFIG_1,
)
}

@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`() {
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)

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

@Test
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.reloadIfExists(FILE_1)
threadSafeEditorConfigCache.reloadIfExists(FILE_1)

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

private companion object {
const val SOME_PROPERTY = "some-property"

private fun String.resource() =
Resource.Resources.ofPath(Paths.get(this), StandardCharsets.UTF_8)
val FILE_1: Resource = "/some/path/to/file/1".resource()
val FILE_2: Resource = "/some/path/to/file/2".resource()

// Create unique instances of the EditConfig by setting a value to a property
fun createUniqueInstanceOfEditConfig(id: String): EditorConfig =
EditorConfig
.builder()
.section(
Section
.builder()
.glob(Glob("*.kt"))
.properties(
Property
.builder()
.name(SOME_PROPERTY)
.value(id),
),
)
.build()

val EDIT_CONFIG_1: EditorConfig = createUniqueInstanceOfEditConfig("edit-config-1")
val EDIT_CONFIG_2: EditorConfig = createUniqueInstanceOfEditConfig("edit-config-2")
}

private class EditorConfigLoaderMock(private var initial: EditorConfig) : EditorConfigLoader(null, null) {
var loadCount = 0

override fun load(configFile: Resource): EditorConfig {
loadCount++
return initial
}
}
}