Skip to content

Commit e14d99c

Browse files
Nivaldo Bondançafacebook-github-bot
authored andcommitted
trimMargin and trimIndent formatting
Summary: Just because it's a multiline string it doesn't mean it doesn't have to be properly formatted. Following the popular opinion from here https://fb.workplace.com/groups/ktfmt/posts/2287111725069697 Reviewed By: cortinico Differential Revision: D79772886 fbshipit-source-id: 77241b451e36b336cf43d1652fca3d6e228c41c8
1 parent c974608 commit e14d99c

4 files changed

Lines changed: 286 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/).
1515
### Added
1616
- `TrailingCommaManagementStrategy.ONLY_ADD` strategy that does not remove existing trailing commas (https://github.com/facebook/ktfmt/issues/461, https://github.com/facebook/ktfmt/issues/512, https://github.com/facebook/ktfmt/issues/514)
1717
- Formatting of where clauses (https://github.com/facebook/ktfmt/issues/541)
18+
- Special format handling of multiline strings with `trimMargin()` and `trimIndent` (https://github.com/facebook/ktfmt/issues/389)
1819

1920
### Changed
2021
- `FormattingOptions.manageTrailingCommas` was replaced with `FormattingOptions.trailingCommaManagementStrategy`, which also added new `TrailingCommaManagementStrategy.ONLY_ADD` strategy (https://github.com/facebook/ktfmt/issues/461, https://github.com/facebook/ktfmt/issues/512, https://github.com/facebook/ktfmt/issues/514)

core/src/main/java/com/facebook/ktfmt/format/Formatter.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,9 @@ object Formatter {
102102
.let { convertLineSeparators(it) }
103103
.let { sortedAndDistinctImports(it) }
104104
.let { dropRedundantElements(it, options) }
105-
.let { prettyPrint(it, options, "\n") }
105+
.let { prettyPrint(it, options, lineSeparator = "\n") }
106106
.let { addRedundantElements(it, options) }
107+
.let { MultilineStringFormatter(options.continuationIndent).format(it) }
107108
.let { convertLineSeparators(it, checkNotNull(Newlines.guessLineSeparator(kotlinCode))) }
108109
.let { if (shebang.isEmpty()) it else shebang + "\n" + it }
109110
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.facebook.ktfmt.format
18+
19+
import org.jetbrains.kotlin.psi.KtQualifiedExpression
20+
import org.jetbrains.kotlin.psi.KtStringTemplateExpression
21+
import org.jetbrains.kotlin.psi.KtTreeVisitorVoid
22+
import org.jetbrains.kotlin.psi.psiUtil.startOffset
23+
24+
/**
25+
* Adds and removes elements that are not strictly needed in the code, such as semicolons and unused
26+
* imports.
27+
*/
28+
class MultilineStringFormatter(val continuationIndentSize: Int) {
29+
class Candidate(
30+
val isMargin: Boolean,
31+
/* The start offset of the trim method call, right before `.trimX` */
32+
val trimMethodCallOffset: Int,
33+
/* The start offset of the string template, starting with `"""` or `$$"""` */
34+
val stringOffset: Int,
35+
) {
36+
val indentationSuffix: String = if (isMargin) "|" else ""
37+
}
38+
39+
companion object {
40+
private const val TQ = "\"\"\""
41+
}
42+
43+
private val String.indentLevel: Int
44+
get() = length - trimStart().length
45+
46+
fun format(code: String): String {
47+
val candidates = getCandidates(code)
48+
val result = StringBuilder(code)
49+
50+
for (candidate in candidates.sortedByDescending(Candidate::stringOffset)) {
51+
val (indentCount, lines) =
52+
result.substring(candidate.stringOffset, candidate.trimMethodCallOffset).lines().let {
53+
result.substring(0, candidate.stringOffset).lines().last().length to it.dropLast(1)
54+
}
55+
if (lines.size < 2) {
56+
// Single line multiline strings are left alone
57+
continue
58+
}
59+
val indentation = " ".repeat(indentCount)
60+
val continuationIndentation = " ".repeat(continuationIndentSize)
61+
val minIndentForTrimIndent: Int =
62+
lines.subList(1, lines.size).minOf { if (it.isEmpty()) Int.MAX_VALUE else it.indentLevel }
63+
64+
val multiline = StringBuilder()
65+
lines.forEachIndexed { i, line ->
66+
if (i == 0) {
67+
val (before, after) = line.split(TQ, limit = 2)
68+
if (after.isNotEmpty()) {
69+
multiline.append(before)
70+
multiline.appendLine(TQ)
71+
multiline.append(indentation)
72+
multiline.append(candidate.indentationSuffix)
73+
multiline.appendLine(after)
74+
} else {
75+
multiline.appendLine(line)
76+
}
77+
} else {
78+
val lineContents =
79+
if (candidate.isMargin) {
80+
if (i == lines.lastIndex && "|" !in line && line.substringBefore(TQ).isBlank()) {
81+
// trimMargin has a special handling of the final line, where it ignores it if
82+
// it's blank
83+
84+
// Drop last new line character
85+
multiline.deleteAt(multiline.lastIndex)
86+
87+
multiline.appendLine(line.substring(line.indexOf(TQ)))
88+
return@forEachIndexed
89+
}
90+
91+
line.substringAfter("|")
92+
} else {
93+
line.drop(minIndentForTrimIndent)
94+
}
95+
if (candidate.isMargin || line.isNotEmpty()) {
96+
multiline.append(indentation)
97+
}
98+
multiline.append(candidate.indentationSuffix)
99+
multiline.appendLine(lineContents)
100+
}
101+
}
102+
multiline.append(indentation)
103+
multiline.append(continuationIndentation)
104+
result.replace(candidate.stringOffset, candidate.trimMethodCallOffset, multiline.toString())
105+
}
106+
107+
return result.toString()
108+
}
109+
110+
private fun getCandidates(code: String): List<Candidate> {
111+
val file = Parser.parse(code)
112+
val candidates = mutableListOf<Candidate>()
113+
file.accept(
114+
object : KtTreeVisitorVoid() {
115+
override fun visitQualifiedExpression(expression: KtQualifiedExpression) {
116+
val receiver = expression.receiverExpression
117+
if (receiver !is KtStringTemplateExpression) return
118+
val selectorExpression = expression.selectorExpression?.text.orEmpty().trim()
119+
val isTrimMargin = selectorExpression.startsWith("trimMargin(")
120+
val isTrimIndent = selectorExpression.startsWith("trimIndent(")
121+
if (isTrimIndent || isTrimMargin) {
122+
// -1 here to account for the space after the dot
123+
val trimOffset = checkNotNull(expression.selectorExpression).startOffset - 1
124+
val stringOffset = receiver.startOffset
125+
candidates.add(Candidate(isTrimMargin, trimOffset, stringOffset))
126+
}
127+
}
128+
})
129+
return candidates
130+
}
131+
}

core/src/test/java/com/facebook/ktfmt/format/FormatterTest.kt

Lines changed: 152 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1602,7 +1602,7 @@ class FormatterTest {
16021602
Env.Prod,
16031603
)
16041604
val aVar = setOf(Env.Dev, Env.Prod)
1605-
1605+
16061606
"""
16071607
.trimIndent(),
16081608
deduceMaxWidth = false,
@@ -2973,6 +2973,156 @@ class FormatterTest {
29732973
|"""
29742974
.trimMargin())
29752975

2976+
@Test
2977+
fun `multiline trimMargin special handling`() {
2978+
val before =
2979+
"""
2980+
|val margin =
2981+
| ${TQ}example
2982+
| | of
2983+
| | a
2984+
| |multiline
2985+
| | string
2986+
| |$TQ
2987+
| .trimMargin()
2988+
|"""
2989+
.trimMargin()
2990+
val after =
2991+
"""
2992+
|val margin =
2993+
| $TQ
2994+
| |example
2995+
| | of
2996+
| | a
2997+
| |multiline
2998+
| | string
2999+
| |$TQ
3000+
| .trimMargin()
3001+
|"""
3002+
.trimMargin()
3003+
3004+
assertThatFormatting(before).isEqualTo(after)
3005+
}
3006+
3007+
@Test
3008+
fun `multiline trimIndent special handling`() {
3009+
val before =
3010+
"""
3011+
|val indent =
3012+
| ${TQ}example
3013+
| of
3014+
| a
3015+
|
3016+
| multiline
3017+
| string
3018+
| $TQ
3019+
| .trimIndent()
3020+
|"""
3021+
.trimMargin()
3022+
val after =
3023+
"""
3024+
|val indent =
3025+
| $TQ
3026+
| example
3027+
| of
3028+
| a
3029+
|
3030+
| multiline
3031+
| string
3032+
| $TQ
3033+
| .trimIndent()
3034+
|"""
3035+
.trimMargin()
3036+
3037+
assertThatFormatting(before).isEqualTo(after)
3038+
}
3039+
3040+
@Test
3041+
fun `trimIndent and trimMargin formatting does not add new lines`() {
3042+
assertThatFormatting(
3043+
"""
3044+
|val margin =
3045+
| $TQ
3046+
| |is this the end of the line?$TQ
3047+
| .trimMargin()
3048+
|"""
3049+
.trimMargin())
3050+
.isEqualTo(
3051+
"""
3052+
|val margin =
3053+
| $TQ
3054+
| |is this the end of the line?$TQ
3055+
| .trimMargin()
3056+
|"""
3057+
.trimMargin())
3058+
3059+
assertThatFormatting(
3060+
"""
3061+
|val margin =
3062+
| $TQ
3063+
| is this the end of the line?$TQ
3064+
| .trimIndent()
3065+
|"""
3066+
.trimMargin())
3067+
.isEqualTo(
3068+
"""
3069+
|val margin =
3070+
| $TQ
3071+
| is this the end of the line?$TQ
3072+
| .trimIndent()
3073+
|"""
3074+
.trimMargin())
3075+
}
3076+
3077+
@Test
3078+
fun `properly handles trimMargin blank lines at the end of multiline string`() =
3079+
assertThatFormatting(
3080+
"""
3081+
|val margin =
3082+
| $TQ
3083+
| |test
3084+
| string
3085+
| |
3086+
| $TQ
3087+
| .trimMargin()
3088+
|"""
3089+
.trimMargin())
3090+
.isEqualTo(
3091+
"""
3092+
|val margin =
3093+
| $TQ
3094+
| |test
3095+
| | string
3096+
| |$TQ
3097+
| .trimMargin()
3098+
|"""
3099+
.trimMargin())
3100+
3101+
@Test
3102+
fun `handles multi-dollar string`() =
3103+
assertThatFormatting(
3104+
"""
3105+
|val margin =
3106+
| ${"$$"}$TQ
3107+
| |{
3108+
| "${'$'}test": "string"
3109+
| |}
3110+
|
3111+
| $TQ.trimMargin()
3112+
|"""
3113+
.trimMargin())
3114+
.isEqualTo(
3115+
"""
3116+
|val margin =
3117+
| ${"$$"}$TQ
3118+
| |{
3119+
| | "${'$'}test": "string"
3120+
| |}
3121+
| |$TQ
3122+
| .trimMargin()
3123+
|"""
3124+
.trimMargin())
3125+
29763126
@Test
29773127
fun `Trailing spaces in a comment are not preserved`() {
29783128
val before =
@@ -7799,7 +7949,7 @@ class FormatterTest {
77997949
| is Animal.Dog -> animal.feedDog()
78007950
| is Animal.Cat if !animal.mouseHunter -> animal.feedCat()
78017951
| is Animal.Cat if !animal.birdHunter -> animal.feedCat()
7802-
| is Animal.Cat if
7952+
| is Animal.Cat if
78037953
| !animal.birdHunter -> animal.feedCat()
78047954
| is Animal.Cat if (!animal.birdHunter) -> animal.feedCat()
78057955
| else -> println("Unknown animal")

0 commit comments

Comments
 (0)