Skip to content
Merged
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
4 changes: 4 additions & 0 deletions Sources/SkipSyntax/Kotlin/KotlinExpressionTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,9 @@ final class KotlinBinaryOperator: KotlinExpression, KotlinSingleStatementVetoing
var lhs: KotlinExpression
var rhs: KotlinExpression
var mayBeSharedMutableStruct = false
/// The inferred result type of the operator expression, used e.g. to decide whether a statement-level
/// operator that produces a View (such as `Text + Text`) needs a Compose tail call.
var inferredType: TypeSignature = .none

static func translate(expression: BinaryOperator, translator: KotlinTranslator) -> KotlinExpression {
// Special case when assigning to _
Expand All @@ -209,6 +212,7 @@ final class KotlinBinaryOperator: KotlinExpression, KotlinSingleStatementVetoing

let kexpression = KotlinBinaryOperator(expression: expression, lhs: klhs, rhs: krhs)
kexpression.mayBeSharedMutableStruct = expression.inferredType.kotlinMayBeSharedMutableStruct(codebaseInfo: translator.codebaseInfo)
kexpression.inferredType = expression.inferredType.resolvingSelf(in: expression)

switch expression.op.symbol {
case "<<=", ">>=", "&=", "|=", "^=", "~=":
Expand Down
16 changes: 16 additions & 0 deletions Sources/SkipSyntax/Kotlin/KotlinSwiftUITransformer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,22 @@ private final class TranslateVisitor {
} else {
return .recurse(nil)
}
} else if let binaryOperator = node as? KotlinBinaryOperator {
var parent = node.parent
if parent is KotlinSRef {
parent = parent?.parent
}
// A statement-level operator expression that evaluates to a View — most notably
// `Text + Text` concatenation — is built into the TupleView like any other view and
// must get a Compose tail call. Wrap it in parentheses so `.Compose` applies to the
// whole operator result rather than binding to its right-hand operand.
if let expressionStatement = parent as? KotlinExpressionStatement, !isInAssignmentExpression(expressionStatement, in: codeBlock),
isSwiftUIType(named: "View", type: binaryOperator.inferredType, codebaseInfo: translator.codebaseInfo) {
addComposeTailCall(to: KotlinParenthesized(content: binaryOperator), statement: expressionStatement)
return .skip
} else {
return .recurse(nil)
}
} else {
return .recurse(nil)
}
Expand Down
42 changes: 42 additions & 0 deletions Tests/SkipSyntaxTests/SwiftUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2884,4 +2884,46 @@ final class SwiftUITests: XCTestCase {
}
""")
}

func testViewProducingOperatorTailCall() async throws {
// A statement-level operator expression that evaluates to a View (e.g. `Text + Text`
// concatenation) must get a Compose tail call, wrapped in parentheses so `.Compose`
// applies to the whole operator result rather than its right-hand operand.
let supportingSwift = baseSupportingSwift + """
extension Text {
// SKIP DECLARE: operator fun plus(other: Text): Text
func plus(other: Text) -> Text {
}
}
"""

try await check(supportingSwift: supportingSwift, swift: """
import SwiftUI
func f() {
VStack {
Text("a") + Text("b")
}
}
""", kotlin: """
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue

import skip.ui.*
import skip.foundation.*
import skip.model.*
internal fun f() {
VStack { ->
ComposeBuilder { composectx: ComposeContext ->
(Text("a") + Text("b")).Compose(composectx)
ComposeResult.ok
}
}
}
""")
}
}