Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Change Log
* Fix: `get` and `set` operator function names are no longer escaped with backticks. (#1869)
* Fix: Don't special case varargs in `KSAnnotation.toAnnotationSpec`. (#2360)
* Fix: Emit context parameters after annotations in `FunSpec` and `PropertySpec`. (#2374)
* Fix: An expression body no longer leaks indentation into later declarations. (#1421)

## Version 2.3.0

Expand Down
116 changes: 115 additions & 1 deletion kotlinpoet/src/jvmMain/kotlin/com/squareup/kotlinpoet/CodeBlock.kt
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,34 @@ private constructor(internal val formatParts: List<String>, internal val args: L
/**
* Returns a copy of the code block without leading and trailing no-arg placeholders (`⇥`, `⇤`,
* `«`, `»`).
*
* With [keepBalanced], placeholders are taken back from either end until nothing left in the
* block is unmatched, which would otherwise leak the indent or the statement into whatever is
* emitted next. A body ending inside `withIndent` loses the `⇤` that closed it, e.g.:
* ```
* ["return ", "%S", "⇥", ".trim()", "⇤"]
* trim() -> ["return ", "%S", "⇥", ".trim()"]
* trim(keepBalanced = true) -> ["return ", "%S", "⇥", ".trim()", "⇤"]
* ```
*
* Both ends move when both are unmatched, e.g.:
* ```
* ["⇥", "body1", "⇤", "⇥", "body2", "⇤"]
* trim() -> ["body1", "⇤", "⇥", "body2"]
* trim(keepBalanced = true) -> ["⇥", "body1", "⇤", "⇥", "body2", "⇤"]
* ```
*
* A block that was already unbalanced before trimming is left to the plain behavior, since taking
* one end back cannot balance it and moves the following declaration further out, e.g.:
* ```
* ["⇥", "return ", "%S", "⇤", "⇥", ".trim()"]
* trim() -> ["return ", "%S", "⇤", "⇥", ".trim()"]
* trim(keepBalanced = true) -> ["return ", "%S", "⇤", "⇥", ".trim()"]
* ```
*
* [trimTrailingNewLine] relies on the default, which always drops both runs.
*/
internal fun trim(): CodeBlock {
internal fun trim(keepBalanced: Boolean = false): CodeBlock {
var start = 0
var end = formatParts.size
while (start < end && formatParts[start] in NO_ARG_PLACEHOLDERS) {
Expand All @@ -129,6 +155,79 @@ private constructor(internal val formatParts: List<String>, internal val args: L
while (start < end && formatParts[end - 1] in NO_ARG_PLACEHOLDERS) {
end--
}
if (keepBalanced && (start > 0 || end < formatParts.size)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It took me a while to fully grasp what's going on here, and something that I think could help is making the naming a bit less ambiguous, I've got a few suggestions:

  1. First, let's rename start and end above to keepFrom and keepUntil. Simpler naming was fine while this method was trivial, now it heavily clashes with first, last and keptEnd making the whole thing hard to grok.
  2. Similarly, let's rename keptEnd to keepPlaceholdersUntil.
  3. Let's also introduce keepPlaceholdersFrom instead of using keepFrom. While it's convenient that they have the same value, IMO it's better to have a separate pointer for a separate array.
  4. We can probably squash first and last into a single variable called pointer or just i, but don't feel strongly here.

// Both stripped runs are placeholders by definition, so they are the first `start` and the
// last `formatParts.size - end` entries of this list, and the kept range is what sits
// between them. Everything below walks placeholders rather than format parts, which keeps
// the balance check to a single pass over the parts.
val placeholders = CharArray(formatParts.size)
var placeholderCount = 0
for (formatPart in formatParts) {
if (formatPart.length == 1 && formatPart[0].isSingleCharNoArgPlaceholder) {
placeholders[placeholderCount++] = formatPart[0]
}
}
val keptEnd = placeholderCount - (formatParts.size - end)
if (isBalanced(placeholders, placeholderCount)) {
// Track the lowest point reached, not just the running total. Two unrelated halves can
// cancel out to a total of zero and still emit an unindent ahead of its indent.
var indentLow = 0
var indentTotal = 0
var statementLow = 0
var statementTotal = 0
for (i in start..<keptEnd) {
when (placeholders[i]) {
'⇥' -> indentTotal++
'⇤' -> indentTotal--
'«' -> statementTotal++
'»' -> statementTotal--
}
if (indentTotal < indentLow) indentLow = indentTotal
if (statementTotal < statementLow) statementLow = statementTotal
}
var first = start
while (first > 0 && (indentLow < 0 || statementLow < 0)) {
when (placeholders[first - 1]) {
'⇥' -> {
indentLow++
indentTotal++
}
'⇤' -> {
indentLow--
indentTotal--
}
'«' -> {
statementLow++
statementTotal++
}
'»' -> {
statementLow--
statementTotal--
}
}
first--
if (indentLow >= 0 && statementLow >= 0) {
start = first
break
}
}
var last = keptEnd
while (last < placeholderCount && (indentTotal > 0 || statementTotal > 0)) {
when (placeholders[last]) {
'⇥' -> indentTotal++
'⇤' -> indentTotal--
'«' -> statementTotal++
'»' -> statementTotal--
}
if (indentTotal < 0 || statementTotal < 0) break

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a test case where this statement will evaluate to true? All tests still pass if I comment it out.

last++
if (indentTotal == 0 && statementTotal == 0) {
end += last - keptEnd
break
}
}
}
}
return when {
start > 0 || end < formatParts.size -> CodeBlock(formatParts.subList(start, end), args)
else -> this
Expand All @@ -148,6 +247,21 @@ private constructor(internal val formatParts: List<String>, internal val args: L

internal fun hasStatements() = formatParts.any { "«" in it }

private fun isBalanced(placeholders: CharArray, count: Int): Boolean {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's move this helper inside trim()?

var indent = 0
var statement = 0
for (i in 0..<count) {
when (placeholders[i]) {
'⇥' -> indent++
'⇤' -> indent--
'«' -> statement++
'»' -> statement--
}
if (indent < 0 || statement < 0) return false
}
return indent == 0 && statement == 0
}

internal fun hasUnmatchedClosingStatement(): Boolean {
var openCount = 0
for (formatPart in formatParts) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ private constructor(
}

private fun CodeBlock.asExpressionBody(): CodeBlock? {
val codeBlock = this.trim()
val codeBlock = this.trim(keepBalanced = true)

// If after trimming there are unmatched closing statement symbols, we can't have an expression
// body.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,13 @@ class CodeBlockTest {
assertThat(CodeBlock.of("«»⇥⇤").trim()).isEqualTo(CodeBlock.of(""))
}

@Test
fun trimKeepsAStatementAndItsIndentTogether() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: maybe trimKeepsStatementsAndIndentsBalanced?

val codeBlock = CodeBlock.of("«⇥return %S⇤».trim()", "taco")

assertThat(codeBlock.trim(keepBalanced = true)).isEqualTo(codeBlock)
}

@Test
fun replaceSimple() {
assertThat(CodeBlock.of("%%⇥%%").replaceAll("%%", "")).isEqualTo(CodeBlock.of("⇥"))
Expand Down Expand Up @@ -631,6 +638,23 @@ class CodeBlockTest {
)
}

@Test
fun ensureEndsWithNewLineKeepsArgsWhenBlockEndsInIndent() {
val codeBlock =
CodeBlock.builder().add("%S", "taco\n").indent().add("\nmore").unindent().build()

assertThat(codeBlock.ensureEndsWithNewLine().toString())
.isEqualTo(
"""
|""${'"'}
||taco
||""${'"'}.trimMargin()
| more
|"""
.trimMargin()
)
}

@Test
fun `N escapes keywords`() {
val funSpec = FunSpec.builder("object").build()
Expand Down
Loading