All Downloads are FREE. Search and download functionalities are using the official Maven repository.

toolkit.utils.common-utils.37.0.0.source-code.Extensions.kt Maven / Gradle / Ivy

Go to download

Part of the OSS Review Toolkit (ORT), a suite to automate software compliance checks.

There is a newer version: 44.0.0
Show newest version
/*
 * Copyright (C) 2017 The ORT Project Authors (see )
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *
 * SPDX-License-Identifier: Apache-2.0
 * License-Filename: LICENSE
 */

@file:Suppress("TooManyFunctions")

package org.ossreviewtoolkit.utils.common

import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.util.ClassUtil

import java.io.File
import java.io.IOException
import java.net.URI
import java.nio.file.Files
import java.nio.file.LinkOption
import java.nio.file.attribute.BasicFileAttributes
import java.util.EnumSet
import java.util.Locale

import kotlin.io.path.deleteRecursively

/**
 * Call [also] only if the receiver is null, e.g. for error handling, and return the receiver in any case.
 */
inline fun  T.alsoIfNull(block: (T) -> Unit): T = this ?: also(block)

/**
 * Return a map that associates duplicates as identified by [keySelector] with belonging lists of collection entries.
 */
fun  Collection.getDuplicates(keySelector: (T) -> K): Map> =
    if (this is Set) emptyMap() else groupBy(keySelector).filter { it.value.size > 1 }

/**
 * Return a set of duplicate entries of a collection.
 */
fun  Collection.getDuplicates(): Set = getDuplicates { it }.keys

/**
 * Collapse consecutive values to a list of pairs that each denote a range. A single value is represented as a
 * range whose first and last elements are equal.
 */
fun Collection.collapseToRanges(): List> {
    if (isEmpty()) return emptyList()

    val ranges = mutableListOf>()

    val sortedValues = toSortedSet()
    val rangeBreaks = sortedValues.zipWithNext { a, b -> (a to b).takeIf { b != a + 1 } }.filterNotNull()

    var current = sortedValues.first()

    rangeBreaks.mapTo(ranges) { (last, first) ->
        (current to last).also { current = first }
    }

    ranges += current to sortedValues.last()

    return ranges
}

/**
 * Return a string of common-separated ranges as denoted by the list of pairs.
 */
fun Collection>.prettyPrintRanges(): String =
    joinToString { (startValue, endValue) ->
        if (startValue == endValue) startValue.toString() else "$startValue-$endValue"
    }

/**
 * Format this [Double] as a string with the provided number of [decimalPlaces].
 */
fun Double.format(decimalPlaces: Int = 2) = "%.${decimalPlaces}f".format(this)

/**
 * Return an [EnumSet] that contains the elements of [this] and [other].
 */
operator fun > EnumSet.plus(other: EnumSet): EnumSet = EnumSet.copyOf(this).apply { addAll(other) }

/**
 * If the SHELL environment variable is set, return the absolute file with a leading "~" expanded to the current user's
 * home directory, otherwise return just the absolute file.
 */
fun File.expandTilde(): File = File(path.expandTilde()).absoluteFile

/**
 * Return true if and only if this file is a symbolic link.
 */
fun File.isSymbolicLink(): Boolean =
    runCatching {
        // Note that we cannot use exists() to check beforehand whether a symbolic link exists to avoid a
        // NoSuchFileException to be thrown as it returns "false" e.g. for dangling Windows junctions.
        Files.readAttributes(toPath(), BasicFileAttributes::class.java, LinkOption.NOFOLLOW_LINKS).let {
            it.isSymbolicLink || (Os.isWindows && it.isOther)
        }
    }.getOrDefault(false)

/**
 * Resolve the file to the real underlying file. In contrast to Java's [File.getCanonicalFile], this also works to
 * resolve symbolic links on Windows.
 */
fun File.realFile(): File = toPath().toRealPath().toFile()

/**
 * Delete a directory recursively without following symbolic links (Unix) or junctions (Windows). If a [baseDirectory]
 * is provided, all empty parent directories along the path to [baseDirectory] are also deleted; [baseDirectory] itself
 * is not deleted. Throws an [IOException] if a directory or file could not be deleted.
 */
fun File.safeDeleteRecursively(baseDirectory: File? = null) {
    // Note that Kotlin's `File.deleteRecursively()` extension function does not work here to delete files with
    // unmappable characters in their names, so use the `Path.deleteRecursively()` extension function instead.
    toPath().deleteRecursively()

    if (baseDirectory == this) {
        safeMkdirs()
        return
    }

    if (baseDirectory != null) {
        var parent = parentFile
        while (parent != null && parent != baseDirectory && parent.delete()) {
            parent = parent.parentFile
        }
    }
}

/**
 * Create all missing intermediate directories without failing if any already exists. Returns the [File] it was called
 * on if successful, otherwise throws an [IOException].
 */
fun File.safeMkdirs(): File {
    // Do not blindly trust mkdirs() returning "false" as it can fail for edge-cases like
    // File(File("/tmp/parent1/parent2"), "/").mkdirs() if parent1 does not exist, although the directory is
    // successfully created.
    if (isDirectory || mkdirs() || isDirectory) {
        return this
    }

    throw IOException("Could not create directory '$absolutePath'.")
}

/**
 * Search [this] directory upwards towards the root until a contained subdirectory called [searchDirName] is found and
 * return the parent of [searchDirName], or return null if no such directory is found.
 */
fun File.searchUpwardsForSubdirectory(searchDirName: String): File? {
    if (!isDirectory) return null

    var currentDir: File? = absoluteFile

    while (currentDir != null && !currentDir.resolve(searchDirName).isDirectory) {
        currentDir = currentDir.parentFile
    }

    return currentDir
}

/**
 * Get the size of this [File] in mebibytes (MiB) with two decimal places as [String].
 */
val File.formatSizeInMib: String get() = "${length().bytesToMib().format()} MiB"

/**
 * Construct a "file:" URI in a safe way by never using a null authority for wider compatibility.
 */
fun File.toSafeUri(): URI {
    val fileUri = toURI()
    return URI("file", "", fileUri.path, fileUri.query, fileUri.fragment)
}

/**
 * Return the bytes equal to this [Int] number of kibibytes (KiB).
 */
inline val Int.kibibytes get(): Long = this * 1024L

/**
 * Return the bytes equal to this [Int] number of mebibytes (MiB).
 */
inline val Int.mebibytes get(): Long = kibibytes * 1024L

/**
 * Return the bytes equal to this [Int] number of gibibytes (GiB).
 */
inline val Int.gibibytes get(): Long = mebibytes * 1024L

/**
 * Return the next value in the iteration, or null if there is no next value.
 */
fun  Iterator.nextOrNull() = if (hasNext()) next() else null

/*
 * Convenience function for [JsonNode] that returns an empty iterator if [JsonNode.fieldNames] is called on a null
 * object, or the field names otherwise.
 */
fun JsonNode?.fieldNamesOrEmpty(): Iterator = this?.fieldNames() ?: ClassUtil.emptyIterator()

/*
 * Convenience function for [JsonNode] that returns an empty iterator if [JsonNode.fields] is called on a null object,
 * or the fields otherwise.
 */
fun JsonNode?.fieldsOrEmpty(): Iterator> = this?.fields() ?: ClassUtil.emptyIterator()

/**
 * Return true if and only if this [JsonNode]
 */
fun JsonNode.isNotEmpty(): Boolean = !isEmpty

/**
 * Convenience function for [JsonNode] that returns an empty string if [JsonNode.textValue] is called on a null object,
 * or the text value is null.
 */
fun JsonNode?.textValueOrEmpty(): String = this?.textValue().orEmpty()

/**
 * Merge two maps by iterating over the combined key set of both maps and applying [operation] to the entries for the
 * same key. Arguments passed to [operation] can be null if there is no entry for a key in the respective map.
 */
inline fun  Map.zip(other: Map, operation: (V?, V?) -> W): Map =
    (keys + other.keys).associateWith { key ->
        operation(this[key], other[key])
    }

/**
 * Merge two maps by iterating over the combined key set of both maps and applying [operation] to the entries for the
 * same key. If there is no entry for a key in one of the maps, [default] is used as the value for that map.
 */
inline fun  Map.zipWithDefault(other: Map, default: V, operation: (V, V) -> W): Map =
    (keys + other.keys).associateWith { key ->
        operation(this[key] ?: default, other[key] ?: default)
    }

/**
 * Merge two maps which have collections as values by creating the combined key set of both maps and merging the
 * collections. If there is no entry for a key in one of the maps, the value from the other map is used.
 */
fun , T> Map.zipWithCollections(other: Map): Map =
    zip(other) { a, b ->
        when {
            // When iterating over the combined key set, not both values can be null.
            a == null -> checkNotNull(b)
            b == null -> a
            else -> {
                @Suppress("UNCHECKED_CAST")
                (a + b) as V
            }
        }
    }

/**
 * Merge two maps which have sets as values by creating the combined key set of both maps and merging the sets. If there
 * is no entry for a key in one of the maps, the value from the other map is used.
 */
@JvmName("zipWithSets")
fun , T> Map.zipWithCollections(other: Map): Map =
    zip(other) { a, b ->
        when {
            // When iterating over the combined key set, not both values can be null.
            a == null -> checkNotNull(b)
            b == null -> a
            else -> {
                @Suppress("UNCHECKED_CAST")
                (a + b) as V
            }
        }
    }

/**
 * Converts this [Number] from bytes to mebibytes (MiB).
 */
fun Number.bytesToMib(): Double = toDouble() / 1.mebibytes

/**
 * Trim leading and trailing whitespace, and collapse consecutive inner whitespace to a single space.
 */
fun String.collapseWhitespace() = trim().replace(CONSECUTIVE_WHITESPACE_REGEX, " ")

private val CONSECUTIVE_WHITESPACE_REGEX = Regex("\\s+")

/**
 * Return the string encoded for safe use as a file name or [emptyValue] encoded for safe use as a file name, if this
 * string is empty. Throws an exception if [emptyValue] is empty.
 */
fun String.encodeOr(emptyValue: String): String {
    require(emptyValue.isNotEmpty())

    return ifEmpty { emptyValue }.fileSystemEncode()
}

/**
 * Return the string encoded for safe use as a file name or "unknown", if the string is empty.
 */
fun String.encodeOrUnknown(): String = encodeOr("unknown")

/**
 * If the SHELL environment variable is set, return the string with a leading "~" expanded to the current user's home
 * directory, otherwise return the string unchanged.
 */
fun String.expandTilde(): String =
    if (Os.env["SHELL"] != null) {
        replace(Regex("^~"), Regex.escapeReplacement(System.getProperty("user.home")))
    } else {
        this
    }

/**
 * Return the string encoded for safe use as a file name. Also limit the length to 255 characters which is the maximum
 * length in most modern filesystems, see
 * [comparison of file system limits](https://en.wikipedia.org/wiki/Comparison_of_file_systems#Limits).
 */
fun String.fileSystemEncode() =
    percentEncode()
        // Percent-encoding does not necessarily encode some characters that are invalid in some file systems, so map
        // these afterwards.
        .replace(Regex("(^\\.|\\.$)"), "%2E")
        .take(255)

/**
 * True if the string is a valid [URI], false otherwise.
 */
fun String.isValidUri() = runCatching { URI(this) }.isSuccess

/**
 * A regular expression matching the non-linux line breaks "\r\n" and "\r".
 */
val NON_LINUX_LINE_BREAKS = Regex("\\r\\n?")

/**
 * Replace "\r\n" and "\r" line breaks with "\n".
 */
fun String.normalizeLineBreaks() = replace(NON_LINUX_LINE_BREAKS, "\n")

/**
 * Return the [percent-encoded](https://en.wikipedia.org/wiki/Percent-encoding) string.
 */
fun String.percentEncode(): String =
    java.net.URLEncoder.encode(this, Charsets.UTF_8)
        // As "encode" above actually performs encoding for forms, not for query strings, spaces are encoded as
        // "+" instead of "%20", so apply the proper mapping here afterwards ("+" in the original string is
        // encoded as "%2B").
        .replace("+", "%20")
        // "*" is a reserved character in RFC 3986.
        .replace("*", "%2A")
        // "~" is an unreserved character in RFC 3986.
        .replace("%7E", "~")

/**
 * Replace any username / password in the URI represented by this [String] with [userInfo]. If [userInfo] is null, the
 * username / password are stripped. Return the unmodified [String] if it does not represent a URI.
 */
fun String.replaceCredentialsInUri(userInfo: String? = null) =
    toUri {
        URI(it.scheme, userInfo, it.host, it.port, it.path, it.query, it.fragment).toString()
    }.getOrDefault(this)

/**
 * Return all substrings that do not contain any whitespace as a list.
 */
fun String.splitOnWhitespace(): List = nonSpaceRegex.findAll(this).mapTo(mutableListOf()) { it.value }

private val nonSpaceRegex = Regex("\\S+")

/**
 * Return this string lower-cased except for the first character which is upper-cased.
 */
fun String.titlecase() = lowercase().uppercaseFirstChar()

/**
 * Return a [Result] that indicates whether the conversion of this [String] to a [URI] was successful.
 */
fun String.toUri() = runCatching { URI(this) }

/**
 * Return a [Result] that indicates whether the conversion of this [String] to a [URI] was successful, and [transform]
 * the [URI] if so.
 */
fun  String.toUri(transform: (URI) -> R) = toUri().mapCatching(transform)

/**
 * Return this string with (nested) single- and double-quotes removed. If [trimWhitespace] is true, then intermediate
 * whitespace is also removed, otherwise it is kept.
 */
fun String.unquote(trimWhitespace: Boolean = true) =
    trim { (trimWhitespace && it.isWhitespace()) || it == '\'' || it == '"' }

/**
 * Return this string with the first character upper-cased.
 */
fun String.uppercaseFirstChar() =
    replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString() }

/**
 * If this string starts with [prefix], return the string without the prefix, otherwise return [missingPrefixValue].
 */
fun String?.withoutPrefix(prefix: String, missingPrefixValue: () -> String? = { null }): String? =
    this?.removePrefix(prefix)?.takeIf { it != this } ?: missingPrefixValue()

/**
 * If this string ends with [suffix], return the string without the suffix, otherwise return [missingSuffixValue].
 */
fun String?.withoutSuffix(suffix: String, missingSuffixValue: () -> String? = { null }): String? =
    this?.removeSuffix(suffix)?.takeIf { it != this } ?: missingSuffixValue()

/**
 * Recursively collect the messages of this [Throwable] and all its causes and join them to a single [String].
 */
fun Throwable.collectMessages(): String {
    fun Throwable.formatCauseAndSuppressedMessages(): String? =
        buildString {
            cause?.also {
                appendLine("Caused by: ${it.javaClass.simpleName}: ${it.message}")
                it.formatCauseAndSuppressedMessages()?.prependIndent()?.also(::append)
            }

            suppressed.forEach {
                appendLine("Suppressed: ${it.javaClass.simpleName}: ${it.message}")
                it.formatCauseAndSuppressedMessages()?.prependIndent()?.also(::append)
            }
        }.trim().takeUnless { it.isEmpty() }

    return listOfNotNull(
        "${javaClass.simpleName}: $message",
        formatCauseAndSuppressedMessages()
    ).joinToString("\n")
}

/**
 * Retrieve query parameters of this [URI]. Multiple values of a single key are supported if they are split by a comma,
 * or if keys are repeated as defined in RFC6570 section 3.2.9, see https://datatracker.ietf.org/doc/rfc6570.
 */
fun URI.getQueryParameters(): Map> {
    if (query == null) return emptyMap()

    return query.split('&')
        .groupBy({ it.substringBefore('=') }, { it.substringAfter('=').split(',') })
        .mapValues { (_, v) -> v.flatten() }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy