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

commonMain.com.bkahlert.kommons.debug.render.kt Maven / Gradle / Ivy

There is a newer version: 2.8.0
Show newest version
package com.bkahlert.kommons.debug

import com.bkahlert.kommons.VALUE_RANGE
import com.bkahlert.kommons.debug.Compression.Always
import com.bkahlert.kommons.debug.Compression.Auto
import com.bkahlert.kommons.debug.Compression.Never
import com.bkahlert.kommons.debug.CustomToString.Ignore
import com.bkahlert.kommons.debug.CustomToString.IgnoreForPlainCollectionsAndMaps
import com.bkahlert.kommons.debug.Typing.FullyTyped
import com.bkahlert.kommons.debug.Typing.SimplyTyped
import com.bkahlert.kommons.debug.Typing.Untyped
import com.bkahlert.kommons.quoted
import com.bkahlert.kommons.text.LineSeparators.isMultiline
import com.bkahlert.kommons.toHexadecimalString

/** Renders this object using [settings]. */
public fun Any?.render(settings: RenderingSettings = RenderingSettings.Default): String =
    buildString { RenderingContext(settings).renderTo(this, this@render) }

/** Renders this object using the [RenderingSettings] built with the specified [template] and [init]. */
public fun Any?.render(template: RenderingSettings = RenderingSettings.Default, init: RenderingSettingsBuilder.() -> Unit): String =
    render(RenderingSettings.build(template, init))

/** Renders this object using the optionally specified [settings] to the specified [out]. */
public fun Any?.renderTo(out: StringBuilder, settings: RenderingSettings = RenderingSettings.Default) {
    RenderingContext(settings).renderTo(out, this)
}

/** Renders this object using the [RenderingSettings] built with the specified [template] and [init] to the specified [out]. */
public fun Any?.renderTo(out: StringBuilder, template: RenderingSettings = RenderingSettings.Default, init: RenderingSettingsBuilder.() -> Unit) {
    renderTo(out, RenderingSettings.build(template, init))
}

/** Settings used to specify how rendering an object takes place. */
public interface RenderingSettings {
    /** If and how type information should be included. */
    public val typing: Typing

    /** If and how to compress the output. */
    public val compression: Compression

    /** How to handle overridden [Any.toString] implementations. */
    public val customToString: CustomToString

    /** Which object-property pairs to render. */
    public val filterProperties: PropertyFilter

    public companion object {
        /** Default [RenderingSettings] */
        public val Default: RenderingSettings = object : RenderingSettings {
            override var typing: Typing = Untyped
            override var compression: Compression = Auto()
            override var customToString: CustomToString = IgnoreForPlainCollectionsAndMaps
            override var filterProperties: PropertyFilter = { _: Any?, _: String? -> true }
        }

        /** Builds a [RenderingSettings] using the specified [init] and optional [template]. */
        public fun build(
            template: RenderingSettings = Default,
            init: (RenderingSettingsBuilder.() -> Unit)? = null,
        ): RenderingSettings =
            (object : RenderingSettingsBuilder {
                val settings: MutableMap = mutableMapOf()
                override var typing: Typing by settings.withDefault { template.typing }
                override var compression: Compression by settings.withDefault { template.compression }
                override var customToString: CustomToString by settings.withDefault { template.customToString }
                override var filterProperties: PropertyFilter by settings.withDefault { template.filterProperties }
                override fun filterProperties(predicate: PropertyFilter) {
                    filterProperties = predicate
                }
            }).apply { init?.invoke(this) }
    }
}

/** Serialization option that specifies if and how type information should be included. */
public sealed class Typing {
    /** Omit type information */
    public object Untyped : Typing()

    /** Include simplified type information */
    public object SimplyTyped : Typing()

    /** Include all available type information */
    public object FullyTyped : Typing()
}

/** Serialization option that specifies if and how to compress the output. */
public sealed class Compression {
    /** Always compress output */
    public object Always : Compression()

    /** Never compress output */
    public object Never : Compression()

    /** Compress output if the output doesn't exceed the specified [maxLength] (default: 60). */
    public class Auto(
        /** The maximum length compressed output is allowed to have. */
        public val maxLength: Int = 60,
    ) : Compression()
}

/** Serialization option that specifies how to handle overridden [Any.toString] implementations. */
public sealed class CustomToString {
    /** Uses an overridden [Any.toString] for all typed but plain collections and maps. */
    public object IgnoreForPlainCollectionsAndMaps : CustomToString()

    /** Ignores an eventually existing [Any.toString] implementation. */
    public object Ignore : CustomToString()
}

/** Filter that specifies which object-property pairs to render. */
public typealias PropertyFilter = (receiver: Any?, property: String?) -> Boolean

/** Builder for [RenderingSettings] */
public interface RenderingSettingsBuilder : RenderingSettings {
    public override var typing: Typing
    public override var compression: Compression
    public override var customToString: CustomToString
    public override var filterProperties: PropertyFilter

    /** @see [RenderingSettings.filterProperties] */
    public fun filterProperties(predicate: PropertyFilter)
}

internal class RenderingContext(
    private val settings: RenderingSettings,
    val rendered: MutableSet = mutableSetOf(),
) : RenderingSettings by settings {
    internal constructor(
        typing: Typing = Untyped,
        compression: Compression = Auto(),
        customToString: CustomToString = IgnoreForPlainCollectionsAndMaps,
        rendered: MutableSet = mutableSetOf(),
        filterProperties: PropertyFilter = { _, _ -> true },
    ) : this(RenderingSettings.build {
        this.typing = typing
        this.compression = compression
        this.customToString = customToString
        this.filterProperties = filterProperties
    }, rendered)

    val CharSequence.needsCompression: Boolean
        get() = when (val compression = settings.compression) {
            is Auto -> length > compression.maxLength
            else -> false
        }

    fun copy(
        init: (RenderingSettingsBuilder.() -> Unit)?,
        isolated: Boolean = false,
    ): RenderingContext = if (init != null || isolated) RenderingContext(
        settings = init?.let { RenderingSettings.build(settings, it) } ?: settings,
        rendered = if (isolated) rendered.toMutableSet() else rendered,
    ) else this

    fun render(
        init: (RenderingSettingsBuilder.() -> Unit)? = null,
        isolated: Boolean = false,
        block: RenderingContext.(StringBuilder) -> Unit,
    ): String = buildString { copy(init, isolated).block(this) }

    fun renderTo(out: StringBuilder, obj: Any?) {
        if (!filterProperties(obj, null)) return
        if (obj == null) {
            out.append("null")
        } else {
            when (typing) {
                Untyped -> {}
                SimplyTyped -> {
                    out.append("!")
                    obj.renderTypeTo(out, simplified = true)
                    out.append(" ")
                }

                FullyTyped -> {
                    out.append("!")
                    obj.renderTypeTo(out, simplified = false)
                    out.append(" ")
                }
            }
            when (obj) {
                is CharSequence -> renderStringTo(out, obj)

                is Boolean, is Char, is Float, is Double,
                is UByte, is UShort, is UInt, is ULong,
                is Byte, is Short, is Int, is Long,
                -> renderPrimitiveTo(out, obj)

                is BooleanArray, is CharArray, is FloatArray, is DoubleArray,
                is UByteArray, is UShortArray, is UIntArray, is ULongArray,
                is ByteArray, is ShortArray, is IntArray, is LongArray,
                -> renderPrimitiveArrayTo(out, obj)

                is Array<*> -> renderArrayTo(out, obj)

                else -> {
                    if (rendered.contains(obj)) {
                        out.append("<")
                        obj.renderTypeTo(out)
                        out.append("@")
                        out.append(obj.hashCode())
                        out.append(">")
                    } else {
                        rendered.add(obj)

                        when {
                            obj is Collection<*> && obj.isPlain -> renderCollectionTo(out, obj)
                            obj is Map<*, *> && obj.isPlain -> renderObjectTo(out, obj)
                            else -> {
                                val likelyRenderInvokingToString = calledBy("toString", "render", "renderTo")

                                when (customToString) {
                                    IgnoreForPlainCollectionsAndMaps -> if (likelyRenderInvokingToString) null else obj.toCustomStringOrNull()
                                    Ignore -> null
                                }
                                    ?.also { out.append(it) }
                                    ?: kotlin.runCatching { renderObjectTo(out, obj) }
                                        .recoverCatching { out.append("<$obj>") }
                                        .recoverCatching { out.append("") }
                            }
                        }
                    }
                }
            }
        }
    }
}

internal expect inline fun calledBy(function: String, vararg callers: String): Boolean

internal fun renderString(
    string: CharSequence,
    template: RenderingSettings = RenderingSettings.Default,
    init: (RenderingSettingsBuilder.() -> Unit)? = null,
): String = buildString { RenderingContext(RenderingSettings.build(template, init)).renderStringTo(this, string) }

internal fun RenderingContext.renderStringTo(out: StringBuilder, string: CharSequence) {
    when (compression) {
        Always -> {
            out.append(string.quoted)
        }

        Never, is Auto -> {
            if (string.isMultiline()) {
                out.append("\"\"\"\n")
                out.append(string.toString())
                out.append("\n\"\"\"")
            } else {
                out.append(string.quoted)
            }
        }
    }
}

internal fun renderPrimitive(primitive: Any): String =
    buildString { renderPrimitiveTo(this, primitive) }

private fun Byte.toDecimalAndHexadecimalString() = "${toInt()}/0x${toHexadecimalString()}"
private fun UByte.toDecimalAndHexadecimalString() = "${toInt()}/0x${toHexadecimalString()}"

internal fun renderPrimitiveTo(out: StringBuilder, primitive: Any) {
    when (primitive) {
        is Boolean -> out.append(primitive)
        is kotlin.Char -> out.append(primitive)
        is Float -> if (primitive.toLong() in Byte.VALUE_RANGE && primitive.mod(1.0f) == 0.0f)
            out.append(primitive.toInt().toByte().toDecimalAndHexadecimalString()) else out.append(primitive)

        is Double -> if (primitive.toLong() in Byte.VALUE_RANGE && primitive.mod(1.0) == 0.0)
            out.append(primitive.toInt().toByte().toDecimalAndHexadecimalString()) else out.append(primitive)

        is UByte -> out.append(primitive.toDecimalAndHexadecimalString())
        is UShort -> if (primitive in UByte.VALUE_RANGE) out.append(primitive.toUByte().toDecimalAndHexadecimalString()) else out.append(primitive)
        is UInt -> if (primitive in UByte.VALUE_RANGE) out.append(primitive.toUByte().toDecimalAndHexadecimalString()) else out.append(primitive)
        is ULong -> if (primitive in UByte.VALUE_RANGE) out.append(primitive.toUByte().toDecimalAndHexadecimalString()) else out.append(primitive)

        is Byte -> out.append(primitive.toDecimalAndHexadecimalString())
        is Short -> if (primitive in Byte.VALUE_RANGE) out.append(primitive.toByte().toDecimalAndHexadecimalString()) else out.append(primitive)
        is Int -> if (primitive in Byte.VALUE_RANGE) out.append(primitive.toByte().toDecimalAndHexadecimalString()) else out.append(primitive)
        is Long -> if (primitive in Byte.VALUE_RANGE) out.append(primitive.toByte().toDecimalAndHexadecimalString()) else out.append(primitive)
        else -> out.append("⁉️")
    }
}

internal fun renderPrimitiveArray(primitiveArray: Any): String =
    buildString { renderPrimitiveArrayTo(this, primitiveArray) }

internal fun renderPrimitiveArrayTo(out: StringBuilder, primitiveArray: Any) {
    when (primitiveArray) {
        is BooleanArray -> renderPrimitivesTo(out, primitiveArray.iterator())
        is CharArray -> renderPrimitivesTo(out, primitiveArray.iterator())
        is FloatArray -> renderPrimitivesTo(out, primitiveArray.iterator())
        is DoubleArray -> renderPrimitivesTo(out, primitiveArray.iterator())

        is UByteArray -> out.append("0x" + primitiveArray.toHexadecimalString())
        is UShortArray -> renderPrimitivesTo(out, primitiveArray.iterator())
        is UIntArray -> renderPrimitivesTo(out, primitiveArray.iterator())
        is ULongArray -> renderPrimitivesTo(out, primitiveArray.iterator())

        is ByteArray -> out.append("0x" + primitiveArray.toHexadecimalString())
        is ShortArray -> renderPrimitivesTo(out, primitiveArray.iterator())
        is IntArray -> renderPrimitivesTo(out, primitiveArray.iterator())
        is LongArray -> renderPrimitivesTo(out, primitiveArray.iterator())
        else -> out.append("⁉️")
    }
}

private fun renderPrimitivesTo(out: StringBuilder, primitives: Iterator<*>) {
    out.append('[')
    primitives.withIndex().forEach { (index, value) ->
        if (index != 0) out.append(", ")
        RenderingContext(customToString = Ignore).renderTo(out, value)
    }
    out.append(']')
}

internal fun renderArray(
    array: Array<*>,
    template: RenderingSettings = RenderingSettings.Default,
    init: (RenderingSettingsBuilder.() -> Unit)? = null,
): String = buildString { RenderingContext(RenderingSettings.build(template, init)).renderArrayTo(this, array) }

internal fun RenderingContext.renderArrayTo(out: StringBuilder, array: Array<*>) =
    renderObjectsTo(out, array.asList())


internal fun renderCollection(
    collection: Collection<*>,
    template: RenderingSettings = RenderingSettings.Default,
    init: (RenderingSettingsBuilder.() -> Unit)? = null,
): String = buildString { RenderingContext(RenderingSettings.build(template, init)).renderCollectionTo(this, collection) }

internal fun RenderingContext.renderCollectionTo(out: StringBuilder, collection: Collection<*>) =
    renderObjectsTo(out, collection.toList())


private fun RenderingContext.renderObjectsTo(out: StringBuilder, objects: List<*>) {
    when (compression) {
        Always -> renderCompressedObjectsTo(out, objects)
        Never -> renderNonCompressedObjectsTo(out, objects)
        is Auto -> {
            render(isolated = true) { renderCompressedObjectsTo(it, objects) }
                .takeUnless { it.needsCompression }
                ?.also { out.append(it); rendered.addAll(objects.filterNotNull()) }
                ?: renderNonCompressedObjectsTo(out, objects)
        }
    }
}

private fun RenderingContext.renderCompressedObjectsTo(out: StringBuilder, objects: List<*>) {
    out.append("[")
    val filteredObjects = objects
        .filterIndexed { index, _ -> filterProperties(objects, "$index") }
        .filter { filterProperties(it, null) }
    if (filteredObjects.isNotEmpty()) out.append(" ")
    filteredObjects.forEachIndexed { index, value ->
        if (index > 0) out.append(", ")
        copy({ compression = Always }).renderTo(out, value)
    }
    if (filteredObjects.isNotEmpty()) out.append(" ")
    out.append("]")
}

private fun RenderingContext.renderNonCompressedObjectsTo(out: StringBuilder, objects: List<*>) {
    val indent = "    "
    out.append("[")
    val filteredObjects = objects
        .filterIndexed { index, _ -> filterProperties(objects, "$index") }
        .filter { filterProperties(it, null) }
    if (filteredObjects.isNotEmpty()) out.append("\n")
    filteredObjects.forEachIndexed { index, value ->
        if (index > 0) out.append(",\n")
        out.append(indent)
        val renderedElement = render({ compression = Never }, isolated = false) { renderTo(it, value) }.prependIndent(indent)
        out.append(renderedElement, indent.length, renderedElement.length)
    }
    if (filteredObjects.isNotEmpty()) out.append("\n")
    out.append("]")
}

internal fun renderObject(
    obj: Any,
    template: RenderingSettings = RenderingSettings.Default,
    init: (RenderingSettingsBuilder.() -> Unit)? = null,
): String = buildString { RenderingContext(RenderingSettings.build(template, init)).renderObjectTo(this, obj) }

internal fun RenderingContext.renderObjectTo(out: StringBuilder, obj: Any) {
    when (compression) {
        Always -> renderCompressedObjectTo(out, obj)
        Never -> renderNonCompressedObjectTo(out, obj)
        is Auto -> {
            render(isolated = true) { renderCompressedObjectTo(it, obj) }
                .takeUnless { it.needsCompression }
                ?.also { out.append(it); rendered.add(obj) }
                ?: renderNonCompressedObjectTo(out, obj)
        }
    }
}

private fun RenderingContext.renderCompressedObjectTo(out: StringBuilder, obj: Any) {
    out.append("{")
    val entries = stringKeyedEntries(obj)
    if (entries.isNotEmpty()) out.append(" ")
    entries.forEachIndexed { index, (key, value) ->
        if (index > 0) out.append(", ")

        out.append(key)
        out.append(": ")

        val renderedValue = render({ compression = Always }) { renderTo(it, value) }
        out.append(renderedValue)
    }
    if (entries.isNotEmpty()) out.append(" ")
    out.append("}")
}

private fun RenderingContext.renderNonCompressedObjectTo(out: StringBuilder, obj: Any) {
    val keyIndent = "    "
    out.append("{")
    val entries = stringKeyedEntries(obj)
    if (entries.isNotEmpty()) out.append("\n")
    entries.forEachIndexed { index, (key, value) ->
        if (index > 0) out.append(",\n")
        out.append(keyIndent)

        out.append(key)
        out.append(": ")

        val valueIndent = " ".repeat(keyIndent.length + key.length + 2)
        val renderedValue = render({ compression = Never }) { renderTo(it, value) }.prependIndent(valueIndent)
        out.append(renderedValue, valueIndent.length, renderedValue.length)
    }
    if (entries.isNotEmpty()) out.append("\n")
    out.append("}")
}

private fun RenderingContext.stringKeyedEntries(obj: Any) =
    (if (obj is Map<*, *>) obj.entries else obj.properties.entries)
        .map { (key, value) ->
            val renderedKey =
                if (key is CharSequence) key.quoted.removeSurrounding("\"")
                else render({ compression = Always }) { renderTo(it, key) }
            renderedKey to value
        }
        .filter { (key, _) -> filterProperties(obj, key) }
        .filter { (_, value) -> filterProperties(value, null) }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy