From 7afa40590d630cbe86a47601c3b310f07ab07506 Mon Sep 17 00:00:00 2001 From: Sergey Mashkov Date: Mon, 3 Aug 2015 19:09:57 +0300 Subject: [PATCH] KT-8708 Function to strip leading whitespace (stripMargin, trimMargin, stripIndent?) --- libraries/stdlib/src/kotlin/text/Strings.kt | 80 ++++++++++++++ libraries/stdlib/test/text/StringTest.kt | 114 +++++++++++++++++++- 2 files changed, 193 insertions(+), 1 deletion(-) diff --git a/libraries/stdlib/src/kotlin/text/Strings.kt b/libraries/stdlib/src/kotlin/text/Strings.kt index 17e5db2b61a..2889613e8fb 100644 --- a/libraries/stdlib/src/kotlin/text/Strings.kt +++ b/libraries/stdlib/src/kotlin/text/Strings.kt @@ -1,5 +1,6 @@ package kotlin +import java.lang import java.util.NoSuchElementException import kotlin.platform.platformName import kotlin.text.MatchResult @@ -791,6 +792,85 @@ public fun String.contains(seq: CharSequence, ignoreCase: Boolean = false): Bool public fun String.contains(char: Char, ignoreCase: Boolean = false): Boolean = indexOf(char, ignoreCase = ignoreCase) >= 0 +private fun getIndentFunction(indent: String) = when { + indent.isEmpty() -> { line: String -> line } + else -> { line: String -> "$indent$line" } +} + +private inline fun List.reindent(length: Int, indentAddFunction: (String) -> String, indentCutFunction: (String) -> String) = lastIndex.let { lastLineIndex -> + withIndex() + .dropWhile { it.index == 0 && it.value.isBlank() } + .takeWhile { it.index != lastLineIndex || it.value.isNotBlank() } + .map { indentCutFunction(it.value) } + .map(indentAddFunction) + .joinTo(StringBuilder(length), "\n") + .toString() +} + +/** + * Trims leading whitespace characters followed by [marginPrefix] from every line of a source string and removes + * first and last lines if they are blank (notice difference blank vs empty). + * Do not preserves original line endings + * + * Example + * ```kotlin + * assertEquals("ABC\n123\n456", """ABC + * |123 + * |456""".trimMargin()) + * ``` + * + * @param marginPrefix characters to be used as a margin delimiter. Default is `|` (pipe character) + * @return the trimmed String + * @see kotlin.isWhitespace + * @since M13 + */ +public fun String.trimMargin(marginPrefix: String = "|", newIndent: String = ""): String { + require(marginPrefix.isNotBlank()) { "marginPrefix should be non blank string but it is '$marginPrefix'" } + + return lines().reindent(length(), getIndentFunction(newIndent)) { line -> + val firstNonWhitespaceIndex = line.indexOfFirst { !it.isWhitespace() } + + when { + firstNonWhitespaceIndex == -1 -> line + line.startsWith(marginPrefix, firstNonWhitespaceIndex) -> line.substring(firstNonWhitespaceIndex + marginPrefix.length()) + else -> line + } + } +} + +/** + * Detects a common minimal indent of all the input lines, removes it from every line and also removes first and last + * lines if they are blank (notice difference blank vs empty). + * Note that blank lines do not affect detected indent level. + * Please keep in mind that if there are non-blank lines with no leading whitespace characters (no indent at all) then the + * common indent is 0 so this function may do nothing so it is recommended to keep first line empty (will be dropped). + * + * Do not preserves original line endings + * + * Example + * ```kotlin + * assertEquals("ABC\n123\n456", """ + * ABC + * 123 + * 456""".trimMargin()) + * ``` + * + * @return deindented String + * @see kotlin.isBlank + * @since M13 + */ +public fun String.trimIndent(newIndent: String = ""): String { + val lines = lines() + + val minCommonIndent = lines + .filter { it.isNotBlank() } + .map { it.indentWidth() } + .min() ?: 0 + + return lines.reindent(length(), getIndentFunction(newIndent)) { line -> line.drop(minCommonIndent) } +} + +private fun String.indentWidth(): Int = indexOfFirst { !it.isWhitespace() }.let { if (it == -1) length() else it } // rangesDelimitedBy diff --git a/libraries/stdlib/test/text/StringTest.kt b/libraries/stdlib/test/text/StringTest.kt index 91cfb9d652f..a83122e47a3 100644 --- a/libraries/stdlib/test/text/StringTest.kt +++ b/libraries/stdlib/test/text/StringTest.kt @@ -1,6 +1,9 @@ package test.text -import kotlin.test.* +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.test.fails import org.junit.Test as test // could not be local inside isEmptyAndBlank, because non-toplevel declarations is not yet supported for JS @@ -555,5 +558,114 @@ class StringTest { assertEquals("-test", "test".replaceFirst("", "-")) } + test fun trimMargin() { + // WARNING + // DO NOT REFORMAT AS TESTS MAY FAIL DUE TO INDENTATION CHANGE + assertEquals("ABC\n123\n456", """ABC + |123 + |456""".trimMargin()) + + assertEquals(" ABC\n 123\n 456", """ABC + |123 + |456""".trimMargin(newIndent = " ")) + + assertEquals("ABC \n123\n456", """ABC${" "} + |123 + |456""".trimMargin()) + + assertEquals(" ABC\n123\n456", """ ABC + >>123 + ${"\t"}>>456""".trimMargin(">>")) + + assertEquals("", "".trimMargin()) + + assertEquals("", """ + """.trimMargin()) + + assertEquals("", """ + |""".trimMargin()) + + assertEquals("", """ + | + """.trimMargin()) + + assertEquals(" a", """ + | a + """.trimMargin()) + + assertEquals(" a", """ + | a""".trimMargin()) + + assertEquals(" a", """ | a + """.trimMargin()) + + assertEquals(" a", """ | a""".trimMargin()) + + assertEquals("\u0000|ABC", "${"\u0000"}|ABC".trimMargin()) + } + + test fun trimIndent() { + // WARNING + // DO NOT REFORMAT AS TESTS MAY FAIL DUE TO INDENTATION CHANGE + + assertEquals("123", """ + 123 + """.trimIndent()) + + assertEquals("123\n 456", """ + 123 + 456 + """.trimIndent()) + + assertEquals(" 123\n456", """ + 123 + 456 + """.trimIndent()) + + assertEquals(" 123\n 456", """ + 123 + 456 + """.trimIndent(newIndent = " ")) + + assertEquals(" 123\n456", """ + 123 + 456""".trimIndent()) + + assertEquals(" ", """ +${" "} + """.trimIndent()) + + val deindented = """ + ,. + ,. _ oo. `88P + ]88b ,o. d88. ]88b ' + 888 _ Y888o888 d88P _ _ + 888 ,888 `Y88888o_ ,888 d88b d88._____ + 888,888P ,oooooo. ;888888b.]88P 888' d888888888p + 888888P d88888888. J88b'YPP ]88b ,888 d888P'''888. + 8888P' ]88P `888 d88[ d88P ]88b 888' Y88b + 8888p ]88b 888 888 d88[ 888 888. `888 + ,88888b 888[ 888 888. d88[ 888. Y88b Y88[ + d88PY88b `888L,d88P Y88b Y88b ]88b `888 888' + 888 Y88b Y88888P 888. 888. 888. Y88b `88P + d88P 888 `'P' Y888. `888. `88P `Y8P ' + Y8P' ' `YP Y8P' ' + + ____ dXp _ _ _________ + ddXXXXXp XXP ,XX dXb Yo.XXXXXX ,oooooo. + X'L_oXXP XX' XX[ dXb dXb YPPPPXXX' + XYXXXXX ]XX dXb dXb dX8Xooooo dXXP + XXb`YYXXo. YXXo_ dXP dXP YXb'''''' ,XXP' + `XX `YYXb `YXXXXP XX[ ]XX ,XX' + YXb YXb `'' XXXXooL `XX._____ `XXXXXXXXooooo. + `XP ' '''''' YPXXXXXX' ''''''`''YPPP + """.trimIndent() + + assertEquals(23, deindented.lines().size()) + val indents = deindented.lines().map { "^\\s*".toRegex().match(it)!!.value.length() } + assertEquals(0, indents.min()) + assertEquals(42, indents.max()) + assertEquals(1, deindented.lines().count { it.isEmpty() }) + } }