Gradle: npm integration using yarn

#KT-31018 Fixed
This commit is contained in:
Sergey Rostov
2019-04-09 10:31:01 +03:00
parent 69f9e30e5a
commit a8799f5a8d
35 changed files with 2019 additions and 159 deletions
@@ -43,6 +43,9 @@ interface KotlinDependencyHandler {
project(listOf("path", "configuration").zip(listOfNotNull(path, configuration)).toMap())
fun project(notation: Map<String, Any?>): ProjectDependency
fun npm(packageName: String, version: String = "*"): Dependency?
fun npm(org: String, packageName: String, version: String = "*"): Dependency?
}
interface HasKotlinDependencies {
@@ -46,6 +46,7 @@ dependencies {
compileOnly(project(":kotlin-scripting-compiler-impl"))
compile("com.google.code.gson:gson:${rootProject.extra["versions.jar.gson"]}")
compile("de.undercouch:gradle-download-task:3.4.3")
compileOnly("com.android.tools.build:gradle:2.0.0")
compileOnly("com.android.tools.build:gradle-core:2.0.0")
@@ -0,0 +1,131 @@
/*
* Copyright 2010-2019 JetBrains s.r.o. 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 com.google.gson.GsonBuilder
import com.google.gson.JsonParseException
import org.gradle.api.Project
import org.gradle.api.internal.project.ProjectInternal
import org.gradle.internal.hash.FileHasher
import org.jetbrains.kotlin.daemon.common.toHexString
import java.io.File
/**
* Cache for preventing processing some files twice.
* Files are precessed some subdirectories of [targetDir] called `target`.
* For each target [getOrCompute] should be called.
* Absent target directories will be deleted on [close].
* `compute` will be called only for source files that was changed since last processing.
*
* See [org.jetbrains.kotlin.gradle.targets.js.npm.GradleNodeModuleBuilder] for example.
*
* @param version When updating logic in `compute`, `version` should be increased to invalidate cache
*/
internal class ProcessedFilesCache(
val project: Project,
val targetDir: File,
stateFileName: String,
val version: String
) : AutoCloseable {
private val hasher = (project as ProjectInternal).services.get(FileHasher::class.java)
private val gson = GsonBuilder().setPrettyPrinting().create()
private class State {
var version: String? = null
val visited: MutableMap<String, Element> = mutableMapOf()
operator fun get(elementHash: ByteArray) =
visited[elementHash.toHexString()]
operator fun set(elementHash: ByteArray, element: Element) {
visited[elementHash.toHexString()] = element
}
}
class Element(
val src: String,
val target: String?
)
private val stateFile = targetDir.resolve(stateFileName)
private val old: State
init {
targetDir.mkdirs()
val state: State? = if (stateFile.exists()) try {
stateFile.reader().use {
gson.fromJson(it, State::class.java)
}.let {
if (it.version != version) null else it
}
} catch (e: JsonParseException) {
project.logger.warn("Cannot read $stateFile", e)
if (targetDir.exists()) {
targetDir.deleteRecursively()
}
null
} else null
old = state ?: State()
}
private val new = State()
init {
new.version = version
}
internal fun getOrCompute(
file: File,
compute: () -> String?
) {
val hash = hasher.hash(file).toByteArray()
val old = old[hash]
if (old != null) {
new[hash] = old
} else {
val key = compute()
new[hash] = Element(file.canonicalPath, key)
}
}
fun deleteTarget(key: String) {
targetDir.resolve(key).deleteRecursively()
}
val targets: Collection<String>
get() = new.visited.mapNotNullTo(mutableSetOf()) { it.value.target }
private fun getDeletedTargets(): MutableSet<String> {
val result = mutableSetOf<String>()
old.visited.forEach {
val target = it.value.target
if (target != null) {
result.add(target)
}
}
new.visited.forEach {
val target = it.value.target
if (target != null) {
result.remove(target)
}
}
return result
}
override fun close() {
getDeletedTargets().forEach {
deleteTarget(it)
}
stateFile.writer().use {
gson.toJson(new, it)
}
}
}
@@ -6,6 +6,7 @@ import org.gradle.api.artifacts.ExternalModuleDependency
import org.gradle.api.artifacts.ProjectDependency
import org.jetbrains.kotlin.gradle.plugin.HasKotlinDependencies
import org.jetbrains.kotlin.gradle.plugin.KotlinDependencyHandler
import org.jetbrains.kotlin.gradle.targets.js.npm.NpmDependency
class DefaultKotlinDependencyHandler(
val parent: HasKotlinDependencies,
@@ -77,4 +78,10 @@ class DefaultKotlinDependencyHandler(
configure(it)
project.dependencies.add(configurationName, it)
}
override fun npm(packageName: String, version: String) =
NpmDependency(project, null, packageName, version)
override fun npm(org: String, packageName: String, version: String) =
NpmDependency(project, org, packageName, version)
}
@@ -1,12 +1,9 @@
package org.jetbrains.kotlin.gradle.targets.js
import org.gradle.api.file.FileCollection
import org.gradle.language.base.plugins.LifecycleBasePlugin
import org.jetbrains.kotlin.gradle.plugin.KotlinCompilationToRunnableFiles
import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsExtension
import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsPlugin
import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsSetupTask
import org.jetbrains.kotlin.gradle.targets.js.tasks.*
import org.jetbrains.kotlin.gradle.targets.js.testing.KotlinJsTest
import org.jetbrains.kotlin.gradle.tasks.Kotlin2JsCompile
import org.jetbrains.kotlin.gradle.tasks.registerTask
import org.jetbrains.kotlin.gradle.testing.internal.configureConventions
@@ -36,81 +33,28 @@ internal class KotlinJsCompilationTestsConfigurator(
return components.first() + components.drop(1).joinToString("") { it.capitalize() }
}
@Suppress("SameParameterValue")
private fun disambiguateUnderscored(name: String, includeCompilation: Boolean) =
disambiguate(name, includeCompilation).joinToString("_")
private val testTaskName: String
get() = disambiguateCamelCased("test", false)
private val nodeModulesDir
get() = project.buildDir.resolve(disambiguateUnderscored("node_modules", true))
private val compileTask: Kotlin2JsCompile
get() = project.tasks.findByName(compileTestKotlin2Js.name) as Kotlin2JsCompile
private val Kotlin2JsCompile.jsRuntimeClasspath: FileCollection
get() = classpath.plus(project.files(destinationDir))
fun configure() {
compilation.dependencies {
implementation(kotlin("test-nodejs-runner"))
}
// apply plugin (cannot do it lazy)
val nodeJs = NodeJsPlugin.apply(target.project)
val nodeModulesTask = registerTask(
project,
disambiguateCamelCased("nodeModules", true),
KotlinJsNodeModulesTask::class.java
) {
it.dependsOn(compileTestKotlin2Js)
it.onlyIf {
compileTestKotlin2Js.outputFile.exists()
}
it.nodeModulesDir = nodeModulesDir
it.classpath = compileTask.jsRuntimeClasspath
}
val projectWithNodeJsPlugin = NodeJsPlugin.ensureAppliedInHierarchy(target.project)
val testTask = registerTask(project, testTaskName, KotlinNodeJsTestTask::class.java) { testJs ->
registerTask(project, testTaskName, KotlinJsTest::class.java) { testJs ->
testJs.group = LifecycleBasePlugin.VERIFICATION_GROUP
testJs.dependsOn(nodeModulesTask.getTaskOrProvider())
testJs.dependsOn(compileTestKotlin2Js, nodeJs.nodeJsSetupTask)
testJs.onlyIf {
compileTestKotlin2Js.outputFile.exists()
}
if (disambiguationClassifier != null) {
testJs.targetName = disambiguationClassifier
testJs.showTestTargetName = true
}
testJs.nodeJsProcessOptions.workingDir = project.projectDir
testJs.nodeModulesDir = nodeModulesDir
testJs.testRuntimeNodeModules = listOf(
"kotlin-test-nodejs-runner.js",
"kotlin-nodejs-source-map-support.js"
)
testJs.nodeModulesToLoad = setOf(compileTestKotlin2Js.outputFile.name)
testJs.runtimeDependencyHandler = compilation
testJs.targetName = disambiguationClassifier
testJs.nodeModulesToLoad.add(compileTestKotlin2Js.outputFile.name)
testJs.configureConventions()
registerTestTask(testJs)
}
project.afterEvaluate {
// defer nodeJs executable setup, as nodejs project settings may change during configuration
testTask.configure {
val nodeJsSetupTask = projectWithNodeJsPlugin.tasks.findByName(NodeJsSetupTask.NAME)
it.dependsOn(nodeJsSetupTask)
if (it.nodeJsProcessOptions.executable == null) {
it.nodeJsProcessOptions.executable = NodeJsExtension[projectWithNodeJsPlugin].buildEnv().nodeExec
}
}
}
}
}
@@ -8,6 +8,8 @@ package org.jetbrains.kotlin.gradle.targets.js
import org.jetbrains.kotlin.gradle.plugin.*
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinJsCompilation
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinOnlyTarget
import org.jetbrains.kotlin.gradle.targets.js.npm.NpmProject
import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpack
import org.jetbrains.kotlin.gradle.tasks.KotlinTasksProvider
class KotlinJsTargetConfigurator(kotlinPluginVersion: String) :
@@ -23,13 +25,26 @@ class KotlinJsTargetConfigurator(kotlinPluginVersion: String) :
platformTarget.compilations.all {
it.compileKotlinTask.kotlinOptions {
moduleKind = "umd"
moduleKind = "commonjs"
sourceMap = true
sourceMapEmbedSources = null
}
}
}
override fun configureTest(target: KotlinOnlyTarget<KotlinJsCompilation>) {
val project = target.project
val npmProject = NpmProject[project]
target.compilations.all { compilation ->
if (isTestCompilation(compilation)) {
KotlinJsCompilationTestsConfigurator(compilation).configure()
} else {
KotlinWebpack.configure(compilation)
}
}
}
companion object {
internal fun isTestCompilation(it: KotlinJsCompilation) =
it.name == KotlinCompilation.TEST_COMPILATION_NAME
@@ -142,7 +142,11 @@ open class RewriteSourceMapFilterReader(
}
protected open fun transformString(value: String): String =
File(srcSourceRoot).resolve(value).canonicalFile.relativeToOrSelf(File(targetSourceRoot)).path
File(srcSourceRoot)
.resolve(value)
.canonicalFile
.relativeToOrSelf(File(targetSourceRoot))
.path
override fun read(): Int {
maybeReadFirst()
@@ -1,16 +1,16 @@
package org.jetbrains.kotlin.gradle.targets.js.nodejs
import java.io.*
import java.io.File
internal data class NodeJsEnv(
val nodeDir: File,
val nodeBinDir: File,
val nodeExec: String,
val npmExec: String,
val nodeDir: File,
val nodeBinDir: File,
val nodeExecutable: String,
val platformName: String,
val architectureName: String,
val ivyDependency: String
val platformName: String,
val architectureName: String,
val ivyDependency: String
) {
val isWindows: Boolean
get() = platformName == "win"
@@ -1,68 +1,31 @@
/*
* Copyright 2010-2019 JetBrains s.r.o. 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.targets.js.nodejs
import org.gradle.api.GradleException
import org.gradle.api.Project
import org.jetbrains.kotlin.gradle.logging.kotlinInfo
import java.io.File
import org.jetbrains.kotlin.gradle.targets.js.npm.PackageJson
open class NodeJsExtension(project: Project) {
private val gradleHome = project.gradle.gradleUserHomeDir.also {
project.logger.kotlinInfo("Storing cached files in $it")
}
open class NodeJsExtension(val project: Project) {
val root: NodeJsRootExtension
get() = this as? NodeJsRootExtension ?: NodeJsRootExtension[project]
var installationDir = gradleHome.resolve("nodejs")
internal val packageJsonHandlers = mutableListOf<PackageJson.() -> Unit>()
var distBaseUrl = "https://nodejs.org/dist"
var version = "10.15.3"
var npmVersion = ""
var nodeCommand = "node"
var npmCommand = "npm"
var download = true
internal fun buildEnv(): NodeJsEnv {
val platform = NodeJsPlatform.name
val architecture = NodeJsPlatform.architecture
val nodeDir = installationDir.resolve("node-v$version-$platform-$architecture")
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:$version:$platform-$architecture@$type"
}
return NodeJsEnv(
nodeDir = nodeDir,
nodeBinDir = nodeBinDir,
nodeExec = getExecutable("node", nodeCommand, "exe"),
npmExec = getExecutable("npm", npmCommand, "cmd"),
platformName = platform,
architectureName = architecture,
ivyDependency = getIvyDependency()
)
@Suppress("unused")
fun packageJson(handler: PackageJson.() -> Unit) {
packageJsonHandlers.add(handler)
}
companion object {
const val NODE_JS: String = "nodeJs"
operator fun get(project: Project): NodeJsExtension {
val extension = project.extensions.findByType(NodeJsExtension::class.java)
if (extension != null)
return extension
val parentProject = project.parent
if (parentProject != null)
return get(parentProject)
throw GradleException("NodeJsExtension is not installed")
NodeJsPlugin.apply(project.rootProject)
return project.extensions.getByName(NodeJsRootExtension.NODE_JS) as NodeJsExtension
}
}
}
val Project.nodeJs: NodeJsExtension
get() = NodeJsExtension[this]
@@ -2,7 +2,7 @@ package org.jetbrains.kotlin.gradle.targets.js.nodejs
/**
* Provides platform and architecture names that is used to download NodeJs.
* See [NodeJsEnv.ivyDependency] that is filled in [NodeJsExtension.buildEnv].
* See [NodeJsEnv.ivyDependency] that is filled in [NodeJsRootExtension.environment].
*/
internal object NodeJsPlatform {
private val props = System.getProperties()
@@ -2,28 +2,46 @@ package org.jetbrains.kotlin.gradle.targets.js.nodejs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsExtension.Companion.NODE_JS
import org.gradle.api.plugins.BasePlugin
import org.gradle.api.tasks.Delete
import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootExtension.Companion.NODE_JS
open class NodeJsPlugin : Plugin<Project> {
override fun apply(project: Project): Unit = project.run {
this.extensions.create(NODE_JS, NodeJsExtension::class.java, this)
check(project == project.rootProject) {
"NodeJsPlugin can be applied only to root project"
}
this.extensions.create(NODE_JS, NodeJsRootExtension::class.java, this)
tasks.create(NodeJsSetupTask.NAME, NodeJsSetupTask::class.java)
setupCleanNodeModulesTask(project)
allprojects {
if (it != project) {
it.extensions.create(NODE_JS, NodeJsExtension::class.java, this)
}
}
}
private fun setupCleanNodeModulesTask(project: Project) {
project.tasks.create("cleanNodeModules", Delete::class.java) {
it.description = "Deletes node_modules and package.json file"
it.group = BasePlugin.BUILD_GROUP
it.doLast {
project.nodeJs.root.packageManager.cleanProject(project)
}
project.tasks.maybeCreate(BasePlugin.CLEAN_TASK_NAME).dependsOn(it)
}
}
companion object {
fun ensureAppliedInHierarchy(myProject: Project): Project =
findInHeirarchy(myProject) ?: myProject.also {
it.pluginManager.apply(NodeJsPlugin::class.java)
}
fun findInHeirarchy(myProject: Project): Project? {
var project: Project? = myProject
while (project != null) {
if (myProject.plugins.hasPlugin(NodeJsPlugin::class.java)) return project
project = project.parent
}
return null
fun apply(project: Project): NodeJsRootExtension {
val rootProject = project.rootProject
rootProject.plugins.apply(NodeJsPlugin::class.java)
return rootProject.extensions.getByName(NODE_JS) as NodeJsRootExtension
}
}
}
@@ -0,0 +1,71 @@
package org.jetbrains.kotlin.gradle.targets.js.nodejs
import org.gradle.api.Project
import org.jetbrains.kotlin.gradle.logging.kotlinInfo
import org.jetbrains.kotlin.gradle.targets.js.npm.NpmApi
import org.jetbrains.kotlin.gradle.targets.js.yarn.Yarn
import java.io.File
open class NodeJsRootExtension(project: Project) : NodeJsExtension(project) {
private val gradleHome = project.gradle.gradleUserHomeDir.also {
project.logger.kotlinInfo("Storing cached files in $it")
}
var installationDir = gradleHome.resolve("nodejs")
var download = true
var nodeDownloadBaseUrl = "https://nodejs.org/dist"
var nodeVersion = "10.15.3"
var nodeCommand = "node"
var manageNodeModules: Boolean = false
var packageManager: NpmApi = Yarn
val nodeJsSetupTask: NodeJsSetupTask
get() = project.tasks.getByName(NodeJsSetupTask.NAME) as NodeJsSetupTask
internal val environment: NodeJsEnv
get() {
val platform = NodeJsPlatform.name
val architecture = NodeJsPlatform.architecture
val nodeDir = installationDir.resolve("node-v$nodeVersion-$platform-$architecture")
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()
)
}
internal fun executeSetup() {
val nodeJsEnv = environment
if (download) {
if (!nodeJsEnv.nodeBinDir.isDirectory) {
nodeJsSetupTask.exec()
}
}
}
companion object {
const val NODE_JS: String = "nodeJs"
operator fun get(project: Project) = NodeJsPlugin.apply(project)
}
}
@@ -11,30 +11,33 @@ import java.io.File
import java.net.URI
open class NodeJsSetupTask : DefaultTask() {
private val settings = NodeJsExtension[project]
private val env by lazy { settings.buildEnv() }
private val settings = project.nodeJs.root
private val env by lazy { settings.environment }
init {
group = NodeJsExtension.NODE_JS
group = NodeJsRootExtension.NODE_JS
description = "Download and install a local node/npm version."
}
val input: Set<String>
@Input get() = setOf(settings.download.toString(), env.ivyDependency)
val ivyDependency: String
@Input get() = env.ivyDependency
val destination: File
@OutputDirectory get() = env.nodeDir
init {
onlyIf {
settings.download && !env.nodeBinDir.isDirectory
}
}
@Suppress("unused")
@TaskAction
fun exec() {
if (!settings.download)
return
@Suppress("UnstableApiUsage", "DEPRECATION")
val repo = project.repositories.ivy { repo ->
repo.name = "Node Distributions at ${settings.distBaseUrl}"
repo.url = URI(settings.distBaseUrl)
repo.name = "Node Distributions at ${settings.nodeDownloadBaseUrl}"
repo.url = URI(settings.nodeDownloadBaseUrl)
if (isGradleVersionAtLeast(5, 0)) {
repo.patternLayout { layout ->
@@ -52,7 +55,7 @@ open class NodeJsSetupTask : DefaultTask() {
}
}
val dep = this.project.dependencies.create(env.ivyDependency)
val dep = this.project.dependencies.create(ivyDependency)
val conf = this.project.configurations.detachedConfiguration(dep)
conf.isTransitive = false
val result = conf.resolve().single()
@@ -63,7 +66,7 @@ open class NodeJsSetupTask : DefaultTask() {
unpackNodeArchive(result, destination.parentFile) // parent because archive contains name already
if (!env.isWindows) {
File(env.nodeExec).setExecutable(true)
File(env.nodeExecutable).setExecutable(true)
}
}
@@ -0,0 +1,19 @@
/*
* Copyright 2010-2019 JetBrains s.r.o. 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.targets.js.nodejs
import org.gradle.api.Project
internal fun nodeJsSetupWithoutTasks(project: Project) {
val nodeJsProject = NodeJsPlugin.ensureAppliedInHierarchy(project)
val nodeJs = NodeJsExtension[nodeJsProject]
val nodeJsEnv = nodeJs.buildEnv()
if (nodeJs.download) {
if (!nodeJsEnv.nodeBinDir.isDirectory) {
(nodeJsProject.tasks.findByName(NodeJsSetupTask.NAME) as NodeJsSetupTask).exec()
}
}
}
@@ -0,0 +1,10 @@
/*
* Copyright 2010-2019 JetBrains s.r.o. 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.targets.js.npm
import java.io.File
class GradleNodeModule(val name: String, val path: File)
@@ -0,0 +1,120 @@
/*
* Copyright 2010-2019 JetBrains s.r.o. 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.targets.js.npm
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import org.gradle.api.Project
import org.gradle.api.artifacts.ResolvedArtifact
import org.gradle.api.artifacts.ResolvedDependency
import java.io.File
internal class GradleNodeModuleBuilder(
val project: Project,
val dependency: ResolvedDependency,
val artifacts: Set<ResolvedArtifact>,
val cache: GradleNodeModules
) {
var srcPackageJsonFile: File? = null
val files = mutableListOf<File>()
fun visitArtifacts() {
artifacts.forEach { artifact ->
val srcFile = artifact.file
when {
isKotlinJsRuntimeFile(srcFile) -> files.add(srcFile)
srcFile.isZip -> project.zipTree(srcFile).forEach { innerFile ->
when {
innerFile.name == NpmProject.PACKAGE_JSON -> srcPackageJsonFile = innerFile
isKotlinJsRuntimeFile(innerFile) -> files.add(innerFile)
}
}
}
}
}
fun rebuild(): File? {
if (files.isEmpty()) return null
val packageJson = srcPackageJsonFile?.reader()?.use {
Gson().fromJson(it, PackageJson::class.java)
} ?: PackageJson(dependency.moduleName, dependency.moduleVersion)
val jsFiles = files.filter { it.name.endsWith(".js") }
if (jsFiles.size == 1) {
val jsFile = jsFiles.single()
packageJson.name = jsFile.nameWithoutExtension
packageJson.main = jsFile.name
}
// yarn requires semver
packageJson.version = fixSemver(packageJson.version)
return makeNodeModule(cache.dir, packageJson) { nodeModule ->
project.copy { copy ->
copy.from(files)
copy.into(nodeModule)
}
}
}
}
private val File.isZip
get() = isFile && (name.endsWith(".jar") || name.endsWith(".zip"))
private fun isKotlinJsRuntimeFile(file: File): Boolean {
if (!file.isFile) return false
val name = file.name
return (name.endsWith(".js") && !name.endsWith(".meta.js"))
|| name.endsWith(".js.map")
}
internal fun fixSemver(version: String): String {
val c = version.split("-", limit = 2)
val main = c[0]
val preRelease = if (c.size > 1) c[1] else null
val numbers = main.split(".")
val major = numbers[0]
val minor = if (numbers.size > 1) numbers[1] else null
val patch = if (numbers.size > 2) numbers[2] else null
return buildString {
append(major.filter { it.isDigit() })
append(".")
append(minor?.filter { it.isDigit() } ?: "0")
append(".")
append(patch?.filter { it.isDigit() } ?: "0")
if (preRelease != null) {
append("-")
append(preRelease)
}
}
}
fun makeNodeModule(
container: File,
packageJson: PackageJson,
files: (File) -> Unit
): File {
val dir = container.resolve(packageJson.name)
if (dir.exists()) dir.deleteRecursively()
check(dir.mkdirs()) {
"Cannot create directory: $dir"
}
val gson = GsonBuilder()
.setPrettyPrinting()
.create()
files(dir)
dir.resolve("package.json").writer().use {
gson.toJson(packageJson, it)
}
return dir
}
@@ -0,0 +1,118 @@
/*
* Copyright 2010-2019 JetBrains s.r.o. 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.targets.js.npm
import org.gradle.api.Project
import org.gradle.api.artifacts.ResolvedArtifact
import org.gradle.api.artifacts.ResolvedDependency
import org.gradle.api.file.CopySpec
import org.jetbrains.kotlin.gradle.dsl.KotlinCommonOptions
import org.jetbrains.kotlin.gradle.internal.ProcessedFilesCache
import org.jetbrains.kotlin.gradle.plugin.KotlinCompilation
import org.jetbrains.kotlin.gradle.targets.js.internal.RewriteSourceMapFilterReader
import org.jetbrains.kotlin.gradle.tasks.Kotlin2JsCompile
internal class GradleNodeModules(val project: Project) : AutoCloseable {
companion object {
const val STATE_FILE_NAME = ".visited"
}
internal val dir = project.buildDir.resolve("node_modules_gradle")
private val nodeModulesDir = project.npmProject.nodeModulesDir
private val cache = ProcessedFilesCache(project, dir, STATE_FILE_NAME, "5")
private val visited = mutableSetOf<ResolvedDependency>()
private val doLast = mutableListOf<() -> Unit>()
val modules
get() = cache.targets.map {
GradleNodeModule(it, dir.resolve(it))
}
fun visitCompilation(compilation: KotlinCompilation<KotlinCommonOptions>) {
val project = compilation.target.project
val kotlin2JsCompile = compilation.compileKotlinTask as Kotlin2JsCompile
// classpath
compilation.relatedConfigurationNames.forEach {
val configuration = project.configurations.getByName(it)
if (configuration.isCanBeResolved) {
configuration.resolvedConfiguration.firstLevelModuleDependencies.forEach {
visitDependency(compilation, it)
}
}
}
// output
doLast.add {
// we should do it only after node package manger work (as it can clean files)
if (kotlin2JsCompile.state.executed) {
visitCompile(project, kotlin2JsCompile)
} else {
kotlin2JsCompile.doLast {
visitCompile(project, kotlin2JsCompile)
}
}
}
}
private fun visitDependency(
compilation: KotlinCompilation<KotlinCommonOptions>,
dependency: ResolvedDependency
) {
if (!visited.add(dependency)) return
visitArtifacts(dependency, dependency.moduleArtifacts)
dependency.children.forEach {
visitDependency(compilation, it)
}
}
private fun visitArtifacts(
dependency: ResolvedDependency,
artifacts: MutableSet<ResolvedArtifact>
) {
val lazyDirName: String? by lazy {
val module = GradleNodeModuleBuilder(project, dependency, artifacts, this)
module.visitArtifacts()
module.rebuild()?.canonicalFile?.relativeTo(dir)?.path
}
artifacts.forEach { artifact ->
cache.getOrCompute(artifact.file) {
lazyDirName
}
}
}
private fun visitCompile(project: Project, kotlin2JsCompile: Kotlin2JsCompile) {
project.copy { copy ->
copy.from(kotlin2JsCompile.outputFile)
copy.from(kotlin2JsCompile.outputFile.path + ".map")
copy.into(nodeModulesDir)
copy.withSourceMapRewriter()
}
}
private fun CopySpec.withSourceMapRewriter() {
eachFile {
if (it.name.endsWith(".js.map")) {
it.filter(
mapOf(
"srcSourceRoot" to it.file.parentFile,
"targetSourceRoot" to nodeModulesDir
),
RewriteSourceMapFilterReader::class.java
)
}
}
}
override fun close() {
doLast.forEach { it() }
cache.close()
}
}
@@ -0,0 +1,148 @@
/*
* Copyright 2010-2019 JetBrains s.r.o. 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.targets.js.npm
import com.google.gson.GsonBuilder
import com.google.gson.JsonIOException
import com.google.gson.JsonSyntaxException
import org.gradle.api.Project
import org.gradle.api.internal.project.ProjectInternal
import org.gradle.internal.hash.FileHasher
import org.jetbrains.kotlin.gradle.dsl.KotlinCommonOptions
import org.jetbrains.kotlin.gradle.plugin.KotlinCompilation
import org.jetbrains.kotlin.gradle.targets.js.tasks.isKotlinJsRuntimeFile
import org.jetbrains.kotlin.gradle.targets.js.tasks.isZip
import org.jetbrains.kotlin.gradle.tasks.Kotlin2JsCompile
import java.io.File
class NodeModulesGradleComponents(val rootProject: Project) {
val hasher = (rootProject as ProjectInternal).services.get(FileHasher::class.java)
val nodeModulesDir = rootProject.rootDir.resolve("node_modules")
val gson = GsonBuilder().setPrettyPrinting().create()
private class State {
val contents: MutableMap<ByteArray, Element> = mutableMapOf()
}
private class Element {
val fileNames: MutableList<String> = mutableListOf()
@Transient
val files = mutableListOf<File>()
}
class TransitiveNpmDependency(
val project: String,
val from: String,
val key: String,
val version: String
)
private lateinit var old: State
private val new = State()
val stateFile = rootProject.buildDir.resolve("nodeModulesGradleComponents.json")
fun loadOldState() {
val state: State? = if (stateFile.exists()) try {
stateFile.reader().use {
gson.fromJson(it, State::class.java)
}
} catch (e: JsonSyntaxException) {
// todo: warn
null
} catch (e: JsonIOException) {
// todo: warn
null
} else null
old = state ?: State()
}
fun sync() {
val children = nodeModulesDir.list()?.toSet() ?: setOf()
val deleted = old.contents.keys.toMutableSet()
val absense = mutableSetOf<ByteArray>()
new.contents.forEach { (key, value) ->
deleted.remove(key)
if (key !in old.contents.keys) absense.add(key)
else if (value.fileNames.any { it !in children }) absense.add(key)
}
deleted.forEach {
old.contents[it]!!.fileNames.forEach { fileName ->
nodeModulesDir.resolve(fileName).delete()
}
}
rootProject.copy { copy ->
absense.forEach { key ->
copy.from(new.contents[key]!!.files)
copy.into(nodeModulesDir)
}
}
saveNewState()
}
private fun saveNewState() {
stateFile.writer().use {
gson.toJson(new, it)
}
}
private fun getOrCompute(file: File, buildElement: (Element) -> Unit) {
val hash = hasher.hash(file).toByteArray()
new.contents[hash] = old.contents[hash] ?: Element().also {
buildElement(it)
it.files.mapTo(it.fileNames) { it.name }
}
}
fun visitCompilation(
compilation: KotlinCompilation<KotlinCommonOptions>,
project: Project,
transitiveDependencies: MutableList<TransitiveNpmDependency>
): List<TransitiveNpmDependency> {
val kotlin2JsCompile = compilation.compileKotlinTask as Kotlin2JsCompile
// classpath
kotlin2JsCompile.classpath.forEach { srcFile ->
when {
isKotlinJsRuntimeFile(srcFile) -> getOrCompute(srcFile) { element ->
element.files.add(srcFile)
}
srcFile.isZip -> getOrCompute(srcFile) { element ->
rootProject.zipTree(srcFile).forEach { innerFile ->
if (innerFile.name == "package.json") {
val packageJson = innerFile.reader().use {
gson.fromJson(it, PackageJson::class.java)
}
packageJson.dependencies.forEach { (key, version) ->
transitiveDependencies.add(TransitiveNpmDependency(project.path, srcFile.path, key, version))
}
} else if (isKotlinJsRuntimeFile(innerFile)) {
element.files.add(innerFile)
}
}
}
}
}
// output
kotlin2JsCompile.doLast {
project.copy { copy ->
copy.from(kotlin2JsCompile.outputFile)
copy.from(kotlin2JsCompile.outputFile.path + ".map")
copy.into(nodeModulesDir)
}
}
return transitiveDependencies
}
}
@@ -0,0 +1,19 @@
/*
* Copyright 2010-2019 JetBrains s.r.o. 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.targets.js.npm
import org.gradle.api.Project
import org.jetbrains.kotlin.gradle.targets.js.nodejs.nodeJsSetupWithoutTasks
object Npm: NpmApi {
override fun setup(project: Project) {
nodeJsSetupWithoutTasks(project)
}
override fun resolveRootProject(project: Project) {
TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
}
}
@@ -0,0 +1,41 @@
/*
* Copyright 2010-2019 JetBrains s.r.o. 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.targets.js.npm
import org.gradle.api.Project
interface NpmApi {
fun setup(project: Project)
@Suppress("EXPOSED_PARAMETER_TYPE")
fun resolveProject(npmPackage: NpmResolver.NpmPackage)
@Suppress("EXPOSED_PARAMETER_TYPE")
fun resolveRootProject(
rootProject: Project,
subprojects: MutableList<NpmResolver.NpmPackage>
)
@Suppress("EXPOSED_PARAMETER_TYPE")
fun hookRootPackage(rootProject: Project, rootPackageJson: PackageJson, allWorkspaces: Collection<NpmResolver.NpmPackage>) {
}
open val hoistGradleNodeModules
get() = false
fun cleanProject(project: Project) {
val npmProject = project.npmProject
npmProject.nodeModulesDir.deleteRecursively()
npmProject.packageJsonFile.delete()
}
companion object {
fun resolveOperationDescription(packageManagerTitle: String): String =
"Resolving NPM dependencies using $packageManagerTitle"
}
}
@@ -0,0 +1,118 @@
/*
* Copyright 2010-2019 JetBrains s.r.o. 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.targets.js.npm
import org.gradle.api.Project
import org.gradle.api.artifacts.Dependency
import org.gradle.api.artifacts.FileCollectionDependency
import org.gradle.api.artifacts.SelfResolvingDependency
import org.gradle.api.file.FileCollection
import org.gradle.api.internal.artifacts.DependencyResolveContext
import org.gradle.api.internal.artifacts.ResolvableDependency
import org.gradle.api.internal.artifacts.dependencies.SelfResolvingDependencyInternal
import org.gradle.api.tasks.TaskDependency
import org.gradle.internal.component.local.model.DefaultLibraryBinaryIdentifier
import org.jetbrains.kotlin.gradle.targets.js.npm.NpmResolver.ResolutionCallResult.*
import java.io.File
data class NpmDependency(
internal val project: Project,
private val org: String?,
private val name: String,
private val version: String
) : SelfResolvingDependency,
SelfResolvingDependencyInternal,
ResolvableDependency,
FileCollectionDependency {
override fun getGroup(): String? = org
internal val dependencies = mutableSetOf<NpmDependency>()
override fun resolve(): MutableSet<File> {
resolveProject() ?: return mutableSetOf()
val all = mutableSetOf<File>()
val visited = mutableSetOf<NpmDependency>()
fun visit(item: NpmDependency) {
if (item in visited) return
visited.add(item)
item.tryFindNodeModule()?.let {
all.add(it)
}
}
visit(this)
return all
}
internal fun resolveProject(): NpmResolver.ResolvedProject? {
val result = NpmResolver.resolve(project)
return when (result) {
is AlreadyInProgress -> null
is AlreadyResolved -> {
check(this in result.resolution.dependencies) {
"Project hierarchy is already resolved in NPM without $this"
}
result.resolution
}
is ResolvedNow -> {
check(this in result.resolution.dependencies) {
"$this was not visited during resolution"
}
result.resolution
}
}
}
private fun tryFindNodeModule(): File? {
var p = project
do {
val result = NpmProject[p].nodeModulesDir.resolve(key)
if (result.exists()) return result
p = project.parent ?: return null
} while (true)
}
val key: String = if (org == null) name else "@$org/$name"
override fun toString() = "$key: $version"
override fun resolve(context: DependencyResolveContext) {
resolve()
dependencies.forEach {
context.add(it)
}
}
override fun resolve(transitive: Boolean): MutableSet<File> = resolve()
override fun getFiles(): FileCollection = project.files(resolve())
override fun getName() = name
override fun getVersion() = version
override fun getBuildDependencies(): TaskDependency = TaskDependency { mutableSetOf() }
override fun contentEquals(dependency: Dependency) = this == dependency
override fun getTargetComponentId() = DefaultLibraryBinaryIdentifier(project.path, key, "npm")
override fun copy(): Dependency = this.copy(org = org)
private var reason: String? = null
override fun because(reason: String?) {
this.reason = reason
}
override fun getReason(): String? = reason
}
@@ -0,0 +1,93 @@
/*
* Copyright 2010-2019 JetBrains s.r.o. 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.targets.js.npm
import com.google.gson.Gson
import com.google.gson.JsonObject
import groovy.transform.TailRecursive
import org.gradle.api.Project
import org.gradle.process.ExecSpec
import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsPlugin
import org.jetbrains.kotlin.gradle.targets.js.nodejs.nodeJs
import org.jetbrains.kotlin.gradle.tasks.Kotlin2JsCompile
import java.io.File
class NpmProject(
val project: Project,
val nodeWorkDir: File,
val managed: Boolean
) {
val nodeModulesDir
get() = nodeWorkDir.resolve(NODE_MODULES)
val packageJsonFile: File
get() {
return nodeWorkDir.resolve(PACKAGE_JSON)
}
fun useTool(exec: ExecSpec, tool: String, vararg args: String) {
NpmResolver.resolve(project)
exec.workingDir = nodeWorkDir
exec.executable = project.nodeJs.root.environment.nodeExecutable
exec.args = listOf(findModule(tool)) + args
}
val hoistGradleNodeModules: Boolean
get() = project.nodeJs.root.packageManager.getHoistGradleNodeModules(project)
val gradleNodeModulesDir: File
get() =
if (hoistGradleNodeModules) project.rootProject.npmProject.nodeModulesDir
else nodeModulesDir
fun moduleOutput(compilationTask: Kotlin2JsCompile): File {
return gradleNodeModulesDir.resolve(compilationTask.outputFile.name)
}
@TailRecursive
fun findModule(name: String, src: NpmProject = this): String {
val file = nodeModulesDir.resolve(name)
if (file.isFile) return file.canonicalPath
if (file.isDirectory) {
val packageJsonFile = file.resolve("package.json")
val main: String = (if (packageJsonFile.isFile) {
val packageJson = packageJsonFile.reader().use {
Gson().fromJson(it, JsonObject::class.java)
}
packageJson["main"] as? String? ?: packageJson["module"] as? String? ?: packageJson["browser"] as? String?
} else null) ?: "index.js"
val mainFile = file.resolve(main)
if (mainFile.isFile) return mainFile.canonicalPath
}
val parent = project.parent
return if (!managed || parent == null) error("Cannot find node module $name in $src")
else NpmProject[parent].findModule(name, src)
}
companion object {
const val PACKAGE_JSON = "package.json"
const val NODE_MODULES = "node_modules"
operator fun get(project: Project): NpmProject {
val nodeJsRootExtension = NodeJsPlugin.apply(project)
val manageNodeModules = nodeJsRootExtension.root.manageNodeModules
val nodeWorkDir =
if (manageNodeModules) project.projectDir
else project.buildDir
return NpmProject(project, nodeWorkDir, manageNodeModules)
}
}
}
val Project.npmProject
get() = NpmProject[this]
@@ -0,0 +1,218 @@
/*
* Copyright 2010-2019 JetBrains s.r.o. 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.targets.js.npm
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import org.gradle.api.Project
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinSingleTargetExtension
import org.jetbrains.kotlin.gradle.dsl.kotlinExtensionOrNull
import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType
import org.jetbrains.kotlin.gradle.plugin.KotlinTarget
import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsPlugin
import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootExtension
import org.jetbrains.kotlin.gradle.targets.js.nodejs.nodeJs
import org.jetbrains.kotlin.gradle.targets.js.npm.NpmResolver.ResolutionCallResult.*
/**
* Generates `package.json` file for projects with npm or js dependencies and
* runs selected [NodeJsRootExtension.packageManager] to download and install it.
*
* All [NpmDependency] for configurations related to kotlin/js will be added to `package.json`.
* For external gradle modules, fake npm packages will be created and added to `package.json`
* as path to directory.
*/
internal class NpmResolver private constructor(val rootProject: Project) {
companion object {
fun resolve(project: Project): ResolutionCallResult {
val rootProject = project.rootProject
val process = ProjectData[rootProject]
if (process != null && process.resolved == null)
return AlreadyInProgress
val resolved = process?.resolved
return if (resolved != null) AlreadyResolved(resolved)
else {
val resolver = NpmResolver(rootProject)
check(resolver.resolve(rootProject, null))
ResolvedNow(ResolvedProject(resolver.npmPackages))
}
}
}
sealed class ResolutionCallResult {
object AlreadyInProgress : ResolutionCallResult()
class AlreadyResolved(val resolution: ResolvedProject) : ResolutionCallResult()
class ResolvedNow(val resolution: ResolvedProject) : ResolutionCallResult()
}
private val nodeJs = NodeJsPlugin.apply(rootProject).root
private val npmProject = rootProject.npmProject
private val packageManager = nodeJs.packageManager
private val hoistGradleNodeModules = npmProject.hoistGradleNodeModules
private val npmPackages = mutableListOf<NpmPackage>()
private val gson = GsonBuilder()
.setPrettyPrinting()
.create()
class ProjectData(var resolved: ResolvedProject? = null) {
companion object {
private const val KEY = "npmResolverData"
operator fun get(project: Project) = project.extensions.findByName(KEY) as ProjectData?
operator fun set(project: Project, value: ProjectData) = project.extensions.add(KEY, value)
}
}
class ResolvedProject(val npmPackages: Collection<NpmPackage>) {
val dependencies by lazy {
npmPackages.flatMapTo(mutableSetOf()) { it.npmDependencies }
}
}
class NpmPackage(
val project: Project,
val packageJson: PackageJson,
val npmDependencies: Set<NpmDependency>
) {
fun savePackageJson(gson: Gson) {
NpmProject[project].packageJsonFile.writer().use {
gson.toJson(packageJson, it)
}
}
}
private var packageManagerInstalled = false
private fun requirePackageManagerSetup() {
if (packageManagerInstalled) return
packageManager.setup(rootProject)
packageManagerInstalled = true
}
private fun resolve(
project: Project,
parentGradleModules: GradleNodeModules?
): Boolean {
val existedProcess = ProjectData[project]
if (existedProcess != null) {
if (existedProcess.resolved != null) error("yarn dependencies for $project already resolved")
else {
// called inside resolution of classpath (from visitTarget)
// we should return as we are already in progress
return false
}
} else ProjectData().also {
ProjectData[project] = it
}
val gradleModules =
if (hoistGradleNodeModules && parentGradleModules != null) parentGradleModules
else GradleNodeModules(project)
project.subprojects.forEach {
resolve(it, gradleModules)
}
val npmPackage = extractNpmPackage(project, gradleModules)
if (npmPackage != null) {
npmPackage.savePackageJson(gson)
requirePackageManagerSetup()
packageManager.resolveProject(npmPackage)
npmPackages.add(npmPackage)
}
if (project == rootProject) {
if (npmPackages.isNotEmpty()) {
requirePackageManagerSetup()
packageManager.resolveRootProject(rootProject, npmPackages)
}
}
if (gradleModules != parentGradleModules) {
gradleModules.close()
}
return true
}
private fun extractNpmPackage(
project: Project,
gradleComponents: GradleNodeModules
): NpmPackage? {
val packageJson = PackageJson(project.name, project.version.toString())
val npmDependencies = mutableSetOf<NpmDependency>()
visitNpmDependencies(project, npmDependencies, gradleComponents, packageJson)
if (!hoistGradleNodeModules || project == project.rootProject) {
gradleComponents.modules.forEach {
val relativePath = it.path.relativeTo(NpmProject[project].nodeWorkDir)
packageJson.dependencies[it.name] = "file:$relativePath"
}
}
project.nodeJs.packageJsonHandlers.forEach {
it(packageJson)
}
val packageJsonRequired = if (project == rootProject) {
packageManager.hookRootPackage(rootProject, packageJson, npmPackages)
} else false
return if (packageJsonRequired || !packageJson.empty) NpmPackage(project, packageJson, npmDependencies) else null
}
private fun visitNpmDependencies(
project: Project,
npmDependencies: MutableSet<NpmDependency>,
gradleComponents: GradleNodeModules,
packageJson: PackageJson
) {
val kotlin = project.kotlinExtensionOrNull
if (kotlin != null) {
when (kotlin) {
is KotlinSingleTargetExtension -> visitTarget(kotlin.target, project, npmDependencies, gradleComponents)
is KotlinMultiplatformExtension -> kotlin.targets.forEach {
visitTarget(it, project, npmDependencies, gradleComponents)
}
}
npmDependencies.forEach {
packageJson.dependencies[it.key] = chooseVersion(packageJson.dependencies[it.key], it.version)
}
}
}
private fun chooseVersion(oldVersion: String?, newVersion: String): String =
oldVersion ?: newVersion // todo: real versions conflict resolution
private fun visitTarget(
target: KotlinTarget,
project: Project,
npmDependencies: MutableSet<NpmDependency>,
gradleComponents: GradleNodeModules
) {
if (target.platformType == KotlinPlatformType.js) {
target.compilations.toList().forEach { compilation ->
compilation.relatedConfigurationNames.forEach {
project.configurations.getByName(it).allDependencies.forEach { dependency ->
when (dependency) {
is NpmDependency -> npmDependencies.add(dependency)
}
}
}
gradleComponents.visitCompilation(compilation)
}
}
}
}
@@ -0,0 +1,43 @@
/*
* Copyright 2010-2019 JetBrains s.r.o. 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.targets.js.npm
class PackageJson(
var name: String,
var version: String
) {
val empty: Boolean
get() = private == null &&
workspaces == null &&
dependencies.isEmpty() &&
devDependencies.isEmpty()
val scopedName: ScopedName
get() = scopedName(name)
var private: Boolean? = null
var main: String? = null
var workspaces: Collection<String>? = null
val devDependencies = mutableMapOf<String, String>()
val dependencies = mutableMapOf<String, String>()
companion object {
fun scopedName(name: String): ScopedName = if (name.contains("/")) ScopedName(
scope = name.substringBeforeLast("/").removePrefix("@"),
name = name.substringAfterLast("/")
) else ScopedName(scope = null, name = name)
operator fun invoke(scope: String, name: String, version: String) =
PackageJson(ScopedName(scope, name).toString(), version)
}
data class ScopedName(val scope: String?, val name: String) {
override fun toString() = if (scope == null) name else "@$scope/$name"
}
}
@@ -52,12 +52,12 @@ open class KotlinJsNodeModulesTask : DefaultTask() {
}
}
private val File.isZip
internal val File.isZip
get() = isFile && (name.endsWith(".jar") || name.endsWith(".zip"))
internal fun isKotlinJsRuntimeFile(file: File): Boolean {
if (!file.isFile) return false
val name = file.name
return (name.endsWith(".js") && !name.endsWith(".meta.js"))
|| name.endsWith(".js.map")
|| name.endsWith(".js.map")
}
@@ -0,0 +1,79 @@
/*
* Copyright 2010-2019 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.targets.js.testing
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.SkipWhenEmpty
import org.gradle.process.internal.DefaultProcessForkOptions
import org.jetbrains.kotlin.gradle.internal.testing.TCServiceMessagesTestExecutionSpec
import org.jetbrains.kotlin.gradle.plugin.HasKotlinDependencies
import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsPlugin
import org.jetbrains.kotlin.gradle.targets.js.npm.NpmProject
import org.jetbrains.kotlin.gradle.targets.js.npm.NpmResolver
import org.jetbrains.kotlin.gradle.targets.js.testing.karma.KotlinKarma
import org.jetbrains.kotlin.gradle.targets.js.testing.mocha.KotlinMocha
import org.jetbrains.kotlin.gradle.targets.js.testing.nodejs.KotlinNodeJsTestRunner
import org.jetbrains.kotlin.gradle.tasks.KotlinTest
open class KotlinJsTest : KotlinTest() {
@Internal
@SkipWhenEmpty
internal var testFramework: KotlinJsTestFramework? = null
@Input
var debug: Boolean = false
@Internal
var runtimeDependencyHandler: HasKotlinDependencies? = null
@Input
var nodeModulesToLoad: MutableList<String> = mutableListOf()
fun useNodeJs(body: KotlinNodeJsTestRunner.() -> Unit) = use(KotlinNodeJsTestRunner(), body)
fun useMocha(body: KotlinMocha.() -> Unit) = use(KotlinMocha(), body)
fun useKarma(body: KotlinKarma.() -> Unit) = use(KotlinKarma(), body)
private inline fun <T : KotlinJsTestFramework> use(runner: T, body: T.() -> Unit): T {
check(testFramework == null) {
"testFramework already configured for task ${this.path}"
}
val testFramework = runner.also(body)
this.testFramework = testFramework
val dependenciesHolder = runtimeDependencyHandler
if (dependenciesHolder != null) {
testFramework.configure(dependenciesHolder)
}
return testFramework
}
override fun executeTests() {
NpmResolver.resolve(project)
super.executeTests()
}
override fun createTestExecutionSpec(): TCServiceMessagesTestExecutionSpec {
val forkOptions = DefaultProcessForkOptions(fileResolver)
NpmResolver.resolve(project)
forkOptions.workingDir = NpmProject[project].nodeWorkDir
forkOptions.executable = NodeJsPlugin.apply(project).root.environment.nodeExecutable
val nodeJsArgs = mutableListOf<String>()
if (debug) {
nodeJsArgs.add("--inspect-brk")
}
return testFramework!!.createTestExecutionSpec(this, forkOptions, nodeJsArgs)
}
}
@@ -0,0 +1,179 @@
/*
* Copyright 2010-2019 JetBrains s.r.o. 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.targets.js.webpack
import com.google.gson.stream.JsonWriter
import org.gradle.api.DefaultTask
import org.gradle.api.internal.file.FileResolver
import org.gradle.api.tasks.*
import org.gradle.process.internal.ExecHandleFactory
import org.jetbrains.kotlin.gradle.plugin.KotlinCompilationToRunnableFiles
import org.jetbrains.kotlin.gradle.targets.js.npm.NpmProject
import org.jetbrains.kotlin.gradle.targets.js.npm.NpmResolver
import org.jetbrains.kotlin.gradle.targets.js.npm.npmProject
import org.jetbrains.kotlin.gradle.tasks.Kotlin2JsCompile
import org.jetbrains.kotlin.gradle.tasks.createOrRegisterTask
import org.jetbrains.kotlin.gradle.testing.internal.reportsDir
import org.jetbrains.kotlin.gradle.utils.injected
import java.io.File
import java.io.StringWriter
import javax.inject.Inject
open class KotlinWebpack : DefaultTask() {
@get:Inject
open val fileResolver: FileResolver
get() = injected
@get:Inject
open val execHandleFactory: ExecHandleFactory
get() = injected
@Input
@SkipWhenEmpty
open lateinit var entry: File
open val configFile: File
@OutputFile get() = project.buildDir.resolve("webpack.config.js")
@Input
var saveConfigFileEffective: Boolean = true
open val configFileEffective: File
@OutputFile get() = project.buildDir.resolve("webpack.config.evaluated.js")
open val outputPath: File
@OutputDirectory get() = project.buildDir.resolve("lib")
open val configDirectory: File
@InputDirectory get() = project.projectDir.resolve("webpack.config.d")
@Input
var report: Boolean = false
open val reportDir: File
@OutputDirectory get() = project.reportsDir.resolve("webpack").resolve(entry.nameWithoutExtension)
@TaskAction
fun execute() {
check(entry.isFile) {
"${this}: Entry file not existed \"$entry\""
}
NpmResolver.resolve(project)
val npmProjectLayout = NpmProject[project]
configFile.writeText(buildConfig())
val execFactory = execHandleFactory.newExec()
npmProjectLayout.useTool(
execFactory,
".bin/webpack",
"--config", configFile.absolutePath
)
val exec = execFactory.build()
exec.start()
exec.waitForFinish()
}
private fun buildConfig(): String {
return buildString {
//language=JavaScript 1.8
append(
"""
const config = {
mode: 'development',
entry: '${entry.canonicalPath}',
output: {
path: '${outputPath.canonicalPath}',
filename: '${entry.name}'
},
resolve: {
modules: [
"node_modules"
]
},
plugins: [],
module: {
rules: []
}
};
"""
)
if (saveConfigFileEffective) {
//language=JavaScript 1.8
append(
"""
const util = require('util');
const fs = require("fs");
const evaluatedConfig = util.inspect(config, {showHidden: false, depth: null, compact: false});
fs.writeFile(${jsQuotedString(configFileEffective.canonicalPath)}, evaluatedConfig, (err) => {});
"""
)
}
if (report) {
//language=JavaScript 1.8
append(
"""
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
config.plugins.push(new BundleAnalyzerPlugin({
analyzerMode: "static",
reportFilename: "${entry.name}.report.html",
openAnalyzer: false,
generateStatsFile: true,
statsFilename: "${entry.name}.stats.json"
})
)
"""
)
}
loadConfigs(configDirectory)
append("module.exports = config")
}
}
private fun jsQuotedString(str: String) = StringWriter().also {
JsonWriter(it).value(str)
}.toString()
private fun StringBuilder.loadConfigs(confDir: File) {
if (confDir.isDirectory) confDir
.listFiles()
?.toList()
?.filter { it.name.endsWith(".js") }
?.forEach {
append(it.readText())
appendln()
appendln()
}
}
companion object {
fun configure(compilation: KotlinCompilationToRunnableFiles<*>) {
val project = compilation.target.project
val npmProject = project.npmProject
compilation.dependencies {
runtimeOnly(npm("webpack", "4.29.6"))
runtimeOnly(npm("webpack-cli", "3.3.0"))
runtimeOnly(npm("webpack-bundle-analyzer", "3.3.2"))
}
project.createOrRegisterTask<KotlinWebpack>("webpack") {
val compileKotlinTask = compilation.compileKotlinTask
compileKotlinTask as Kotlin2JsCompile
it.dependsOn(compileKotlinTask)
it.entry = npmProject.moduleOutput(compileKotlinTask)
}
}
}
}
@@ -0,0 +1,153 @@
/*
* Copyright 2010-2019 JetBrains s.r.o. 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.targets.js.yarn
import org.gradle.api.Project
import org.gradle.api.internal.project.ProjectInternal
import org.gradle.internal.hash.FileHasher
import org.jetbrains.kotlin.daemon.common.toHexString
import org.jetbrains.kotlin.gradle.internal.execWithProgress
import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsPlugin
import org.jetbrains.kotlin.gradle.targets.js.npm.*
import java.io.File
object Yarn : NpmApi {
override fun setup(project: Project) {
YarnPlugin.apply(project).executeSetup()
}
val Project.packageJsonHashFile: File
get() = buildDir.resolve("package.json.hash")
fun yarnExec(
project: Project,
description: String,
vararg args: String,
npmProject: NpmProject = NpmProject[project]
) {
val nodeJsEnv = NodeJsPlugin.apply(project).root.environment
val yarnEnv = YarnPlugin.apply(project).environment
val packageJsonHashFile = project.packageJsonHashFile
val packageJsonHash = if (packageJsonHashFile.exists()) packageJsonHashFile.readText() else null
val hasher = (project as ProjectInternal).services.get(FileHasher::class.java)
val hash = hasher.hash(npmProject.packageJsonFile).toByteArray().toHexString()
if (packageJsonHash == hash) return
packageJsonHashFile.writeText(hash)
val nodeWorkDir = npmProject.nodeWorkDir
project.execWithProgress(description) { exec ->
exec.executable = nodeJsEnv.nodeExecutable
exec.args = listOf(yarnEnv.home.resolve("bin/yarn.js").absolutePath) + args
exec.workingDir = nodeWorkDir
}
}
private fun yarnLockReadTransitiveDependencies(
nodeWorkDir: File,
srcDependenciesList: Collection<NpmDependency>
) {
val yarnLock = nodeWorkDir.resolve("yarn.lock")
if (yarnLock.isFile) {
val byKey = YarnLock.parse(yarnLock).entries.associateBy { it.key }
val visited = mutableSetOf<NpmDependency>()
fun resolveRecursively(src: NpmDependency): NpmDependency {
if (src in visited) return src
visited.add(src)
val key = YarnLock.key(src.key, src.version)
val deps = byKey[key]
if (deps != null) {
src.dependencies.addAll(deps.dependencies.map { dep ->
val scopedName = dep.scopedName
resolveRecursively(
NpmDependency(
src.project,
scopedName.scope,
scopedName.name,
dep.version ?: "*"
)
)
})
} else {
// todo: [WARN] cannot find $key in yarn.lock
}
return src
}
srcDependenciesList.forEach { src ->
resolveRecursively(src)
}
}
}
@Suppress("EXPOSED_PARAMETER_TYPE")
override fun resolveProject(npmPackage: NpmResolver.NpmPackage) {
val project = npmPackage.project
if (!project.yarn.useWorkspaces) {
yarnExec(project, NpmApi.resolveOperationDescription("yarn for ${project.path}"))
yarnLockReadTransitiveDependencies(NpmProject[project].nodeWorkDir, npmPackage.npmDependencies)
}
}
@Suppress("EXPOSED_PARAMETER_TYPE")
override fun hookRootPackage(
rootProject: Project,
rootPackageJson: PackageJson,
allWorkspaces: Collection<NpmResolver.NpmPackage>
): Boolean {
if (rootProject.yarn.useWorkspaces) {
rootPackageJson.private = true
rootPackageJson.workspaces = allWorkspaces
.filter { it.project != rootProject }
.map { it.project.projectDir.relativeTo(rootProject.rootDir).path }
return true
}
return false
}
override fun getHoistGradleNodeModules(project: Project): Boolean =
project.rootProject.yarn.useWorkspaces
@Suppress("EXPOSED_PARAMETER_TYPE")
override fun resolveRootProject(
rootProject: Project,
subprojects: MutableList<NpmResolver.NpmPackage>
) {
check(rootProject == rootProject.rootProject)
if (rootProject.yarn.useWorkspaces) {
yarnExec(rootProject, NpmApi.resolveOperationDescription("yarn"))
yarnLockReadTransitiveDependencies(NpmProject[rootProject].nodeWorkDir, subprojects.flatMap { it.npmDependencies })
} else {
if (subprojects.any { it.project != rootProject }) {
// todo: proofread message
rootProject.logger.warn(
"Build contains sub projects with NPM dependencies. " +
"It is recommended to enable yarn workspaces to store common NPM dependencies in root project. " +
"To enable it add this to your root project: \n" +
"nodeJs { manageNodeModules = true } \n" +
"Note: with `manageNodeModules` enabled, your `node_modules` and `package.json` files will be managed by " +
"Gradle, will be overridden during build and should be ignored in VCS."
)
}
}
}
override fun cleanProject(project: Project) {
super.cleanProject(project)
project.packageJsonHashFile.delete()
}
}
@@ -0,0 +1,13 @@
/*
* Copyright 2010-2019 JetBrains s.r.o. 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.targets.js.yarn
import java.io.File
internal data class YarnEnv(
val downloadUrl: String,
val home: File
)
@@ -0,0 +1,46 @@
/*
* Copyright 2010-2019 JetBrains s.r.o. 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.targets.js.yarn
import org.gradle.api.GradleException
import org.gradle.api.Project
import org.jetbrains.kotlin.gradle.logging.kotlinInfo
open class YarnExtension(project: Project) {
private val gradleHome = project.gradle.gradleUserHomeDir.also {
project.logger.kotlinInfo("Storing cached files in $it")
}
var installationDir = gradleHome.resolve("yarn")
var distBaseUrl = "https://github.com/yarnpkg/yarn/releases/download"
var version = "1.15.2"
var command = "yarn"
var download = true
internal fun buildEnv() = YarnEnv(
downloadUrl = "$distBaseUrl/v$version/yarn-v$version.tar.gz",
home = installationDir.resolve("yarn-v$version")
)
companion object {
const val YARN: String = "yarn"
operator fun get(project: Project): YarnExtension {
val extension = project.extensions.findByType(YarnExtension::class.java)
if (extension != null)
return extension
val parentProject = project.parent
if (parentProject != null)
return get(parentProject)
throw GradleException("YarnExtension is not installed")
}
}
}
@@ -0,0 +1,108 @@
/*
* Copyright 2010-2019 JetBrains s.r.o. 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.targets.js.yarn
import com.google.gson.stream.JsonReader
import org.jetbrains.kotlin.gradle.targets.js.npm.PackageJson
import java.io.File
import java.io.StringReader
data class YarnLock(val entries: List<Entry>) {
data class Entry(
val key: String,
val version: String?,
val resolved: String?,
val integrity: String?,
val dependencies: List<Dependency>
)
data class Dependency(val key: String, val version: String?) {
val scopedName: PackageJson.ScopedName
get() = PackageJson.scopedName(key)
}
companion object {
fun key(packageKey: String, version: String) = "$packageKey@$version"
private class Node(val parent: Node?, val value: String? = null) {
var indent: String? = null
val children = mutableMapOf<String, Node>()
}
fun parse(yarnLock: File): YarnLock {
val root = parseTree(yarnLock)
return YarnLock(root.children.map {
val values = it.value.children
Entry(
it.key,
values["version"]?.value,
values["resolved"]?.value,
values["integrity"]?.value,
values["dependencies"]?.children?.map { dep ->
Dependency(dep.key, dep.value.value)
} ?: listOf()
)
})
}
private fun parseTree(yarnLock: File): Node {
val root = Node(null)
var parent = root
var onNewLevel = true
yarnLock.useLines {
it.forEach { line ->
if (!line.startsWith("#")) {
var i = 0
while (i < line.length && line[i].isWhitespace()) i++
val indentPos = i
val indent = line.substring(0, indentPos)
var key = false
val line1: String = if (line.endsWith(":")) {
key = true
line.removeSuffix(":")
} else line
if (line1.isNotEmpty()) {
val values = line1.substring(indentPos).split(" ").map { value ->
val value1 = value.removeSuffix(",")
if (value1.startsWith("\""))
try {
JsonReader(StringReader(value1)).nextString()
} catch (e: Throwable) {
value1.removePrefix("\"").removeSuffix("\"")
}
else value1
}
if (onNewLevel) {
parent.indent = indent
onNewLevel = false
} else {
while (indent != parent.indent!!) {
parent = parent.parent!!
}
}
val child = Node(parent, if (values.size > 1) values[1] else null)
if (key) {
values.forEach {
parent.children[it] = child
}
parent = child
onNewLevel = true
} else {
parent.children[values[0]] = child
}
}
}
}
}
return root
}
}
}
@@ -0,0 +1,33 @@
/*
* Copyright 2010-2019 JetBrains s.r.o. 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.targets.js.yarn
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsPlugin
open class YarnPlugin : Plugin<Project> {
override fun apply(project: Project): Unit = project.run {
check(project == project.rootProject) {
"YarnPlugin can be applied only to root project"
}
val nodeJs = NodeJsPlugin.apply(this)
this.extensions.create(YarnRootExtension.YARN, YarnRootExtension::class.java, this)
tasks.create(YarnSetupTask.NAME, YarnSetupTask::class.java) {
it.dependsOn(nodeJs.nodeJsSetupTask)
}
}
companion object {
fun apply(project: Project): YarnRootExtension {
val rootProject = project.rootProject
rootProject.plugins.apply(YarnPlugin::class.java)
return rootProject.extensions.getByName(YarnRootExtension.YARN) as YarnRootExtension
}
}
}
@@ -0,0 +1,58 @@
/*
* Copyright 2010-2019 JetBrains s.r.o. 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.targets.js.yarn
import org.gradle.api.Project
import org.jetbrains.kotlin.gradle.logging.kotlinInfo
import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsPlugin
import org.jetbrains.kotlin.gradle.targets.js.nodejs.nodeJs
open class YarnRootExtension(val project: Project) {
private val gradleHome = project.gradle.gradleUserHomeDir.also {
project.logger.kotlinInfo("Storing cached files in $it")
}
var installationDir = gradleHome.resolve("yarn")
var downloadBaseUrl = "https://github.com/yarnpkg/yarn/releases/download"
var version = "1.15.2"
val yarnSetupTask: YarnSetupTask
get() = project.tasks.getByName(YarnSetupTask.NAME) as YarnSetupTask
var disableWorkspaces: Boolean = false
val useWorkspaces: Boolean
get() = project.nodeJs.root.manageNodeModules && !disableWorkspaces
internal fun executeSetup() {
NodeJsPlugin.apply(project).executeSetup()
val env = environment
if (!env.home.isDirectory) {
yarnSetupTask.setup()
}
}
internal val environment
get() = YarnEnv(
downloadUrl = "$downloadBaseUrl/v$version/yarn-v$version.tar.gz",
home = installationDir.resolve("yarn-v$version")
)
companion object {
const val YARN: String = "yarn"
operator fun get(project: Project): YarnRootExtension {
val rootProject = project.rootProject
rootProject.plugins.apply(YarnPlugin::class.java)
return rootProject.extensions.getByName(YARN) as YarnRootExtension
}
}
}
val Project.yarn: YarnRootExtension
get() = YarnRootExtension[this]
@@ -0,0 +1,66 @@
/*
* Copyright 2010-2019 JetBrains s.r.o. 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.targets.js.yarn
import de.undercouch.gradle.tasks.download.DownloadAction
import org.gradle.api.DefaultTask
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.OutputDirectory
import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootExtension
import java.io.File
import javax.xml.ws.Action
open class YarnSetupTask : DefaultTask() {
private val settings = project.yarn
private val env by lazy { settings.environment }
@Suppress("MemberVisibilityCanBePrivate")
val downloadUrl
@Input get() = env.downloadUrl
@Suppress("MemberVisibilityCanBePrivate")
val destination: File
@OutputDirectory get() = env.home
init {
group = NodeJsRootExtension.NODE_JS
description = "Download and install a local yarn version."
onlyIf {
!settings.installationDir.exists()
}
}
@Action
fun setup() {
val dir = temporaryDir
val tar = download(dir)
extract(tar, destination)
}
private fun download(tar: File): File {
val action = DownloadAction(project)
action.src(downloadUrl)
action.dest(tar)
action.execute()
return action.outputFiles.singleOrNull() ?: error("Cannot get downloaded file $downloadUrl")
}
private fun extract(archive: File, destination: File) {
val dirInTar = archive.name.removeSuffix(".tar.gz") + File.pathSeparator
project.copy {
it.from(project.tarTree(archive))
it.into(destination)
it.eachFile { fileCopy ->
fileCopy.path = fileCopy.path.removePrefix(dirInTar)
}
}
}
companion object {
const val NAME: String = "yarnSetup"
}
}
@@ -0,0 +1,18 @@
/*
* Copyright 2010-2019 JetBrains s.r.o. 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.targets.js.npm
import org.junit.Test
import org.junit.Assert.*
class GradleNodeModuleBuilderKtTest {
@Test
fun fixSemver() {
assertEquals("1.3.0-SNAPSHOT", fixSemver("1.3-SNAPSHOT"))
assertEquals("1.2.3-RC1-1234", fixSemver("1.2.3-RC1-1234"))
}
}