From 6ac21856cc9ce3f1af2fcd5c7d3fe3ea92f2b88f Mon Sep 17 00:00:00 2001 From: Sergey Rostov Date: Wed, 15 Jan 2020 12:23:23 +0300 Subject: [PATCH] NodeJsRootExtension, YarnRootExtension: introduce configuration phase Some properties were calculated based on yarn and nodeJs version. So, when version is changed properties were not updated. This change moves calculation to the end of configuration phase and adds assertion to ensure that version and other poperties are not changed after configuration was done. --- .../internal/ConfigurationPhaseAware.kt | 37 ++++++++++ .../gradle/targets/js/nodejs/NodeJsEnv.kt | 4 +- .../gradle/targets/js/nodejs/NodeJsExec.kt | 2 +- .../targets/js/nodejs/NodeJsRootExtension.kt | 72 ++++++++++--------- .../targets/js/nodejs/NodeJsRootPlugin.kt | 6 +- .../targets/js/nodejs/NodeJsSetupTask.kt | 2 +- .../gradle/targets/js/npm/NpmProject.kt | 2 +- .../gradle/targets/js/testing/KotlinJsTest.kt | 2 +- .../gradle/targets/js/yarn/YarnBasics.kt | 6 +- .../kotlin/gradle/targets/js/yarn/YarnEnv.kt | 4 +- .../gradle/targets/js/yarn/YarnPlugin.kt | 5 +- .../targets/js/yarn/YarnRootExtension.kt | 30 ++++---- .../gradle/targets/js/yarn/YarnSetupTask.kt | 2 +- .../kotlin/gradle/tasks/CleanDataTask.kt | 2 +- 14 files changed, 109 insertions(+), 67 deletions(-) create mode 100644 libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/internal/ConfigurationPhaseAware.kt diff --git a/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/internal/ConfigurationPhaseAware.kt b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/internal/ConfigurationPhaseAware.kt new file mode 100644 index 00000000000..95b78234f0a --- /dev/null +++ b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/internal/ConfigurationPhaseAware.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2010-2020 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.internal + +import kotlin.reflect.KProperty + +abstract class ConfigurationPhaseAware { + + private var configured: C? = null + + @Synchronized + fun requireConfigured(): C { + if (configured == null) { + configured = finalizeConfiguration() + } + + return configured!! + } + + protected fun requireNotConfigured() { + check(configured == null) { "Configuration already finalized for previous property values" } + } + + inner class Property(var value: T) { + operator fun getValue(receiver: Any, property: KProperty<*>): T = value + + operator fun setValue(receiver: Any, property: KProperty<*>, value: T) { + requireNotConfigured() + this.value = value + } + } + + protected abstract fun finalizeConfiguration(): C +} \ No newline at end of file diff --git a/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/nodejs/NodeJsEnv.kt b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/nodejs/NodeJsEnv.kt index f21d9d55621..04b521e96c1 100644 --- a/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/nodejs/NodeJsEnv.kt +++ b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/nodejs/NodeJsEnv.kt @@ -1,8 +1,10 @@ package org.jetbrains.kotlin.gradle.targets.js.nodejs +import org.jetbrains.kotlin.gradle.tasks.internal.CleanableStore import java.io.File -internal data class NodeJsEnv( +data class NodeJsEnv( + val cleanableStore: CleanableStore, val nodeDir: File, val nodeBinDir: File, val nodeExecutable: String, diff --git a/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/nodejs/NodeJsExec.kt b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/nodejs/NodeJsExec.kt index e21c4ffc7b4..68d3c73362a 100644 --- a/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/nodejs/NodeJsExec.kt +++ b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/nodejs/NodeJsExec.kt @@ -72,7 +72,7 @@ open class NodeJsExec : AbstractExecTask(NodeJsExec::class.java), Re return project.registerTask(name) { it.nodeJs = nodeJs it.compilation = compilation - it.executable = nodeJs.environment.nodeExecutable + it.executable = nodeJs.requireConfigured().nodeExecutable it.dependsOn(nodeJs.npmInstallTask) val compileKotlinTask = compilation.compileKotlinTask diff --git a/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/nodejs/NodeJsRootExtension.kt b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/nodejs/NodeJsRootExtension.kt index 4d762ca67a2..43b8cdfb24e 100644 --- a/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/nodejs/NodeJsRootExtension.kt +++ b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/nodejs/NodeJsRootExtension.kt @@ -1,6 +1,7 @@ package org.jetbrains.kotlin.gradle.targets.js.nodejs import org.gradle.api.Project +import org.jetbrains.kotlin.gradle.internal.ConfigurationPhaseAware import org.jetbrains.kotlin.gradle.logging.kotlinInfo import org.jetbrains.kotlin.gradle.plugin.PropertiesProvider import org.jetbrains.kotlin.gradle.targets.js.NpmVersions @@ -11,7 +12,7 @@ import org.jetbrains.kotlin.gradle.targets.js.yarn.Yarn import org.jetbrains.kotlin.gradle.tasks.internal.CleanableStore import java.io.File -open class NodeJsRootExtension(val rootProject: Project) { +open class NodeJsRootExtension(val rootProject: Project) : ConfigurationPhaseAware() { init { check(rootProject.rootProject == rootProject) } @@ -20,16 +21,16 @@ open class NodeJsRootExtension(val rootProject: Project) { rootProject.logger.kotlinInfo("Storing cached files in $it") } - var installationDir = gradleHome.resolve("nodejs") - var cleanableStore = CleanableStore[installationDir.absolutePath] + var installationDir by Property(gradleHome.resolve("nodejs")) - var download = true - var nodeDownloadBaseUrl = "https://nodejs.org/dist" - var nodeVersion = "12.14.0" + var download by Property(true) - var nodeCommand = "node" + var nodeDownloadBaseUrl by Property("https://nodejs.org/dist") + var nodeVersion by Property("12.14.0") - var packageManager: NpmApi = Yarn + var nodeCommand by Property("node") + + var packageManager: NpmApi by Property(Yarn) private val projectProperties = PropertiesProvider(rootProject) @@ -61,38 +62,39 @@ open class NodeJsRootExtension(val rootProject: Project) { val nodeModulesGradleCacheDir: File get() = rootPackageDir.resolve("packages_imported") - internal val environment: NodeJsEnv - get() { - val platform = NodeJsPlatform.name - val architecture = NodeJsPlatform.architecture + override fun finalizeConfiguration(): NodeJsEnv { + val platform = NodeJsPlatform.name + val architecture = NodeJsPlatform.architecture - val nodeDirName = "node-v$nodeVersion-$platform-$architecture" - val nodeDir = cleanableStore[nodeDirName].use() - val isWindows = NodeJsPlatform.name == NodeJsPlatform.WIN - val nodeBinDir = if (isWindows) nodeDir else nodeDir.resolve("bin") + val nodeDirName = "node-v$nodeVersion-$platform-$architecture" + val cleanableStore = CleanableStore[installationDir.absolutePath] + val nodeDir = cleanableStore[nodeDirName].use() + val isWindows = NodeJsPlatform.name == NodeJsPlatform.WIN + val nodeBinDir = if (isWindows) nodeDir else nodeDir.resolve("bin") - fun getExecutable(command: String, customCommand: String, windowsExtension: String): String { - val finalCommand = if (isWindows && customCommand == command) "$command.$windowsExtension" else customCommand - return if (download) File(nodeBinDir, finalCommand).absolutePath else finalCommand - } - - fun getIvyDependency(): String { - val type = if (isWindows) "zip" else "tar.gz" - return "org.nodejs:node:$nodeVersion:$platform-$architecture@$type" - } - - return NodeJsEnv( - nodeDir = nodeDir, - nodeBinDir = nodeBinDir, - nodeExecutable = getExecutable("node", nodeCommand, "exe"), - platformName = platform, - architectureName = architecture, - ivyDependency = getIvyDependency() - ) + fun getExecutable(command: String, customCommand: String, windowsExtension: String): String { + val finalCommand = if (isWindows && customCommand == command) "$command.$windowsExtension" else customCommand + return if (download) File(nodeBinDir, finalCommand).absolutePath else finalCommand } + fun getIvyDependency(): String { + val type = if (isWindows) "zip" else "tar.gz" + return "org.nodejs:node:$nodeVersion:$platform-$architecture@$type" + } + + return NodeJsEnv( + cleanableStore = cleanableStore, + nodeDir = nodeDir, + nodeBinDir = nodeBinDir, + nodeExecutable = getExecutable("node", nodeCommand, "exe"), + platformName = platform, + architectureName = architecture, + ivyDependency = getIvyDependency() + ) + } + internal fun executeSetup() { - val nodeJsEnv = environment + val nodeJsEnv = requireConfigured() if (download) { if (!nodeJsEnv.nodeBinDir.isDirectory) { nodeJsSetupTask.exec() diff --git a/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/nodejs/NodeJsRootPlugin.kt b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/nodejs/NodeJsRootPlugin.kt index 9619478fa9e..a9651e3017d 100644 --- a/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/nodejs/NodeJsRootPlugin.kt +++ b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/nodejs/NodeJsRootPlugin.kt @@ -22,14 +22,14 @@ open class NodeJsRootPlugin : Plugin { it.description = "Download and install a local node/npm version" } - tasks.create(KotlinNpmInstallTask.NAME, KotlinNpmInstallTask::class.java) { + tasks.register(KotlinNpmInstallTask.NAME, KotlinNpmInstallTask::class.java) { it.dependsOn(setupTask) it.group = TASKS_GROUP_NAME it.description = "Find, download and link NPM dependencies and projects" } - tasks.create("node" + CleanDataTask.NAME, CleanDataTask::class.java) { - it.cleanableStore = settings.cleanableStore + tasks.register("node" + CleanDataTask.NAME_SUFFIX, CleanDataTask::class.java) { + it.cleanableStore = settings.requireConfigured().cleanableStore it.group = TASKS_GROUP_NAME it.description = "Clean unused local node version" } diff --git a/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/nodejs/NodeJsSetupTask.kt b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/nodejs/NodeJsSetupTask.kt index 16bf3ab0d9a..061d6c5b4f9 100644 --- a/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/nodejs/NodeJsSetupTask.kt +++ b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/nodejs/NodeJsSetupTask.kt @@ -15,7 +15,7 @@ import java.net.URI @CacheableTask open class NodeJsSetupTask : DefaultTask() { private val settings get() = NodeJsRootPlugin.apply(project.rootProject) - private val env by lazy { settings.environment } + private val env by lazy { settings.requireConfigured() } val ivyDependency: String @Input get() = env.ivyDependency diff --git a/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/npm/NpmProject.kt b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/npm/NpmProject.kt index 95450898369..a2c22978c0f 100644 --- a/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/npm/NpmProject.kt +++ b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/npm/NpmProject.kt @@ -70,7 +70,7 @@ open class NpmProject(val compilation: KotlinJsCompilation) { fun useTool(exec: ExecSpec, tool: String, vararg args: String) { exec.workingDir = dir - exec.executable = nodeJs.environment.nodeExecutable + exec.executable = nodeJs.requireConfigured().nodeExecutable exec.args = listOf(require(tool)) + args } diff --git a/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/testing/KotlinJsTest.kt b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/testing/KotlinJsTest.kt index 527ef92480e..91af08a9abf 100644 --- a/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/testing/KotlinJsTest.kt +++ b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/testing/KotlinJsTest.kt @@ -110,7 +110,7 @@ open class KotlinJsTest : KotlinTest(), RequiresNpmDependencies { override fun createTestExecutionSpec(): TCServiceMessagesTestExecutionSpec { val forkOptions = DefaultProcessForkOptions(fileResolver) forkOptions.workingDir = compilation.npmProject.dir - forkOptions.executable = nodeJs.environment.nodeExecutable + forkOptions.executable = nodeJs.requireConfigured().nodeExecutable val nodeJsArgs = mutableListOf() diff --git a/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/yarn/YarnBasics.kt b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/yarn/YarnBasics.kt index 3ca92fe9f22..4a39610f065 100644 --- a/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/yarn/YarnBasics.kt +++ b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/yarn/YarnBasics.kt @@ -24,13 +24,11 @@ abstract class YarnBasics : NpmApi { vararg args: String ) { val nodeJs = NodeJsRootPlugin.apply(project) - val nodeJsEnv = nodeJs.environment val yarnPlugin = YarnPlugin.apply(project) - val yarnEnv = yarnPlugin.environment project.execWithProgress(description) { exec -> - exec.executable = nodeJsEnv.nodeExecutable - exec.args = listOf(yarnEnv.home.resolve("bin/yarn.js").absolutePath) + + exec.executable = nodeJs.requireConfigured().nodeExecutable + exec.args = listOf(yarnPlugin.requireConfigured().home.resolve("bin/yarn.js").absolutePath) + args + if (project.logger.isDebugEnabled) "--verbose" else "" exec.workingDir = dir diff --git a/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/yarn/YarnEnv.kt b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/yarn/YarnEnv.kt index 991eddca7a4..e1f23b8f694 100644 --- a/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/yarn/YarnEnv.kt +++ b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/yarn/YarnEnv.kt @@ -5,9 +5,11 @@ package org.jetbrains.kotlin.gradle.targets.js.yarn +import org.jetbrains.kotlin.gradle.tasks.internal.CleanableStore import java.io.File -internal data class YarnEnv( +data class YarnEnv( val downloadUrl: String, + val cleanableStore: CleanableStore, val home: File ) diff --git a/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/yarn/YarnPlugin.kt b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/yarn/YarnPlugin.kt index 22029732370..9f2643a134a 100644 --- a/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/yarn/YarnPlugin.kt +++ b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/yarn/YarnPlugin.kt @@ -9,7 +9,6 @@ import org.gradle.api.Plugin import org.gradle.api.Project import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootPlugin import org.jetbrains.kotlin.gradle.tasks.CleanDataTask -import org.jetbrains.kotlin.gradle.tasks.internal.CleanableStore open class YarnPlugin : Plugin { override fun apply(project: Project): Unit = project.run { @@ -25,8 +24,8 @@ open class YarnPlugin : Plugin { it.dependsOn(nodeJs.nodeJsSetupTask) } - tasks.create("yarn" + CleanDataTask.NAME, CleanDataTask::class.java) { - it.cleanableStore = yarnRootExtension.cleanableStore + tasks.create("yarn" + CleanDataTask.NAME_SUFFIX, CleanDataTask::class.java) { + it.cleanableStore = yarnRootExtension.requireConfigured().cleanableStore it.description = "Clean unused local yarn version" } } diff --git a/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/yarn/YarnRootExtension.kt b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/yarn/YarnRootExtension.kt index 7eb054d5dc1..4dd89a128a4 100644 --- a/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/yarn/YarnRootExtension.kt +++ b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/yarn/YarnRootExtension.kt @@ -6,11 +6,12 @@ package org.jetbrains.kotlin.gradle.targets.js.yarn import org.gradle.api.Project +import org.jetbrains.kotlin.gradle.internal.ConfigurationPhaseAware import org.jetbrains.kotlin.gradle.logging.kotlinInfo import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootPlugin import org.jetbrains.kotlin.gradle.tasks.internal.CleanableStore -open class YarnRootExtension(val project: Project) { +open class YarnRootExtension(val project: Project) : ConfigurationPhaseAware() { init { check(project == project.rootProject) } @@ -19,32 +20,33 @@ open class YarnRootExtension(val project: Project) { project.logger.kotlinInfo("Storing cached files in $it") } - var installationDir = gradleHome.resolve("yarn") + var installationDir by Property(gradleHome.resolve("yarn")) - // TODO: Split configuration and execution phases to support changed installationDir - val cleanableStore = CleanableStore[installationDir.path] - - var downloadBaseUrl = "https://github.com/yarnpkg/yarn/releases/download" - var version = "1.21.1" + var downloadBaseUrl by Property("https://github.com/yarnpkg/yarn/releases/download") + var version by Property("1.21.1") val yarnSetupTask: YarnSetupTask get() = project.tasks.getByName(YarnSetupTask.NAME) as YarnSetupTask - var disableWorkspaces: Boolean = false + var disableWorkspaces: Boolean by Property(false) val useWorkspaces: Boolean get() = !disableWorkspaces - // TODO: Split configuration and execution phases to support changed settings - internal val environment = YarnEnv( - downloadUrl = "$downloadBaseUrl/v$version/yarn-v$version.tar.gz", - home = cleanableStore["yarn-v$version"].use() - ) + override fun finalizeConfiguration(): YarnEnv { + val cleanableStore = CleanableStore[installationDir.path] + + return YarnEnv( + downloadUrl = "$downloadBaseUrl/v$version/yarn-v$version.tar.gz", + cleanableStore = cleanableStore, + home = cleanableStore["yarn-v$version"].use() + ) + } internal fun executeSetup() { NodeJsRootPlugin.apply(project).executeSetup() - if (!environment.home.isDirectory) { + if (!finalizeConfiguration().home.isDirectory) { yarnSetupTask.setup() } } diff --git a/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/yarn/YarnSetupTask.kt b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/yarn/YarnSetupTask.kt index 066fbcabbfa..7591b391b46 100644 --- a/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/yarn/YarnSetupTask.kt +++ b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/targets/js/yarn/YarnSetupTask.kt @@ -17,7 +17,7 @@ import java.io.File @CacheableTask open class YarnSetupTask : DefaultTask() { private val settings = project.yarn - private val env by lazy { settings.environment } + private val env by lazy { settings.requireConfigured() } @Suppress("MemberVisibilityCanBePrivate") val downloadUrl diff --git a/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/tasks/CleanDataTask.kt b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/tasks/CleanDataTask.kt index 2a2304cc021..e5a53d3e0f9 100644 --- a/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/tasks/CleanDataTask.kt +++ b/libraries/tools/kotlin-gradle-plugin/src/main/kotlin/org/jetbrains/kotlin/gradle/tasks/CleanDataTask.kt @@ -41,7 +41,7 @@ open class CleanDataTask : DefaultTask() { } companion object { - const val NAME: String = "KotlinClean" + const val NAME_SUFFIX: String = "KotlinClean" } } \ No newline at end of file