Are `FunctionCallExprSyntax` and `LabeledExprListSyntax` supposed to synthesize trailing commas?

Hi! I'm experimenting with a macro that is using FunctionCallExprSyntax to programmatically generate a function call. I'm seeing some unexpected behavior with trailing commas that looks different between release and main (from the swift-syntax repo). Any ideas if this behavior is intended?

I start with a simple test:

final class FooTests: XCTestCase {
  static let parameters = [
    TokenSyntax.identifier("bar"),
    TokenSyntax.identifier("baz"),
  ]
}

extension FooTests {
  static func call(
    with arguments: LabeledExprListSyntax
  ) -> FunctionCallExprSyntax {
    FunctionCallExprSyntax(
      calledExpression: DeclReferenceExprSyntax(
        baseName: .identifier("foo")
      ),
      leftParen: .leftParenToken(),
      arguments: arguments,
      rightParen: .rightParenToken()
    )
  }
}

extension FooTests {
  public func testFooMainWithoutComma() {
    let call = Self.call(
      with: LabeledExprListSyntax {
        //  FIXME: Cannot convert value of type '[LabeledExprSyntax]' to expected argument type 'LabeledExprListBuilder.Expression' (aka 'LabeledExprSyntax')
        Self.parameters.map { parameter in
          LabeledExprSyntax(
            label: parameter,
            colon: .colonToken(),
            expression: DeclReferenceExprSyntax(
              baseName: parameter
            ),
            trailingComma: nil
          )
        }
      }
    )
    
    XCTAssertEqual("\(call)", "foo(bar:bar,baz:baz)")
  }
}

This test compiled (and passed) when building from main… but failed to compile from release. I explicitly pass nil as a trailingComma to LabeledExprSyntax… but I'm getting my expected result and my test is passing.

Here's my next test:

extension FooTests {
  public func testFooMainWithComma() {
    let call = Self.call(
      with: LabeledExprListSyntax {
        //  FIXME: Cannot convert value of type '[LabeledExprSyntax]' to expected argument type 'LabeledExprListBuilder.Expression' (aka 'LabeledExprSyntax')
        Self.parameters.map { parameter in
          LabeledExprSyntax(
            label: parameter,
            colon: .colonToken(),
            expression: DeclReferenceExprSyntax(
              baseName: parameter
            ),
            trailingComma: .commaToken()
          )
        }
      }
    )
    
    //  FIXME: XCTAssertEqual failed: ("foo(bar:bar,baz:baz,)") is not equal to ("foo(bar:bar,baz:baz)")
    XCTAssertEqual("\(call)", "foo(bar:bar,baz:baz)")
  }
}

This test compiled (and failed) when building from main (and failed to compile from release). I am explicitly passing a commaToken to trailingComma and the infra is giving me a comma after every LabeledExprSyntax.

I can try a different approach to compile from release:

extension FooTests {
  func testFooReleaseWithoutComma() {
    let call = Self.call(
      with: LabeledExprListSyntax(
        Self.parameters.map { parameter in
          LabeledExprSyntax(
            label: parameter,
            colon: .colonToken(),
            expression: DeclReferenceExprSyntax(
              baseName: parameter
            ),
            trailingComma: nil
          )
        }
      )
    )
    
    //  FIXME: XCTAssertEqual failed: ("foo(bar:barbaz:baz)") is not equal to ("foo(bar:bar,baz:baz)")
    XCTAssertEqual("\(call)", "foo(bar:bar,baz:baz)")
  }
}

extension FooTests {
  func testFooReleaseWithComma() {
    let call = Self.call(
      with: LabeledExprListSyntax(
        Self.parameters.map { parameter in
          LabeledExprSyntax(
            label: parameter,
            colon: .colonToken(),
            expression: DeclReferenceExprSyntax(
              baseName: parameter
            ),
            trailingComma: .commaToken()
          )
        }
      )
    )
    
    //  FIXME: XCTAssertEqual failed: ("foo(bar:bar,baz:baz,)") is not equal to ("foo(bar:bar,baz:baz)")
    XCTAssertEqual("\(call)", "foo(bar:bar,baz:baz)")
  }
}

Both those tests fail… passing a nil for trailingComma to testFooReleaseWithoutComma fails… but passing a nil for trailingComma to testFooMainWithoutComma passed.

I can try a different approach to build from release and also pass a test:

extension FooTests {
  func testFooReleaseWithConditionalComma() {
    let call = Self.call(
      with: LabeledExprListSyntax(
        Self.parameters.map { parameter in
          LabeledExprSyntax(
            label: parameter,
            colon: .colonToken(),
            expression: DeclReferenceExprSyntax(
              baseName: parameter
            ),
            trailingComma: (parameter == Self.parameters.last ? nil : .commaToken())
          )
        }
      )
    )
    
    XCTAssertEqual("\(call)", "foo(bar:bar,baz:baz)")
  }
}

This seems to unblock me… but I'm also open to suggestions if there is another legit way to achieve this same behavior. I have to assume that the Self.parameters could change depending on where this macro is used… so I don't have an easy way to just hard-code the "function string" directly in my codegen.

Any ideas what is happening here?

  • Why is not passing a comma passing my test in testFooMainWithoutComma?
  • Why is not passing a comma failing my test in testFooReleaseWithoutComma?

Is this the intended behavior? Could I expect main to continue behaving like this… or this is a regression that should be changed to be consistent with release?

Thanks!

The behavior you describe is expected.

  1. The ability to use arrays in result builder initializers is a somewhat recent addition. I don’t have the PR at hand but it’s expected that these won’t exist in the existing releases (this is the compilation error in your first example)
  2. In the second test case you are explicitly specifying that each LabeledExprSyntax should have a trailing comma, so that’s why you’re getting the trailing comma. This explains the test failure of your second example. The compilation error when using the SwiftSyntax release is still (1).
  3. The third case doesn’t use the result builder initializer. The result builder initializer is the one that adds the trailing comma to all elements (swift-syntax/Sources/SwiftSyntaxBuilder/ListBuilder.swift at 7233193eea66bc83d6a38d8e742758587c9f8980 · apple/swift-syntax · GitHub), so that’s why you aren’t getting intermediate commas between the elements. We could probably improve the documentation here.

The following should work both in the swift-syntax releases and main

LabeledExprListSyntax {
  for parameter in Self.parameters {
    LabeledExprSyntax(
      label: parameter,
      colon: .colonToken(),
      expression: DeclReferenceExprSyntax(
        baseName: parameter
      ),
      trailingComma: .commaToken()
    )
  }
}

4 Likes

Ahh… for each instead of map! Good idea. Thanks!

extension FooTests {
  func testFooReleaseForEachWithoutComma() {
    let call = Self.call(
      with: LabeledExprListSyntax {
        for parameter in Self.parameters {
          LabeledExprSyntax(
            label: parameter,
            colon: .colonToken(),
            expression: DeclReferenceExprSyntax(
              baseName: parameter
            ),
            trailingComma: nil
          )
        }
      }
    )
    
    XCTAssertEqual("\(call)", "foo(bar:bar,baz:baz)")
  }
}

This test passes for release and main from passing a nil to trailingComma. The infra is correctly synthesizing the intermediate commas.

If you wanna utilize result builder you have to use For-in loop.

1 Like