diff --git a/Sources/SkipSyntax/Kotlin/KotlinExpressionTypes.swift b/Sources/SkipSyntax/Kotlin/KotlinExpressionTypes.swift index cea0452e..4378fa14 100644 --- a/Sources/SkipSyntax/Kotlin/KotlinExpressionTypes.swift +++ b/Sources/SkipSyntax/Kotlin/KotlinExpressionTypes.swift @@ -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 _ @@ -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 "<<=", ">>=", "&=", "|=", "^=", "~=": diff --git a/Sources/SkipSyntax/Kotlin/KotlinSwiftUITransformer.swift b/Sources/SkipSyntax/Kotlin/KotlinSwiftUITransformer.swift index 99666b94..22907443 100644 --- a/Sources/SkipSyntax/Kotlin/KotlinSwiftUITransformer.swift +++ b/Sources/SkipSyntax/Kotlin/KotlinSwiftUITransformer.swift @@ -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) } diff --git a/Tests/SkipSyntaxTests/SwiftUITests.swift b/Tests/SkipSyntaxTests/SwiftUITests.swift index 290697dc..d1174697 100644 --- a/Tests/SkipSyntaxTests/SwiftUITests.swift +++ b/Tests/SkipSyntaxTests/SwiftUITests.swift @@ -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 + } + } + } + """) + } }