From 80ffd72f25b8f956d4647d4fe6ab89ba55051cb8 Mon Sep 17 00:00:00 2001 From: Sebastian Sellmair Date: Wed, 8 Mar 2023 16:46:37 +0100 Subject: [PATCH] [Gradle] Introduce 'LifecycleAwareProperty' KT-34662 --- .../KotlinMultiplatformPluginLifecycle.kt | 141 ++++++++++++++++-- .../gradle/plugin/KotlinPluginWrapper.kt | 3 +- .../plugin/mpp/KotlinMultiplatformPlugin.kt | 10 +- .../KotlinMetadataTargetConfigurator.kt | 2 +- .../KotlinTargetHierarchyDescriptorTest.kt | 15 +- .../unitTests/KotlinTargetHierarchyDslTest.kt | 2 +- .../unitTests/LifecycleAwarePropertyTest.kt | 63 ++++++++ .../kotlin/gradle/util/lifecycleUtils.kt | 15 ++ 8 files changed, 222 insertions(+), 29 deletions(-) create mode 100644 libraries/tools/kotlin-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/kotlin/gradle/unitTests/LifecycleAwarePropertyTest.kt create mode 100644 libraries/tools/kotlin-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/kotlin/gradle/util/lifecycleUtils.kt diff --git a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/KotlinMultiplatformPluginLifecycle.kt b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/KotlinMultiplatformPluginLifecycle.kt index 44549c7ebaf..a77147245a9 100644 --- a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/KotlinMultiplatformPluginLifecycle.kt +++ b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/KotlinMultiplatformPluginLifecycle.kt @@ -5,24 +5,79 @@ package org.jetbrains.kotlin.gradle.plugin +import kotlinx.coroutines.currentCoroutineContext import org.gradle.api.Project -import org.jetbrains.kotlin.gradle.plugin.KotlinMultiplatformPluginLifecycle.IllegalLifecycleException -import org.jetbrains.kotlin.gradle.plugin.KotlinMultiplatformPluginLifecycle.Stage +import org.gradle.api.provider.Property +import org.jetbrains.kotlin.gradle.plugin.KotlinMultiplatformPluginLifecycle.* import org.jetbrains.kotlin.gradle.utils.getOrPut +import java.lang.ref.WeakReference +import java.util.* import java.util.concurrent.atomic.AtomicBoolean +import kotlin.collections.ArrayDeque +import kotlin.collections.Map +import kotlin.collections.associateWith +import kotlin.collections.getValue +import kotlin.collections.set +import kotlin.collections.toList import kotlin.coroutines.* +import kotlin.reflect.KProperty + +/* +Util functions + */ + +internal fun Project.launch(block: suspend KotlinMultiplatformPluginLifecycle.() -> Unit) { + kotlinMultiplatformPluginLifecycle.launch(block) +} + +internal fun Project.launch(stage: Stage, block: suspend KotlinMultiplatformPluginLifecycle.() -> Unit) { + launch { + await(stage) + block() + } +} internal val Project.kotlinMultiplatformPluginLifecycle: KotlinMultiplatformPluginLifecycle - get() = extraProperties.getOrPut(KotlinMultiplatformPluginLifecycle::class.java.name) { KotlinMultiplatformPluginLifecycleImpl() } + get() = extraProperties.getOrPut(KotlinMultiplatformPluginLifecycle::class.java.name) { + KotlinMultiplatformPluginLifecycleImpl(project) + } internal fun Project.startKotlinMultiplatformPluginLifecycle() { - (kotlinMultiplatformPluginLifecycle as KotlinMultiplatformPluginLifecycleImpl).start(this) + (kotlinMultiplatformPluginLifecycle as KotlinMultiplatformPluginLifecycleImpl).start() } -internal inline fun Project.enqueue(stage: Stage, crossinline action: Project.() -> Unit) { - kotlinMultiplatformPluginLifecycle.enqueue(stage) { action() } +internal suspend fun currentMultiplatformPluginLifecycle(): KotlinMultiplatformPluginLifecycle { + return currentCoroutineContext()[KotlinMultiplatformPluginLifecycleCoroutineContextElement]?.lifecycle + ?: error("Missing $KotlinMultiplatformPluginLifecycleCoroutineContextElement in currentCoroutineContext") } +internal suspend fun await(stage: Stage) { + currentMultiplatformPluginLifecycle().await(stage) +} + +internal inline fun Project.newLifecycleAwareProperty( + finaliseIn: Stage, initialValue: T? = null +): LifecycleAwareProperty { + return kotlinMultiplatformPluginLifecycle.newLifecycleAwareProperty(T::class.java, finaliseIn, initialValue) +} + +internal suspend fun Property.findLifecycleAwareProperty(): LifecycleAwareProperty? { + return (currentMultiplatformPluginLifecycle() as KotlinMultiplatformPluginLifecycleImpl).findLifecycleAwareProperty(this) +} + +internal suspend fun Property.awaitFinalValue(): T? { + val lifecycleAwareProperty = findLifecycleAwareProperty() ?: throw IllegalArgumentException("Property is not lifecycle aware") + return lifecycleAwareProperty.awaitFinalValue() +} + +internal suspend fun Property<*>.isLifecycleAware(): Boolean { + return findLifecycleAwareProperty() != null +} + +/* +Definition of the Lifecycle and its stages + */ + internal interface KotlinMultiplatformPluginLifecycle { enum class Stage { Configure, @@ -39,25 +94,47 @@ internal interface KotlinMultiplatformPluginLifecycle { Finalised } + interface LifecycleAwareProperty { + val finaliseIn: Stage + val property: Property + suspend fun awaitFinalValue(): T? + operator fun getValue(thisRef: Any?, property: KProperty<*>): Property = this.property + } + + val project: Project + val stage: Stage - fun enqueue(stage: Stage, action: () -> Unit) + fun enqueue(stage: Stage, action: KotlinMultiplatformPluginLifecycle.() -> Unit) fun launch(block: suspend KotlinMultiplatformPluginLifecycle.() -> Unit) suspend fun await(stage: Stage) + fun newLifecycleAwareProperty( + type: Class, finaliseIn: Stage, initialValue: T? + ): LifecycleAwareProperty + class IllegalLifecycleException(message: String) : IllegalStateException(message) } -private class KotlinMultiplatformPluginLifecycleImpl : KotlinMultiplatformPluginLifecycle { + +/* +Implementation + */ + +private class KotlinMultiplatformPluginLifecycleImpl(override val project: Project) : KotlinMultiplatformPluginLifecycle { private val enqueuedStages: ArrayDeque = ArrayDeque(Stage.values().toList()) - private val enqueuedActions: Map Unit>> = Stage.values().associateWith { ArrayDeque() } + private val enqueuedActions: Map Unit>> = + Stage.values().associateWith { ArrayDeque() } + private var configureLoopRunning = AtomicBoolean(false) private var isStarted = AtomicBoolean(false) private var isFinished = AtomicBoolean(false) - fun start(project: Project) { + private val properties = WeakHashMap, WeakReference>>() + + fun start() { check(!isStarted.getAndSet(true)) { "${KotlinMultiplatformPluginLifecycle::class.java.name} already started" } @@ -76,7 +153,7 @@ private class KotlinMultiplatformPluginLifecycleImpl : KotlinMultiplatformPlugin val queue = enqueuedActions.getValue(stage) do { val action = queue.removeFirstOrNull() - action?.invoke() + action?.invoke(this) } while (action != null) val nextStage = enqueuedStages.removeFirstOrNull() ?: run { isFinished.set(true) @@ -95,7 +172,7 @@ private class KotlinMultiplatformPluginLifecycleImpl : KotlinMultiplatformPlugin val queue = enqueuedActions.getValue(Stage.Configure) do { val action = queue.removeFirstOrNull() - action?.invoke() + action?.invoke(this) } while (action != null) } finally { configureLoopRunning.set(false) @@ -104,7 +181,7 @@ private class KotlinMultiplatformPluginLifecycleImpl : KotlinMultiplatformPlugin override var stage: Stage = enqueuedStages.removeFirst() - override fun enqueue(stage: Stage, action: () -> Unit) { + override fun enqueue(stage: Stage, action: KotlinMultiplatformPluginLifecycle.() -> Unit) { if (stage < this.stage) { throw IllegalLifecycleException("Cannot enqueue Action for stage '${this.stage}' in current stage '${this.stage}'") } @@ -117,10 +194,14 @@ private class KotlinMultiplatformPluginLifecycleImpl : KotlinMultiplatformPlugin } override fun launch(block: suspend KotlinMultiplatformPluginLifecycle.() -> Unit) { + val lifecycle = this check(isStarted.get()) { "Cannot launch when ${KotlinMultiplatformPluginLifecycle::class.simpleName} is not started" } check(!isFinished.get()) { "Cannot launch when ${KotlinMultiplatformPluginLifecycle::class.simpleName} is already finished" } val coroutine = block.createCoroutine(this, object : Continuation { - override val context: CoroutineContext = EmptyCoroutineContext + + override val context: CoroutineContext = EmptyCoroutineContext + + KotlinMultiplatformPluginLifecycleCoroutineContextElement(lifecycle) + override fun resumeWith(result: Result) = result.getOrThrow() }) @@ -137,4 +218,36 @@ private class KotlinMultiplatformPluginLifecycleImpl : KotlinMultiplatformPlugin } } } + + override fun newLifecycleAwareProperty(type: Class, finaliseIn: Stage, initialValue: T?): LifecycleAwareProperty { + val property = project.objects.property(type) + if (initialValue != null) property.set(initialValue) + enqueue(finaliseIn) { property.finalizeValue() } + val lifecycleAwareProperty = LifecycleAwarePropertyImpl(finaliseIn, property) + properties[property] = WeakReference(lifecycleAwareProperty) + return lifecycleAwareProperty + } + + fun findLifecycleAwareProperty(property: Property): LifecycleAwareProperty? { + @Suppress("UNCHECKED_CAST") + return properties[property]?.get() as? LifecycleAwareProperty + } + + private class LifecycleAwarePropertyImpl( + override val finaliseIn: Stage, + override val property: Property + ) : LifecycleAwareProperty { + override suspend fun awaitFinalValue(): T? { + await(finaliseIn) + return property.orNull + } + } +} + +private class KotlinMultiplatformPluginLifecycleCoroutineContextElement( + val lifecycle: KotlinMultiplatformPluginLifecycle +) : CoroutineContext.Element { + companion object Key : CoroutineContext.Key + + override val key: CoroutineContext.Key = Key } diff --git a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/KotlinPluginWrapper.kt b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/KotlinPluginWrapper.kt index bdeac18371f..4e119987107 100644 --- a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/KotlinPluginWrapper.kt +++ b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/KotlinPluginWrapper.kt @@ -77,6 +77,8 @@ abstract class DefaultKotlinBasePlugin : KotlinBasePlugin { checkGradleCompatibility() + project.startKotlinMultiplatformPluginLifecycle() + project.gradle.projectsEvaluated { whenBuildEvaluated(project) } @@ -323,7 +325,6 @@ abstract class AbstractKotlinMultiplatformPluginWrapper : KotlinBasePluginWrappe override fun apply(project: Project) { super.apply(project) - project.startKotlinMultiplatformPluginLifecycle() project.runMultiplatformAndroidGradlePluginCompatibilityHealthCheckWhenAndroidIsApplied() } diff --git a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/mpp/KotlinMultiplatformPlugin.kt b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/mpp/KotlinMultiplatformPlugin.kt index d1e5fcdd11e..61148faf77d 100644 --- a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/mpp/KotlinMultiplatformPlugin.kt +++ b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/plugin/mpp/KotlinMultiplatformPlugin.kt @@ -214,12 +214,12 @@ internal fun applyUserDefinedAttributes(target: AbstractKotlinTarget) { // To copy the attributes to the output configurations, find those output configurations and their producing compilations // based on the target's components: val outputConfigurationsWithCompilations = target.kotlinComponents.filterIsInstance().flatMap { kotlinVariant -> - kotlinVariant.usages.mapNotNull { usageContext -> - project.configurations.findByName(usageContext.dependencyConfigurationName)?.let { configuration -> - configuration to usageContext.compilation - } + kotlinVariant.usages.mapNotNull { usageContext -> + project.configurations.findByName(usageContext.dependencyConfigurationName)?.let { configuration -> + configuration to usageContext.compilation } - }.toMutableList() + } + }.toMutableList() val mainCompilation = target.compilations.findByName(KotlinCompilation.MAIN_COMPILATION_NAME) val defaultTargetConfiguration = project.configurations.findByName(target.defaultConfigurationName) diff --git a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/metadata/KotlinMetadataTargetConfigurator.kt b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/metadata/KotlinMetadataTargetConfigurator.kt index 96fb7dac743..d513dc3ee5e 100644 --- a/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/metadata/KotlinMetadataTargetConfigurator.kt +++ b/libraries/tools/kotlin-gradle-plugin/src/common/kotlin/org/jetbrains/kotlin/gradle/targets/metadata/KotlinMetadataTargetConfigurator.kt @@ -175,7 +175,7 @@ class KotlinMetadataTargetConfigurator : private fun createMetadataCompilationsForCommonSourceSets( target: KotlinMetadataTarget, allMetadataJar: TaskProvider - ) = target.project.enqueue(KotlinMultiplatformPluginLifecycle.Stage.FinaliseCompilations) { + ) = target.project.launch(KotlinMultiplatformPluginLifecycle.Stage.FinaliseCompilations) { // Do this after all targets are configured by the user build script val publishedCommonSourceSets: Set = getCommonSourceSetsForMetadataCompilation(project) diff --git a/libraries/tools/kotlin-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/kotlin/gradle/unitTests/KotlinTargetHierarchyDescriptorTest.kt b/libraries/tools/kotlin-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/kotlin/gradle/unitTests/KotlinTargetHierarchyDescriptorTest.kt index a6b02d85e2b..2c12c348714 100644 --- a/libraries/tools/kotlin-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/kotlin/gradle/unitTests/KotlinTargetHierarchyDescriptorTest.kt +++ b/libraries/tools/kotlin-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/kotlin/gradle/unitTests/KotlinTargetHierarchyDescriptorTest.kt @@ -14,6 +14,7 @@ import org.jetbrains.kotlin.gradle.plugin.mpp.targetHierarchy.KotlinTargetHierar import org.jetbrains.kotlin.gradle.plugin.mpp.targetHierarchy.KotlinTargetHierarchy.Node import org.jetbrains.kotlin.gradle.plugin.mpp.targetHierarchy.buildKotlinTargetHierarchy import org.jetbrains.kotlin.gradle.util.buildProjectWithMPP +import org.jetbrains.kotlin.gradle.util.runLifecycleAwareTest import org.jetbrains.kotlin.gradle.util.main import org.junit.Test import kotlin.test.* @@ -24,7 +25,7 @@ class KotlinTargetHierarchyDescriptorTest { private val kotlin = project.multiplatformExtension @Test - fun `test - simple descriptor`() { + fun `test - simple descriptor`() = project.runLifecycleAwareTest { val descriptor = KotlinTargetHierarchyDescriptor { common { group("groupA") { withCompilations { it.target.name == "a" } } @@ -61,7 +62,7 @@ class KotlinTargetHierarchyDescriptorTest { } @Test - fun `test - extend`() { + fun `test - extend`() = project.runLifecycleAwareTest { val descriptor = KotlinTargetHierarchyDescriptor { group("base") }.extend { group("base") { group("extension") { @@ -82,7 +83,7 @@ class KotlinTargetHierarchyDescriptorTest { @Test - fun `test - extend - with new root`() { + fun `test - extend - with new root`() = project.runLifecycleAwareTest { val descriptor = KotlinTargetHierarchyDescriptor { group("base") }.extend { group("newRoot") { group("base") { @@ -107,7 +108,7 @@ class KotlinTargetHierarchyDescriptorTest { @Test - fun `test - extend - with two new roots and two extensions`() { + fun `test - extend - with two new roots and two extensions`() = project.runLifecycleAwareTest { val descriptor = KotlinTargetHierarchyDescriptor { group("base") } .extend { group("newRoot1") { @@ -163,7 +164,7 @@ class KotlinTargetHierarchyDescriptorTest { } @Test - fun `test - cycle`() { + fun `test - cycle`() = project.runLifecycleAwareTest { val descriptor = KotlinTargetHierarchyDescriptor { group("x") { // decoy 1 group("a") { @@ -193,7 +194,7 @@ class KotlinTargetHierarchyDescriptorTest { } @Test - fun `test - filterCompilations`() { + fun `test - filterCompilations`() = project.runLifecycleAwareTest { val descriptor = KotlinTargetHierarchyDescriptor { filterCompilations { it.name in setOf("a", "b") } common { @@ -223,7 +224,7 @@ class KotlinTargetHierarchyDescriptorTest { } @Test - fun `test - filterCompilations - include them again`() { + fun `test - filterCompilations - include them again`() = project.runLifecycleAwareTest { val descriptor = KotlinTargetHierarchyDescriptor { withCompilations { true } filterCompilations { it.name == "a" } diff --git a/libraries/tools/kotlin-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/kotlin/gradle/unitTests/KotlinTargetHierarchyDslTest.kt b/libraries/tools/kotlin-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/kotlin/gradle/unitTests/KotlinTargetHierarchyDslTest.kt index fb14cc4b0db..ca37ae646ce 100644 --- a/libraries/tools/kotlin-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/kotlin/gradle/unitTests/KotlinTargetHierarchyDslTest.kt +++ b/libraries/tools/kotlin-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/kotlin/gradle/unitTests/KotlinTargetHierarchyDslTest.kt @@ -183,7 +183,7 @@ class KotlinTargetHierarchyDslTest { } @Test - fun `test - hierarchy default - is only applied to main and test compilations`() { + fun `test - hierarchy default - is only applied to main and test compilations`() = project.runLifecycleAwareTest { assertNotNull(defaultKotlinTargetHierarchy.buildKotlinTargetHierarchy(kotlin.linuxX64().compilations.main)) assertNotNull(defaultKotlinTargetHierarchy.buildKotlinTargetHierarchy(kotlin.linuxX64().compilations.test)) assertNull(defaultKotlinTargetHierarchy.buildKotlinTargetHierarchy(kotlin.linuxX64().compilations.maybeCreate("custom"))) diff --git a/libraries/tools/kotlin-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/kotlin/gradle/unitTests/LifecycleAwarePropertyTest.kt b/libraries/tools/kotlin-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/kotlin/gradle/unitTests/LifecycleAwarePropertyTest.kt new file mode 100644 index 00000000000..80510358d2b --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/kotlin/gradle/unitTests/LifecycleAwarePropertyTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2010-2023 JetBrains s.r.o. and Kotlin Programming Language contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file. + */ + +@file:Suppress("FunctionName") + +package org.jetbrains.kotlin.gradle.unitTests + +import org.jetbrains.kotlin.gradle.plugin.* +import org.jetbrains.kotlin.gradle.plugin.KotlinMultiplatformPluginLifecycle.Stage.* +import org.jetbrains.kotlin.gradle.plugin.awaitFinalValue +import org.jetbrains.kotlin.gradle.plugin.currentMultiplatformPluginLifecycle +import org.jetbrains.kotlin.gradle.plugin.launch +import org.jetbrains.kotlin.gradle.plugin.newLifecycleAwareProperty +import org.jetbrains.kotlin.gradle.util.buildProjectWithMPP +import org.jetbrains.kotlin.gradle.util.runLifecycleAwareTest +import org.jetbrains.kotlin.gradle.utils.newProperty +import org.junit.Assert.assertFalse +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class LifecycleAwarePropertyTest { + private val project = buildProjectWithMPP() + + @Test + fun `test - awaitFinalValue`() = project.runLifecycleAwareTest { + val property by project.newLifecycleAwareProperty(AfterFinaliseRefinesEdges) + assertTrue(property.isLifecycleAware()) + + launch(BeforeFinaliseRefinesEdges) { + property.set(1) + } + + launch(FinaliseRefinesEdges) { + assertEquals(1, property.get()) + property.set(2) + } + + assertEquals(Configure, currentMultiplatformPluginLifecycle().stage) + assertEquals(2, property.awaitFinalValue()) + assertEquals(AfterFinaliseRefinesEdges, currentMultiplatformPluginLifecycle().stage) + } + + @Test + fun `test - awaitFinalValue - on non lifecycle aware property`() = project.runLifecycleAwareTest { + val property = project.newProperty() + assertFalse(property.isLifecycleAware()) + assertFailsWith { property.awaitFinalValue() } + } + + @Test + fun `test - changing value after finalized`() = project.runLifecycleAwareTest { + val property by project.newLifecycleAwareProperty(AfterEvaluate) + property.set(1) + + launch(AfterEvaluate) { + assertFailsWith { property.set(2) } + } + } +} diff --git a/libraries/tools/kotlin-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/kotlin/gradle/util/lifecycleUtils.kt b/libraries/tools/kotlin-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/kotlin/gradle/util/lifecycleUtils.kt new file mode 100644 index 00000000000..fbada7b518a --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/kotlin/gradle/util/lifecycleUtils.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2010-2023 JetBrains s.r.o. and Kotlin Programming Language contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file. + */ + +package org.jetbrains.kotlin.gradle.util + +import org.gradle.api.Project +import org.gradle.api.internal.project.ProjectInternal +import org.jetbrains.kotlin.gradle.plugin.kotlinMultiplatformPluginLifecycle + +fun Project.runLifecycleAwareTest(block: suspend Project.() -> Unit) { + kotlinMultiplatformPluginLifecycle.launch { block() } + (this as ProjectInternal).evaluate() +} \ No newline at end of file