/** * Script for creating a changelog. Call: * ``` * kotlin changelog.main.kts v1.7.0+dev555 * ``` * or: * ``` * kotlin changelog.main.kts v1.7.0..v1.7.1+dev555 * ``` * where: * v1.7.0+dev555 - the tag/branch of the version. The previous version will be read from CHANGELOG.md * v1.7.0..v1.7.1+dev555 - the range of tag/branches for the changelog * * It modifies CHANGELOG.md and adds new changes between the last version in CHANGELOG.md and the specified version. * * Changelog entries are generated from reading Release Notes in GitHub PR's. * * ## Checking PR description in a file * Not supposed to be called manually, used by GitHub workflow: * https://github.com/JetBrains/compose-multiplatform/blob/master/tools/changelog/check-release-notes-github-action/action.yml) * ``` * kotlin changelog.main.kts action=checkPr prDescription.txt * ``` * * compose-multiplatform - name of the GitHub repo * 5202 - PR number * * ## How to run Kotlin scripts * Option 1 - via Command line * 1. Download https://github.com/JetBrains/kotlin/releases/tag/v1.9.22 and add `bin` to PATH * * Option 2 - via IntelliJ: * 1. Right click on the script * 2. More Run/Debug * 3. Modify Run Configuration... * 4. Add Program arguments * 5. Clear all "Before launch" tasks (you can edit the system-wide template as well) * 6. OK */ @file:Repository("https://repo1.maven.org/maven2/") @file:DependsOn("com.google.code.gson:gson:2.10.1") import com.google.gson.Gson import com.google.gson.annotations.SerializedName import java.io.File import java.io.IOException import java.lang.ProcessBuilder.Redirect import java.lang.System.err import java.net.URL import java.net.URLEncoder import java.nio.charset.StandardCharsets.UTF_8 import java.time.LocalDate import java.time.format.DateTimeFormatter import java.util.* import kotlin.system.exitProcess val changelogFile = __FILE__.resolve("../../../CHANGELOG.md").canonicalFile val prFormatFile = File("PR_FORMAT.md") val prFormatLink = "https://github.com/JetBrains/compose-multiplatform/blob/master/tools/changelog/PR_FORMAT.md" val argsKeyless = args .filter { !it.contains("=") } val argsKeyToValue = args .filter { it.contains("=") } .associate { it.substringBefore("=") to it.substringAfter("=") } val token = argsKeyToValue["token"] // Parse sections from [PR_FORMAT.md] fun parseSections(title: String) = prFormatFile .readText() .substringAfter(title) .substringAfter("```") .substringBefore("```") .split("\n") .map { it.trim().removePrefix("-").substringBefore("#").substringBefore("\n").trim() } .filter { it.isNotEmpty() } val standardSections = parseSections("### Sections") val standardSubsections = parseSections("### Subsections") println() when (argsKeyToValue["action"]) { "checkPr" -> checkPr() else -> generateChangelog() } fun generateChangelog() { if (token == null) { println("To increase the rate limit, specify token (https://github.com/settings/tokens), adding token=yourtoken in the end\n") } val commitsArg = argsKeyless.getOrNull(0) ?: "HEAD" var previousVersionCommitArg: String? var versionCommitArg: String if (commitsArg.contains("..")) { previousVersionCommitArg = commitsArg.substringBefore("..") versionCommitArg = commitsArg.substringAfter("..") } else { previousVersionCommitArg = null versionCommitArg = commitsArg } val versionCommit = versionCommitArg val androidxLibToPreviousVersion = previousVersionCommitArg?.let(::androidxLibToVersion) val androidxLibToVersion = androidxLibToVersion(versionCommit) val androidxLibToRedirectionVersion = androidxLibToRedirectionVersion(versionCommit) fun formatAndroidxLibPreviousVersion(libName: String) = androidxLibToPreviousVersion?.get(libName) ?: "PLACEHOLDER".also { println("Can't find $libName previous version. Using PLACEHOLDER") } fun formatAndroidxLibVersion(libName: String) = androidxLibToVersion[libName] ?: "PLACEHOLDER".also { println("Can't find $libName version. Using PLACEHOLDER") } fun formatAndroidxLibRedirectingVersion(libName: String) = androidxLibToRedirectionVersion[libName] ?: "PLACEHOLDER".also { println("Can't find $libName redirection version. Using PLACEHOLDER") } val versionCompose = formatAndroidxLibVersion("COMPOSE") val versionComposeMaterial3 = formatAndroidxLibVersion("COMPOSE_MATERIAL3") val versionComposeMaterial3Adaptive = formatAndroidxLibVersion("COMPOSE_MATERIAL3_ADAPTIVE") val versionLifecycle = formatAndroidxLibVersion("LIFECYCLE") val versionNavigationEvent = formatAndroidxLibVersion("NAVIGATION_EVENT") val versionSavedstate = formatAndroidxLibVersion("SAVEDSTATE") val versionWindow = formatAndroidxLibVersion("WINDOW") val versionNavigation3 = formatAndroidxLibVersion("NAVIGATION_3") val versionRedirectingCompose = formatAndroidxLibRedirectingVersion("compose") val versionRedirectingComposeMaterial3 = formatAndroidxLibRedirectingVersion("compose.material3") val versionRedirectingComposeMaterial3Adaptive = formatAndroidxLibRedirectingVersion("compose.material3.adaptive") val versionRedirectingLifecycle = formatAndroidxLibRedirectingVersion("lifecycle") val versionRedirectingNavigationEvent = formatAndroidxLibRedirectingVersion("navigationevent") val versionRedirectingSavedstate = formatAndroidxLibRedirectingVersion("savedstate") val versionRedirectingWindow = formatAndroidxLibRedirectingVersion("window") val versionRedirectingNavigation3 = formatAndroidxLibRedirectingVersion("navigation3") val versionName = versionCompose val currentChangelog = changelogFile.readText() val previousChangelog = if (currentChangelog.startsWith("# $versionName ")) { val nextChangelogIndex = currentChangelog.indexOf("\n# ") currentChangelog.substring(nextChangelogIndex).removePrefix("\n") } else { currentChangelog } var previousVersionCommit: String var previousVersion: String if (previousVersionCommitArg != null) { previousVersionCommit = previousVersionCommitArg!! previousVersion = formatAndroidxLibPreviousVersion("COMPOSE") } else { val previousVersionInChangelog = previousChangelog.substringAfter("# ").substringBefore(" (") previousVersionCommit = "v$previousVersionInChangelog" previousVersion = previousVersionInChangelog } fun getChangelog(firstCommit: String, lastCommit: String, firstVersion: String, lastVersion: String): String { val isPrerelease = lastVersion.contains("-") val entries = entriesForRepo("JetBrains/compose-multiplatform-core", firstCommit, lastCommit) + entriesForRepo("JetBrains/compose-multiplatform", firstCommit, lastCommit) return buildString { appendLine("# $lastVersion (${currentChangelogDate()})") appendLine() appendLine("_Changes since ${firstVersion}_") appendLine() entries .filter { isPrerelease || !it.isPrerelease } .sortedBy { it.sectionOrder() } .groupBy { it.sectionName() } .forEach { (section, sectionEntries) -> appendLine("## $section") appendLine() sectionEntries .sortedBy { it.subsectionOrder() } .groupBy { it.subsectionName() } .forEach { (subsection, subsectionEntries) -> appendLine("### $subsection") appendLine() subsectionEntries.forEach { appendLine(it.run { "- $title [#$prNumber]($link)" }) if (it.details != null) { if (!it.details.startsWith("-")) { appendLine() } appendLine(it.details.prependIndent(" ")) } } appendLine() } } append( """ ## Components ### Gradle plugin `org.jetbrains.compose` version `$versionCompose` ### Libraries | Library group | Coordinates | Based on Jetpack | |---------------|-------------|------------------| | Runtime | `org.jetbrains.compose.runtime:runtime*:$versionCompose` | [Runtime $versionRedirectingCompose](https://developer.android.com/jetpack/androidx/releases/compose-runtime#$versionRedirectingCompose) | | UI | `org.jetbrains.compose.ui:ui*:$versionCompose` | [UI $versionRedirectingCompose](https://developer.android.com/jetpack/androidx/releases/compose-ui#$versionRedirectingCompose) | | Foundation | `org.jetbrains.compose.foundation:foundation*:$versionCompose` | [Foundation $versionRedirectingCompose](https://developer.android.com/jetpack/androidx/releases/compose-foundation#$versionRedirectingCompose) | | Material | `org.jetbrains.compose.material:material*:$versionCompose` | [Material $versionRedirectingCompose](https://developer.android.com/jetpack/androidx/releases/compose-material#$versionRedirectingCompose) | | Material3 | `org.jetbrains.compose.material3:material3*:$versionComposeMaterial3` | [Material3 $versionRedirectingComposeMaterial3](https://developer.android.com/jetpack/androidx/releases/compose-material3#$versionRedirectingComposeMaterial3) | | Material3 Adaptive | `org.jetbrains.compose.material3.adaptive:adaptive*:$versionComposeMaterial3Adaptive` | [Material3 Adaptive $versionRedirectingComposeMaterial3Adaptive](https://developer.android.com/jetpack/androidx/releases/compose-material3-adaptive#$versionRedirectingComposeMaterial3Adaptive) | | Lifecycle | `org.jetbrains.androidx.lifecycle:lifecycle-*:$versionLifecycle` | [Lifecycle $versionRedirectingLifecycle](https://developer.android.com/jetpack/androidx/releases/lifecycle#$versionRedirectingLifecycle) | | Navigation | `org.jetbrains.androidx.navigation:navigation-*:2.9.1` | [Navigation 2.9.4](https://developer.android.com/jetpack/androidx/releases/navigation#2.9.4) | | Navigation3 | `org.jetbrains.androidx.navigation3:navigation3-*:$versionNavigation3` | [Navigation3 $versionRedirectingNavigation3](https://developer.android.com/jetpack/androidx/releases/navigation3#$versionRedirectingNavigation3) | | Navigation Event | `org.jetbrains.androidx.navigationevent:navigationevent-compose:$versionNavigationEvent` | [Navigation Event $versionRedirectingNavigationEvent](https://developer.android.com/jetpack/androidx/releases/navigationevent#$versionRedirectingNavigationEvent) | | Savedstate | `org.jetbrains.androidx.savedstate:savedstate*:$versionSavedstate` | [Savedstate $versionRedirectingSavedstate](https://developer.android.com/jetpack/androidx/releases/savedstate#$versionRedirectingSavedstate) | | WindowManager Core | `org.jetbrains.androidx.window:window-core:$versionWindow` | [WindowManager $versionRedirectingWindow](https://developer.android.com/jetpack/androidx/releases/window#$versionRedirectingWindow) | --- """.trimIndent() ) appendLine() appendLine() val nonstandardSectionEntries = entries .filter { it.section != null && it.subsection != null && it.section !in standardSections && it.subsection !in standardSubsections } if (nonstandardSectionEntries.isNotEmpty()) { println() println("WARNING! Changelog contains nonstandard sections. Please change them to the standard ones, or enhance the list in the PR template.") for (entry in nonstandardSectionEntries) { println("${entry.section} - ${entry.subsection} in ${entry.link}") } } } } println() println("Generating changelog between $previousVersion and $versionName") val newChangelog = getChangelog(previousVersionCommit, versionCommit, previousVersion, versionName) changelogFile.writeText( newChangelog + previousChangelog ) println() println("CHANGELOG.md changed") } fun checkPr() { val filePath = argsKeyless.getOrNull(0) ?: error("Please specify a file that contains PR description as the first argument") val body = File(filePath).readText() val releaseNotes = extractReleaseNotes(body, 0, "https://github.com/JetBrains/compose-multiplatform/pull/0") val nonstandardSections = releaseNotes?.entries .orEmpty() .filter { it.section !in standardSections || it.subsection !in standardSubsections } .map { "${it.section} - ${it.subsection}" } .toSet() println() when { releaseNotes is ReleaseNotes.Specified && releaseNotes.entries.isEmpty() -> { err.println(""" "## Release Notes" doesn't contain any items, or "### Section - Subsection" isn't specified See the format in $prFormatLink """.trimIndent()) exitProcess(1) } releaseNotes is ReleaseNotes.Specified && nonstandardSections.isNotEmpty() -> { err.println(""" "## Release Notes" contains nonstandard "Section - Subsection" pairs: ${nonstandardSections.joinToString(", ")} Allowed sections: ${standardSections.joinToString(", ")} Allowed subsections: ${standardSubsections.joinToString(", ")} See the full format in $prFormatLink """.trimIndent()) exitProcess(1) } releaseNotes == null -> { err.println(""" "## Release Notes" section is missing in the PR description See the format in $prFormatLink """.trimIndent()) exitProcess(1) } else -> { println("\"## Release Notes\" are correct") } } } /** * September 2024 */ fun currentChangelogDate() = LocalDate.now().format(DateTimeFormatter.ofPattern("MMMM yyyy", Locale.ENGLISH)) fun GitHubPullEntry.extractReleaseNotes() = extractReleaseNotes(body, number, htmlUrl) fun GitHubPullEntry.unknownChangelogEntries() = listOf(ChangelogEntry("- $title", null, null, null, number, htmlUrl, false)) /** * Extract by format [PR_FORMAT.md] */ fun extractReleaseNotes(body: String?, prNumber: Int, prLink: String): ReleaseNotes? { fun String?.substringBetween(begin: String, end: String): String? { val after = this?.substringAfter(begin, "")?.ifBlank { null } return after?.substringBefore(end, "")?.ifBlank { null } ?: after } // extract body inside "# Release Notes" val relNoteBody = body ?.replace("# Release notes", "# Release Notes", ignoreCase = true) ?.replace("#Release notes", "# Release Notes", ignoreCase = true) ?.replace("# RelNote", "# Release Notes", ignoreCase = true) ?.run { substringBetween("# Release Notes", "\n# ") ?: substringBetween("## Release Notes", "\n## ") ?: substringBetween("### Release Notes", "\n### ") ?: substringBetween("## Release Notes", "\n# ") ?: substringBetween("### Release Notes", "\n## ") ?: substringBetween("### Release Notes", "\n# ") } ?.trim() if (relNoteBody == null) return null if (relNoteBody.trim().lowercase() == "n/a") return ReleaseNotes.NA // Check if the release notes contain only GitHub PR links val pullRequests = relNoteBody .split("\n") .map { it.trim() } .mapNotNull(PullRequestLink::parseOrNull) if (pullRequests.isNotEmpty()) return ReleaseNotes.CherryPicks(pullRequests) /** * Parses bodies like: * ``` * ### Highlights - iOS * - Describe change 1 * details (multiline) * * - Describe change 2 * details (multiline) * ``` */ fun parseChangelogEntries(sectionBody: StringList): List { val s = sectionBody.first().trimStart { it == '#' || it.isWhitespace() } val section = s.substringBefore("-", "").trim() .normalizeSectionName().ifEmpty { return emptyList() } val subsection = s.substringAfter("-", "").trim() .normalizeSubsectionName().ifEmpty { return emptyList() } return sectionBody .drop(1) .split { it.startsWith("-") } .map { entryBody -> val title = entryBody.first().trim().removePrefix("-").removeSuffix(".").trim() val details = entryBody.drop(1) .dropWhile { it.isBlank() } .dropLastWhile { it.isBlank() } .joinToString("\n") .trimIndent() .ifBlank { null } val isPrerelease = title.contains("(prerelease fix)") ChangelogEntry(title, details, section, subsection, prNumber, prLink, isPrerelease) } } return ReleaseNotes.Specified( relNoteBody .split("\n") .split { it.trim().startsWith("#") } .flatMap(::parseChangelogEntries) ) } /** * @param repo Example: * JetBrains/compose-multiplatform-core */ fun entriesForRepo(repo: String, firstCommit: String, lastCommit: String): List { val pulls = (1..10) .flatMap { requestJson>("https://api.github.com/repos/$repo/pulls?state=closed&per_page=100&page=$it").toList() } val shaToPull = pulls.associateBy { it.mergeCommitSha } val numberToPull = pulls.associateBy { it.number } val pullToReleaseNotes = Cache(GitHubPullEntry::extractReleaseNotes) // if GitHubPullEntry is a cherry-picks PR (contains a list of links to other PRs), replace it by the original PRs fun List.replaceCherryPicks(): List = flatMap { pullRequest -> val releaseNotes = pullToReleaseNotes[pullRequest] if (releaseNotes is ReleaseNotes.CherryPicks) { releaseNotes.pullRequests .filter { it.repo.equals(repo, ignoreCase = true) } .mapNotNull { numberToPull[it.number] } } else { listOf(pullRequest) } } val repoFolder = githubClone(repo) // Commits that exist in [firstCommit] and not identified as cherry-picks by `git log` // We'll try to exclude them via manual links to cherry-picks val cherryPickedPrsInFirstCommit = gitLogShas(repoFolder, firstCommit, lastCommit, "--cherry-pick --left-only") .mapNotNull(shaToPull::get) .replaceCherryPicks() // Exclude the same entries for partial cherry-picked PRs (example https://github.com/JetBrains/compose-multiplatform-core/pull/2096) val cherryPickedIdsInFirstCommit = cherryPickedPrsInFirstCommit .flatMap { pullToReleaseNotes[it]?.entries.orEmpty() } .mapTo(mutableSetOf()) { it.id } return gitLogShas(repoFolder, firstCommit, lastCommit, "--cherry-pick --right-only") .reversed() // older changes are at the bottom .mapNotNull(shaToPull::get) .replaceCherryPicks() .minus(cherryPickedPrsInFirstCommit) .distinctBy { it.number } .flatMap { pullToReleaseNotes[it]?.entries ?: it.unknownChangelogEntries() } .filterNot { it.id in cherryPickedIdsInFirstCommit } } /** * Extract redirection versions from core repo, file gradle.properties * * Example * https://raw.githubusercontent.com/JetBrains/compose-multiplatform-core/v1.8.0%2Bdev1966/gradle.properties * artifactRedirecting.androidx.graphics.version=1.0.1 */ fun androidxLibToRedirectionVersion(commit: String): Map { val gradleProperties = githubContentOf("JetBrains/compose-multiplatform-core", "gradle.properties", commit) val regexV1 = Regex("artifactRedirecting\\.androidx\\.(.*)\\.version=(.*)") val regexV2 = Regex("artifactRedirection\\.version\\.androidx\\.(.*)=(.*)") // changed in https://github.com/JetBrains/compose-multiplatform-core/pull/1946/files#diff-3d103fc7c312a3e136f88e81cef592424b8af2464c468116545c4d22d6edcf19R100 return listOf(regexV1, regexV2).flatMap { it.findAll(gradleProperties) }.associate { result -> result.groupValues[1].trim() to result.groupValues[2].trim() } } /** * Extract versions from CI config, file .teamcity/compose/Library.kt * * Example * https://jetbrains.team/p/ui/repositories/compose-teamcity-config/files/8f8408ccd05a9188895969b1fa0243050716baad/.teamcity/compose/Library.kt?tab=source&line=37&lines-count=1 * Library.CORE_BUNDLE -> "1.1.0-alpha01" */ fun androidxLibToVersion(commit: String): Map { val repo = "ssh://git@git.jetbrains.team/ui/compose-teamcity-config.git" val file = ".teamcity/compose/Library.kt" val libraryKt = try { spaceContentOf(repo, file, commit) } catch (_: Exception) { "" } return if (libraryKt.isBlank()) { println("Can't find library versions in $repo for $commit. Either the format is changed, or you need to register your ssh key in https://jetbrains.team/m/me/authentication?tab=GitKeys") emptyMap() } else { val regex = Regex("Library\\.(.*)\\s*->\\s*\"(.*)\"") return regex.findAll(libraryKt).associate { result -> result.groupValues[1].trim() to result.groupValues[2].trim() } } } fun githubContentOf(repo: String, path: String, commit: String): String { val commitEncoded = URLEncoder.encode(commit, UTF_8) return requestPlain("https://raw.githubusercontent.com/$repo/$commitEncoded/$path") } fun spaceContentOf(repoUrl: String, path: String, tagName: String): String { return pipeProcess("git archive --remote=$repoUrl $tagName $path") .pipeTo("tar -xO $path") .readText() } /** * Return a list of shas between [firstCommit] and [lastCommit] in [folder] */ fun gitLogShas(folder: File, firstCommit: String, lastCommit: String, additionalArgs: String): List { val absolutePath = folder.absolutePath val commits = pipeProcess("git -C $absolutePath log --oneline --format=%H $additionalArgs $firstCommit...$lastCommit"). readText() return commits.split("\n") } /** * Clone or fetch GitHub repo into [result] folder */ fun githubClone(repo: String): File { val url = "https://github.com/$repo" val folder = File("build/github/$repo") val absolutePath = folder.absolutePath if (!folder.exists() || folder.listFiles()?.isEmpty() == true) { folder.mkdirs() println("Cloning $url into ${folder.absolutePath}") pipeProcess("git clone --bare $url $absolutePath").waitAndCheck() } else { println("Fetching $url into ${folder.absolutePath}") pipeProcess("git -C $absolutePath fetch --force --tags").waitAndCheck() } check(folder.listFiles()?.isNotEmpty() == true) { "Cloning $url failed" } return folder } data class PullRequestLink(val repo: String, val number: Int) { companion object { fun parseOrNull(link: String): PullRequestLink? { val (repo, number) = Regex("https://github\\.com/(.+)/pull/(\\d+)/?") .matchEntire(link) ?.destructured ?: return null return PullRequestLink(repo, number.toInt()) } } } sealed interface ReleaseNotes { val entries: List object NA: ReleaseNotes { override val entries: List get() = emptyList() } class CherryPicks(val pullRequests: List): ReleaseNotes { override val entries: List get() = emptyList() } class Specified(override val entries: List): ReleaseNotes } /** * Describes a single entry in a format: * * ### section - subsection * ... * - title (single line) * details (line 1) * details (line 2) * ... */ data class ChangelogEntry( val title: String, /** */ val details: String?, val section: String?, val subsection: String?, val prNumber: Int, val link: String, val isPrerelease: Boolean, ) { /** * Unique entry id used for excluding cherry-picked entries */ val id: UUID = UUID.nameUUIDFromBytes((section + subsection + title + details).toByteArray(Charsets.UTF_8)) } fun ChangelogEntry.sectionOrder(): Int = section?.let(standardSections::indexOf) ?: standardSections.size fun ChangelogEntry.subsectionOrder(): Int = subsection?.let(standardSubsections::indexOf) ?: standardSubsections.size fun ChangelogEntry.sectionName(): String = section ?: "Unknown" fun ChangelogEntry.subsectionName(): String = subsection ?: "Unknown" fun String.normalizeSectionName() = standardSections.find { it.lowercase() == this.lowercase() } ?: this fun String.normalizeSubsectionName() = standardSubsections.find { it.lowercase() == this.lowercase() } ?: this // example https://api.github.com/repos/JetBrains/compose-multiplatform-core/pulls?state=closed data class GitHubPullEntry( @SerializedName("html_url") val htmlUrl: String, val number: Int, val title: String, val body: String?, @SerializedName("merge_commit_sha") val mergeCommitSha: String?, ) //region ========================================== UTILS ========================================= fun pipeProcess(command: String) = ProcessBuilder(command.split(" ")) .redirectOutput(Redirect.PIPE) .redirectError(Redirect.PIPE) .start()!! fun Process.pipeTo(command: String): Process = pipeProcess(command).also { inputStream.use { input -> it.outputStream.use { out -> input.copyTo(out) } } } fun Process.waitAndCheck() { val exitCode = waitFor() if (exitCode != 0) { val message = errorStream.bufferedReader().use { it.readText() } error("Command failed with exit code $exitCode:\n$message") } } fun Process.readText(): String = inputStream.bufferedReader().use { it.readText().also { waitAndCheck() } } inline fun requestJson(url: String): T = Gson().fromJson(requestPlain(url), T::class.java) fun requestPlain(url: String): String = exponentialRetry { println("Request $url") val connection = URL(url).openConnection() connection.setRequestProperty("User-Agent", "Compose-Multiplatform-Script") if (token != null) { connection.setRequestProperty("Authorization", "Bearer $token") } connection.getInputStream().use { it.bufferedReader().readText() } } fun exponentialRetry(block: () -> T): T { val exception = IOException() val retriesMinutes = listOf(1, 5, 15, 30, 60) for (retriesMinute in retriesMinutes) { try { return block() } catch (e: IOException) { e.printStackTrace() exception.addSuppressed(e) println("Retry in $retriesMinute minutes") Thread.sleep(retriesMinute.toLong() * 60 * 1000) } } throw exception } typealias StringList = List fun StringList.split(shouldSplit: (line: String) -> Boolean): List = fold(initial = mutableListOf>()) { acc, it -> if (acc.isEmpty() || shouldSplit(it)) { acc.add(mutableListOf()) } acc.last().add(it) acc } class Cache(private val create: (K) -> V) { private val map = mutableMapOf() operator fun get(key: K): V = map.getOrPut(key) { create(key) } } //endregion